mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Apply the reviewed change for Keep proven external connection failures out of Sentry. Validation: local typecheck/build and focused regression tests, passing exact-head CI, Greptile 5/5, and independent source review. The PR records full-suite evidence and any local environment limitations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
3347 lines
115 KiB
TypeScript
3347 lines
115 KiB
TypeScript
import { execFile as execFileCallback } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { agents } from "@paperclipai/db";
|
|
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
|
import {
|
|
applyPersistedExecutionWorkspaceConfig,
|
|
assertGitSensitiveAdapterWorkspaceValid,
|
|
assertGitWorktreeBaseWorkspaceReady,
|
|
assertPushCapabilityCheckoutValid,
|
|
buildExplicitResumeSessionOverride,
|
|
buildEffectiveRunSessionConfigMetadata,
|
|
buildEffectiveRunWorkspaceConfigMetadata,
|
|
buildWorkspaceConfigFreshnessOperation,
|
|
deriveTaskKeyWithHeartbeatFallback,
|
|
formatRuntimeWorkspaceWarningLog,
|
|
mergeExecutionWorkspaceMetadataForPersistence,
|
|
mergeCoalescedContextSnapshot,
|
|
preflightLowTrustWorkspaceIsolation,
|
|
prioritizeProjectWorkspaceCandidatesForRun,
|
|
parseSessionCompactionPolicy,
|
|
provisionExecutionWorkspaceForFreshnessDecision,
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId,
|
|
resolveNativeRecoveryExecutionWorkspaceBinding,
|
|
resolveExecutionWorkspaceBranchOwnership,
|
|
resolveExecutionWorkspaceConfigFreshness,
|
|
resolveExecutionWorkspaceReuseRequestForIssue,
|
|
resolveExecutionWorkspaceReuseProvisioningPolicy,
|
|
resolveNextSessionState,
|
|
resolveTaskSessionConfigFreshness,
|
|
isWorkspaceSyncConflictFailure,
|
|
requiresPushCapabilityPreflight,
|
|
resolveWorkspaceAfterLowTrustPreflight,
|
|
resolveRuntimeSessionParamsForWorkspace,
|
|
shouldDeferFollowupWakeForSameIssue,
|
|
stripHostWorkspaceProvisionForLowTrustSandbox,
|
|
stripWorkspaceRuntimeFromExecutionRunConfig,
|
|
shouldResetTaskSessionForModelChange,
|
|
stripConfiguredModelFromSessionParams,
|
|
stripPaperclipSessionMetadataFromSessionParams,
|
|
normalizeSessionParams,
|
|
isTaskSessionCredentialCompatible,
|
|
shouldResetTaskSessionForWake,
|
|
scrubGitCredentialText,
|
|
buildAnchorFallbackWorkspaceNotes,
|
|
type ResolvedWorkspaceForRun,
|
|
} from "../services/heartbeat.ts";
|
|
import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
|
|
return {
|
|
cwd: "/tmp/project",
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
workspaceHints: [],
|
|
warnings: [],
|
|
baseCwdFallback: false,
|
|
materializationFailures: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
type WorkspaceValidationInput = Parameters<typeof assertGitSensitiveAdapterWorkspaceValid>[0];
|
|
|
|
function buildWorkspaceValidationInput(
|
|
overrides: Partial<WorkspaceValidationInput> = {},
|
|
): WorkspaceValidationInput {
|
|
return {
|
|
adapterType: "codex_local",
|
|
agentId: "agent-1",
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace(),
|
|
executionWorkspace: {
|
|
baseCwd: "/tmp/project",
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
strategy: "project_primary",
|
|
cwd: "/tmp/project",
|
|
branchName: null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
baseRefSha: null,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
id: "execution-workspace-1",
|
|
companyId: "company-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
sourceIssueId: "issue-1",
|
|
mode: "project_workspace",
|
|
strategyType: "project_primary",
|
|
name: "Primary workspace",
|
|
status: "active",
|
|
cwd: "/tmp/project",
|
|
repoUrl: null,
|
|
baseRef: null,
|
|
branchName: null,
|
|
providerType: "local_path",
|
|
providerRef: null,
|
|
derivedFromExecutionWorkspaceId: null,
|
|
lastUsedAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
openedAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
closedAt: null,
|
|
cleanupEligibleAt: null,
|
|
cleanupReason: null,
|
|
config: null,
|
|
metadata: null,
|
|
createdAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-06T00:00:00.000Z"),
|
|
},
|
|
executionTarget: { kind: "local" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function runGit(cwd: string, args: string[]) {
|
|
await execFile("git", args, { cwd });
|
|
}
|
|
|
|
async function createGitCheckout(options: { withRemote: boolean }) {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-push-preflight-"));
|
|
await runGit(root, ["init"]);
|
|
if (options.withRemote) {
|
|
await runGit(root, ["remote", "add", "origin", "https://github.com/example/repo.git"]);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
async function expectWorkspaceValidationFailure(
|
|
input: WorkspaceValidationInput,
|
|
reason: string,
|
|
message: string,
|
|
) {
|
|
await expect(assertGitSensitiveAdapterWorkspaceValid(input)).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining(message),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason,
|
|
adapterType: input.adapterType,
|
|
issueId: input.issue?.id,
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
projectId: null,
|
|
goalId: null,
|
|
name: "Agent",
|
|
role: "engineer",
|
|
title: null,
|
|
icon: null,
|
|
status: "running",
|
|
reportsTo: null,
|
|
capabilities: null,
|
|
adapterType,
|
|
adapterConfig: {},
|
|
runtimeConfig,
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
permissions: {},
|
|
lastHeartbeatAt: null,
|
|
metadata: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
} as unknown as typeof agents.$inferSelect;
|
|
}
|
|
|
|
const hermesSessionCodec = {
|
|
deserialize(raw: unknown) {
|
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
const record = raw as Record<string, unknown>;
|
|
const sessionId = typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId.trim() : null;
|
|
return sessionId ? { sessionId } : null;
|
|
},
|
|
serialize(params: Record<string, unknown> | null) {
|
|
if (!params) return null;
|
|
const sessionId = typeof params.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
|
|
return sessionId ? { sessionId } : null;
|
|
},
|
|
getDisplayId(params: Record<string, unknown> | null) {
|
|
return typeof params?.sessionId === "string" && params.sessionId.trim() ? params.sessionId.trim() : null;
|
|
},
|
|
};
|
|
|
|
const truncatingHermesSessionCodec = {
|
|
...hermesSessionCodec,
|
|
getDisplayId(params: Record<string, unknown> | null) {
|
|
const sessionId = hermesSessionCodec.getDisplayId(params);
|
|
return sessionId ? sessionId.slice(0, 16) : null;
|
|
},
|
|
};
|
|
|
|
function lowTrustResolution(): TrustPresetResolution {
|
|
return {
|
|
kind: "low_trust_review",
|
|
preset: "low_trust_review",
|
|
boundary: {
|
|
mode: "low_trust_review",
|
|
companyId: "company-1",
|
|
rootIssueId: "issue-1",
|
|
},
|
|
sourcePresets: { agent: "low_trust_review" },
|
|
};
|
|
}
|
|
|
|
function standardTrustResolution(): TrustPresetResolution {
|
|
return {
|
|
kind: "standard",
|
|
preset: "standard",
|
|
boundary: null,
|
|
sourcePresets: {},
|
|
};
|
|
}
|
|
|
|
function buildIssueAncestryDb(rows: Array<{ id: string; companyId: string; parentId: string | null }>) {
|
|
const queue = [...rows];
|
|
return {
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => {
|
|
const row = queue.shift();
|
|
return Promise.resolve(row ? [row] : []);
|
|
},
|
|
}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("assertGitSensitiveAdapterWorkspaceValid", () => {
|
|
it("rejects a project-workspace-linked issue that is missing its project id before adapter launch", async () => {
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
}),
|
|
"missing_project_id",
|
|
"linked to a project workspace but has no project id",
|
|
);
|
|
});
|
|
|
|
it("rejects a git-sensitive local adapter when effective cwd differs from the persisted workspace cwd", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: "/tmp/agent-fallback",
|
|
},
|
|
}),
|
|
"persisted_cwd_mismatch",
|
|
'resolved adapter cwd "/tmp/agent-fallback"',
|
|
);
|
|
});
|
|
|
|
it("rejects a workspace-linked issue when no execution workspace was persisted", async () => {
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
persistedExecutionWorkspace: null,
|
|
}),
|
|
"missing_persisted_execution_workspace",
|
|
"requires a project execution workspace",
|
|
);
|
|
});
|
|
|
|
it("rejects a workspace-linked issue when no effective adapter cwd was resolved", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: null,
|
|
},
|
|
}),
|
|
"missing_effective_cwd",
|
|
"no adapter cwd was resolved",
|
|
);
|
|
});
|
|
|
|
it("rejects a persisted execution workspace linked to a different project workspace", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
projectWorkspaceId: "workspace-other",
|
|
},
|
|
}),
|
|
"project_workspace_mismatch",
|
|
'expected project workspace "workspace-1"',
|
|
);
|
|
});
|
|
|
|
it("rejects a persisted execution workspace missing its project workspace id", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
projectWorkspaceId: null,
|
|
},
|
|
}),
|
|
"persisted_workspace_missing_project_workspace_id",
|
|
"has no project workspace id",
|
|
);
|
|
});
|
|
|
|
it("rejects a workspace-linked issue that would launch from the agent fallback cwd", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: fallbackCwd,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
cwd: fallbackCwd,
|
|
},
|
|
}),
|
|
"fallback_agent_home_cwd",
|
|
"would launch from agent fallback cwd",
|
|
);
|
|
});
|
|
|
|
it("rejects a git worktree persisted workspace when cwd differs from providerRef", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
strategy: "git_worktree",
|
|
cwd: "/tmp/worktree-current",
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
strategyType: "git_worktree",
|
|
cwd: "/tmp/worktree-current",
|
|
providerRef: "/tmp/worktree-expected",
|
|
},
|
|
}),
|
|
"git_worktree_provider_ref_mismatch",
|
|
'expected git worktree "/tmp/worktree-expected"',
|
|
);
|
|
});
|
|
|
|
it("rejects a git worktree persisted workspace when the checked-out branch differs from the recorded branch", async () => {
|
|
const repoRoot = await createGitCheckout({ withRemote: false });
|
|
const worktreeParent = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-branch-worktree-"));
|
|
const worktreePath = path.join(worktreeParent, "workspace");
|
|
const recordedBranch = "PAP-1-recorded-branch";
|
|
const actualBranch = "PAP-1-push-pr-head";
|
|
try {
|
|
await runGit(repoRoot, ["config", "user.email", "test@example.com"]);
|
|
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
|
await fs.writeFile(path.join(repoRoot, "README.md"), "initial\n", "utf8");
|
|
await runGit(repoRoot, ["add", "README.md"]);
|
|
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
|
await runGit(repoRoot, ["worktree", "add", "-b", recordedBranch, worktreePath, "HEAD"]);
|
|
await runGit(worktreePath, ["checkout", "-b", actualBranch]);
|
|
|
|
const input = buildWorkspaceValidationInput();
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd: worktreePath }),
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
strategy: "git_worktree",
|
|
baseCwd: repoRoot,
|
|
cwd: worktreePath,
|
|
branchName: recordedBranch,
|
|
worktreePath,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
strategyType: "git_worktree",
|
|
cwd: worktreePath,
|
|
providerType: "git_worktree",
|
|
providerRef: worktreePath,
|
|
branchName: recordedBranch,
|
|
},
|
|
}),
|
|
"git_worktree_branch_mismatch",
|
|
`expected git worktree branch "${recordedBranch}"`,
|
|
);
|
|
} finally {
|
|
await fs.rm(repoRoot, { recursive: true, force: true });
|
|
await fs.rm(worktreeParent, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects a workspace-linked issue when adapter cwd has no git metadata", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
const cwd = "/tmp/paperclip-workspace-without-git-metadata";
|
|
|
|
await expectWorkspaceValidationFailure(
|
|
buildWorkspaceValidationInput({
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd }),
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
baseCwd: cwd,
|
|
cwd,
|
|
},
|
|
persistedExecutionWorkspace: {
|
|
...input.persistedExecutionWorkspace!,
|
|
cwd,
|
|
},
|
|
}),
|
|
"missing_git_metadata",
|
|
"has no .git metadata",
|
|
);
|
|
});
|
|
|
|
it("does not apply the git-sensitive workspace guard to non-local execution targets", async () => {
|
|
const input = buildWorkspaceValidationInput();
|
|
|
|
await expect(
|
|
assertGitSensitiveAdapterWorkspaceValid(
|
|
buildWorkspaceValidationInput({
|
|
executionTarget: { kind: "cloud" },
|
|
executionWorkspace: {
|
|
...input.executionWorkspace,
|
|
cwd: "/tmp/agent-fallback",
|
|
},
|
|
}),
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("assertGitWorktreeBaseWorkspaceReady", () => {
|
|
it.each(["all_external", "mixed", "malformed"])("keeps materialization aggregation fail-closed: %s", async (kind) => {
|
|
const first = { schemaVersion: 1, provider: "git", operation: "clone", reason: "authentication_failed" } as const;
|
|
const second = kind === "all_external" ? { ...first, reason: "dns_failure" as const } :
|
|
kind === "malformed" ? { ...first, reason: "unknown" } : undefined;
|
|
const failures = [first, second].map((connectionFailure, index) => ({
|
|
projectWorkspaceId: `workspace-${index}`, repoUrl: "https://example.test/team/repo.git",
|
|
error: "Managed checkout failed", ...(connectionFailure ? { connectionFailure } : {}),
|
|
}));
|
|
const error = await assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: { id: "issue-1", identifier: "TEST-1", projectId: "project-1", projectWorkspaceId: "workspace-0" },
|
|
base: { baseCwd: "/tmp/unused-fallback", source: "project_primary", projectId: "project-1", workspaceId: "workspace-0", repoUrl: "https://example.test/team/repo.git", repoRef: null },
|
|
anchor: { baseCwdFallback: true, materializationFailures: failures as never },
|
|
}).catch((error: unknown) => error);
|
|
expect(error).toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
resultJson: { workspaceValidation: { reason: "git_worktree_base_materialization_failed", materializationFailures: failures } },
|
|
});
|
|
expect((error as { resultJson: Record<string, unknown> }).resultJson.connectionFailure)
|
|
.toEqual(kind === "all_external" ? first : undefined);
|
|
});
|
|
|
|
it("rejects projectless isolated git worktrees that resolved to agent_home", async () => {
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: "isolated_workspace",
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("needs a project / project workspace or a reusable execution workspace"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_agent_home",
|
|
issueId: "issue-1",
|
|
resolvedWorkspaceSource: "agent_home",
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
workspaceStrategyType: "git_worktree",
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects operator-branch git worktrees that resolved to agent_home", async () => {
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "operator_branch",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_agent_home",
|
|
requestedExecutionWorkspaceMode: "operator_branch",
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects isolated git worktrees when the resolved base is not a git checkout", async () => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-non-git-workspace-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("is not a git checkout"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_not_git_checkout",
|
|
issueId: "issue-1",
|
|
resolvedWorkspaceSource: "project_primary",
|
|
resolvedWorkspaceCwd: cwd,
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function configuredLocalPathInput(cwd: string): Parameters<typeof assertGitWorktreeBaseWorkspaceReady>[0] {
|
|
return {
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: { id: "issue-1", identifier: null, projectId: "project-1", projectWorkspaceId: "workspace-1" },
|
|
base: { baseCwd: cwd, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: null },
|
|
anchor: { localPathOnlyWorkspace: true, baseCwdFallback: false, materializationFailures: [] },
|
|
};
|
|
}
|
|
|
|
it("marks a proven non-Git local path as an owner configuration conflict without changing the failure", async () => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-local-path-policy-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady(configuredLocalPathInput(cwd))).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("is not a git checkout"),
|
|
resultJson: { workspaceValidation: {
|
|
reason: "git_worktree_base_not_git_checkout",
|
|
configurationReason: "local_path_requires_git_checkout",
|
|
} },
|
|
});
|
|
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it.each(["legacy", "repository", "fallback", "materialization", "session", "unbound"])(
|
|
"does not mark ambiguous non-Git workspace evidence as a configuration-only failure: %s", async (kind) => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ambiguous-workspace-"));
|
|
try {
|
|
const input = configuredLocalPathInput(cwd);
|
|
if (kind === "legacy") input.anchor!.localPathOnlyWorkspace = undefined;
|
|
if (kind === "repository") input.base.repoUrl = "https://example.com/repository.git";
|
|
if (kind === "fallback") input.anchor!.baseCwdFallback = true;
|
|
if (kind === "materialization") input.anchor!.materializationFailures = [{ projectWorkspaceId: "another-workspace", repoUrl: null, error: "checkout failed" }];
|
|
if (kind === "session") input.base.source = "task_session";
|
|
if (kind === "unbound") input.base.workspaceId = null;
|
|
const error = await assertGitWorktreeBaseWorkspaceReady(input).catch((error) => error);
|
|
expect(error.code).toBe("workspace_validation_failed");
|
|
expect(error.resultJson.workspaceValidation.configurationReason).toBeUndefined();
|
|
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
|
|
},
|
|
);
|
|
|
|
it.each(["missing_git", "permission", "git_error", "killed", "missing_directory"])(
|
|
"does not classify a Git or filesystem failure as an owner-only configuration mismatch: %s", async (kind) => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-probe-failure-"));
|
|
const bin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-probe-bin-"));
|
|
try {
|
|
if (kind !== "missing_git" && kind !== "missing_directory") {
|
|
const command = kind === "killed"
|
|
? "kill -TERM $$"
|
|
: kind === "permission"
|
|
? "printf '%s\\n' 'fatal: cannot open .git: Permission denied' >&2; exit 128"
|
|
: "printf '%s\\n' 'fatal: bad object HEAD' >&2; exit 128";
|
|
await fs.writeFile(path.join(bin, "git"), `#!/bin/sh\n${command}\n`, { mode: 0o755 });
|
|
}
|
|
if (kind !== "missing_directory") vi.stubEnv("PATH", bin);
|
|
const input = configuredLocalPathInput(kind === "missing_directory" ? path.join(cwd, "absent") : cwd);
|
|
const error = await assertGitWorktreeBaseWorkspaceReady(input).catch((error) => error);
|
|
expect(error.code).toBe("workspace_validation_failed");
|
|
expect(error.resultJson.workspaceValidation.configurationReason).toBeUndefined();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
await fs.rm(bin, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("still accepts a configured local path that is a Git checkout", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try { await expect(assertGitWorktreeBaseWorkspaceReady(configuredLocalPathInput(cwd))).resolves.toBeUndefined(); }
|
|
finally { await fs.rm(cwd, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it.each(["empty_metadata", "missing_head", "damaged_head", "dangling_metadata", "ancestor_metadata", "bare_metadata"])(
|
|
"keeps real broken repository metadata reportable even when Git says not a repository: %s", async (kind) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-broken-local-repo-"));
|
|
try {
|
|
let cwd = root;
|
|
if (kind === "missing_head" || kind === "damaged_head") {
|
|
await execFile("git", ["init", root]);
|
|
if (kind === "missing_head") await fs.rm(path.join(root, ".git", "HEAD"));
|
|
else await fs.writeFile(path.join(root, ".git", "HEAD"), "damaged HEAD\n");
|
|
} else if (kind === "dangling_metadata") {
|
|
await fs.symlink(path.join(root, "missing-git-dir"), path.join(root, ".git"));
|
|
} else if (kind === "bare_metadata") {
|
|
await fs.mkdir(path.join(root, "objects"));
|
|
} else {
|
|
await fs.mkdir(path.join(root, ".git"));
|
|
if (kind === "ancestor_metadata") {
|
|
cwd = path.join(root, "documents");
|
|
await fs.mkdir(cwd);
|
|
}
|
|
}
|
|
const gitFailure = await execFile("git", ["rev-parse", "--show-toplevel"], {
|
|
cwd, env: { ...process.env, LC_ALL: "C" },
|
|
}).catch((error) => error);
|
|
expect(gitFailure.code).toBe(128);
|
|
expect(gitFailure.stderr).toMatch(/^fatal: not a git repository/);
|
|
const failure = await assertGitWorktreeBaseWorkspaceReady(configuredLocalPathInput(cwd)).catch((error) => error);
|
|
expect(failure.code).toBe("workspace_validation_failed");
|
|
expect(failure.resultJson.workspaceValidation.configurationReason).toBeUndefined();
|
|
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
|
},
|
|
);
|
|
|
|
it("rejects isolated git worktrees when the project workspace could not be materialized, even if the fallback cwd is a git checkout", async () => {
|
|
// The fallback agent-home dir being a git repo must not let the run proceed: it would be an
|
|
// unrelated repository, and the not-a-git-checkout probe would mask the real clone failure.
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private-repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
anchor: {
|
|
baseCwdFallback: true,
|
|
materializationFailures: [{
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private-repo.git",
|
|
error: 'Failed to prepare managed checkout for "https://github.com/example/private-repo.git": fatal: could not read Username',
|
|
}],
|
|
},
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("could not be prepared: Failed to prepare managed checkout"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_materialization_failed",
|
|
issueId: "issue-1",
|
|
baseCwdFallback: true,
|
|
materializationFailures: [expect.objectContaining({
|
|
projectWorkspaceId: "workspace-1",
|
|
error: expect.stringContaining("could not read Username"),
|
|
})],
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps the not-a-git-checkout reason for a fallback with no failed materialization attempt", async () => {
|
|
// A configured path that is simply unavailable is not a clone failure; the message must
|
|
// not steer the operator toward repairing clone access.
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-unavailable-path-fallback-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
anchor: { baseCwdFallback: true },
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("is not a git checkout"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_not_git_checkout",
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("refuses a git-checkout fallback cwd that is not the project workspace, without claiming a clone failed", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
anchor: { baseCwdFallback: true, materializationFailures: [] },
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("configured project workspace path is not available"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "git_worktree_base_fallback_not_project_workspace",
|
|
baseCwdFallback: true,
|
|
materializationFailures: [],
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows isolated git worktrees when the anchor reports no fallback", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
anchor: { baseCwdFallback: false, materializationFailures: [] },
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows isolated git worktrees when the resolved base is a git checkout", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/master",
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not require git for shared project-primary workspaces", async () => {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-shared-workspace-"));
|
|
try {
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "shared_workspace",
|
|
config: { workspaceStrategy: { type: "git_worktree" } },
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
},
|
|
base: {
|
|
baseCwd: cwd,
|
|
source: "project_primary",
|
|
projectId: "project-1",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows isolated workspace with no explicit strategy type even when base is agent_home", async () => {
|
|
// No workspaceStrategy.type → realizeExecutionWorkspace defaults to project_primary (not git_worktree),
|
|
// so the guard must not fire. This prevents false workspace_validation_failed for configs that omit type.
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "isolated_workspace",
|
|
config: {},
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("allows operator-branch workspace with no explicit strategy type even when base is agent_home", async () => {
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir("agent-1");
|
|
await expect(assertGitWorktreeBaseWorkspaceReady({
|
|
requestedExecutionWorkspaceMode: "operator_branch",
|
|
config: {},
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
},
|
|
base: {
|
|
baseCwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
repoUrl: null,
|
|
repoRef: null,
|
|
},
|
|
})).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("scrubGitCredentialText", () => {
|
|
it("masks URL userinfo including tokens", () => {
|
|
expect(scrubGitCredentialText(
|
|
'fatal: unable to access "https://x-access-token:ghp_abc123@github.com/example/repo.git"',
|
|
)).toBe('fatal: unable to access "https://***@github.com/example/repo.git"');
|
|
});
|
|
|
|
it("masks bare-username userinfo and multiple occurrences", () => {
|
|
expect(scrubGitCredentialText(
|
|
"clone https://alice@github.com/a.git then http://token@internal.example/b.git",
|
|
)).toBe("clone https://***@github.com/a.git then http://***@internal.example/b.git");
|
|
});
|
|
|
|
it("masks userinfo on non-HTTP schemes, leaving scp-style remotes alone", () => {
|
|
expect(scrubGitCredentialText(
|
|
"fatal: cannot clone ssh://deploy:hunter2@internal.example/repo.git or git+ssh://bob@host/x.git",
|
|
)).toBe("fatal: cannot clone ssh://***@internal.example/repo.git or git+ssh://***@host/x.git");
|
|
expect(scrubGitCredentialText("fetch from git@github.com:example/repo.git failed")).toBe(
|
|
"fetch from git@github.com:example/repo.git failed",
|
|
);
|
|
});
|
|
|
|
it("masks entire URL query strings regardless of parameter names", () => {
|
|
expect(scrubGitCredentialText(
|
|
"fatal: unable to access 'https://github.com/example/repo.git?access_token=ghs_secret&ref=main'",
|
|
)).toBe("fatal: unable to access 'https://github.com/example/repo.git?***'");
|
|
expect(scrubGitCredentialText(
|
|
"clone https://gitlab.example/repo.git?anything=glpat-123 failed",
|
|
)).toBe("clone https://gitlab.example/repo.git?*** failed");
|
|
});
|
|
|
|
it("leaves credential-free text unchanged", () => {
|
|
const text = 'Failed to clone "https://github.com/example/repo.git": exit code 128';
|
|
expect(scrubGitCredentialText(text)).toBe(text);
|
|
const plain = "git clone failed with exit code=128 at key step ref=main";
|
|
expect(scrubGitCredentialText(plain)).toBe(plain);
|
|
});
|
|
});
|
|
|
|
describe("buildAnchorFallbackWorkspaceNotes", () => {
|
|
it("reports materialization failures ahead of the generic no-cwd note", () => {
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/paperclip/workspaces/agent-1",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [{
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private.git",
|
|
error: "fatal: could not read Username",
|
|
}],
|
|
missingProjectCwds: [],
|
|
hasConfiguredProjectCwd: false,
|
|
})).toEqual([
|
|
'Failed to prepare the project workspace checkout: fatal: could not read Username. Using fallback workspace "/paperclip/workspaces/agent-1" for this run.',
|
|
]);
|
|
});
|
|
|
|
it("counts additional failed candidates", () => {
|
|
const failure = {
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: null,
|
|
error: "clone timed out",
|
|
};
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [failure, { ...failure, projectWorkspaceId: "workspace-2" }],
|
|
missingProjectCwds: [],
|
|
hasConfiguredProjectCwd: false,
|
|
})).toEqual([
|
|
'Failed to prepare the project workspace checkout (clone timed out), and 1 other candidate workspace(s) also failed. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
});
|
|
|
|
it("preserves the existing missing-path and no-cwd notes when nothing failed to materialize", () => {
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: "Selected project workspace \"workspace-9\" is not available on this project.",
|
|
materializationFailures: [],
|
|
missingProjectCwds: ["/missing/path"],
|
|
hasConfiguredProjectCwd: true,
|
|
})).toEqual([
|
|
'Selected project workspace "workspace-9" is not available on this project.',
|
|
'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [],
|
|
missingProjectCwds: [],
|
|
hasConfiguredProjectCwd: false,
|
|
})).toEqual([
|
|
'Project workspace has no local cwd configured. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
});
|
|
|
|
it("emits both failure and missing-path notes when a project has both kinds of candidates", () => {
|
|
expect(buildAnchorFallbackWorkspaceNotes({
|
|
fallbackCwd: "/fallback",
|
|
preferredWorkspaceWarning: null,
|
|
materializationFailures: [{
|
|
projectWorkspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/example/private.git",
|
|
error: "authentication failed",
|
|
}],
|
|
missingProjectCwds: ["/missing/path"],
|
|
hasConfiguredProjectCwd: true,
|
|
})).toEqual([
|
|
'Failed to prepare the project workspace checkout: authentication failed. Using fallback workspace "/fallback" for this run.',
|
|
'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("assertPushCapabilityCheckoutValid", () => {
|
|
it("rejects a GitHub PR workflow checkout without a configured push remote", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: false });
|
|
try {
|
|
await expect(assertPushCapabilityCheckoutValid({
|
|
enabled: true,
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
},
|
|
cwd,
|
|
})).rejects.toMatchObject({
|
|
code: "workspace_validation_failed",
|
|
message: expect.stringContaining("has no configured push remote"),
|
|
resultJson: {
|
|
workspaceValidation: expect.objectContaining({
|
|
reason: "missing_git_push_remote",
|
|
issueId: "issue-1",
|
|
executionWorkspaceCwd: cwd,
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows a GitHub PR workflow checkout when a push remote is configured", async () => {
|
|
const cwd = await createGitCheckout({ withRemote: true });
|
|
try {
|
|
await expect(assertPushCapabilityCheckoutValid({
|
|
enabled: true,
|
|
issue: {
|
|
id: "issue-1",
|
|
identifier: "PAP-1",
|
|
},
|
|
cwd,
|
|
})).resolves.toBeUndefined();
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("requiresPushCapabilityPreflight", () => {
|
|
it("only enables the guard when the issue explicitly mentions the GitHub PR workflow skill", () => {
|
|
expect(requiresPushCapabilityPreflight({
|
|
adapterType: "codex_local",
|
|
issueId: "issue-1",
|
|
explicitRunScopedSkillKeys: ["paperclipai/bundled/software-development/github-pr-workflow"],
|
|
})).toBe(true);
|
|
|
|
expect(requiresPushCapabilityPreflight({
|
|
adapterType: "codex_local",
|
|
issueId: "issue-1",
|
|
explicitRunScopedSkillKeys: [],
|
|
})).toBe(false);
|
|
|
|
expect(requiresPushCapabilityPreflight({
|
|
adapterType: "cursor-cloud",
|
|
issueId: "issue-1",
|
|
explicitRunScopedSkillKeys: ["paperclipai/bundled/software-development/github-pr-workflow"],
|
|
})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("stripHostWorkspaceProvisionForLowTrustSandbox", () => {
|
|
it("removes only the host-side provision command for sandbox-backed low-trust runs", () => {
|
|
const config = {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
|
},
|
|
workspaceRuntime: {
|
|
services: [{ name: "web" }],
|
|
},
|
|
};
|
|
|
|
const result = stripHostWorkspaceProvisionForLowTrustSandbox({
|
|
config,
|
|
trustPreset: lowTrustResolution(),
|
|
selectedEnvironmentDriver: "sandbox",
|
|
});
|
|
|
|
expect(result).not.toBe(config);
|
|
expect(result.workspaceStrategy).toEqual({
|
|
type: "git_worktree",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
|
});
|
|
expect(result.workspaceRuntime).toBe(config.workspaceRuntime);
|
|
expect(config.workspaceStrategy.provisionCommand).toBe("bash ./scripts/provision-worktree.sh");
|
|
});
|
|
|
|
it("preserves provision commands for standard-trust runs", () => {
|
|
const config = {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
},
|
|
};
|
|
|
|
expect(stripHostWorkspaceProvisionForLowTrustSandbox({
|
|
config,
|
|
trustPreset: standardTrustResolution(),
|
|
selectedEnvironmentDriver: "sandbox",
|
|
})).toBe(config);
|
|
});
|
|
|
|
it("preserves provision commands when a low-trust run is not sandbox-backed", () => {
|
|
const config = {
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
|
},
|
|
};
|
|
|
|
expect(stripHostWorkspaceProvisionForLowTrustSandbox({
|
|
config,
|
|
trustPreset: lowTrustResolution(),
|
|
selectedEnvironmentDriver: "local",
|
|
})).toBe(config);
|
|
});
|
|
});
|
|
|
|
describe("preflightLowTrustWorkspaceIsolation", () => {
|
|
it("fails non-sandbox low-trust runs before the caller reaches host workspace side effects", async () => {
|
|
let hostWorkspaceSideEffectReached = false;
|
|
|
|
await expect((async () => {
|
|
await preflightLowTrustWorkspaceIsolation({
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "local",
|
|
});
|
|
hostWorkspaceSideEffectReached = true;
|
|
})()).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "low_trust_requires_sandbox_environment",
|
|
}),
|
|
});
|
|
|
|
expect(hostWorkspaceSideEffectReached).toBe(false);
|
|
});
|
|
|
|
it("returns the sandbox driver for sandbox-backed low-trust runs", async () => {
|
|
await expect(preflightLowTrustWorkspaceIsolation({
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "sandbox",
|
|
})).resolves.toBe("sandbox");
|
|
});
|
|
|
|
it("allows child issues inside a rootIssueId low-trust boundary during workspace preflight", async () => {
|
|
await expect(preflightLowTrustWorkspaceIsolation({
|
|
db: buildIssueAncestryDb([
|
|
{ id: "issue-child", companyId: "company-1", parentId: "issue-1" },
|
|
{ id: "issue-1", companyId: "company-1", parentId: null },
|
|
]) as any,
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-child",
|
|
projectId: null,
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "sandbox",
|
|
})).resolves.toBe("sandbox");
|
|
});
|
|
});
|
|
|
|
describe("resolveWorkspaceAfterLowTrustPreflight", () => {
|
|
it("fails non-sandbox low-trust runs before resolving workspaces", async () => {
|
|
let workspaceResolverReached = false;
|
|
|
|
await expect(resolveWorkspaceAfterLowTrustPreflight({
|
|
trustPreset: lowTrustResolution(),
|
|
isolatedWorkspacesEnabled: true,
|
|
effectiveExecutionWorkspaceMode: "isolated_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => "local",
|
|
resolveWorkspace: async () => {
|
|
workspaceResolverReached = true;
|
|
return buildResolvedWorkspace();
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
details: expect.objectContaining({
|
|
code: "low_trust_requires_sandbox_environment",
|
|
}),
|
|
});
|
|
|
|
expect(workspaceResolverReached).toBe(false);
|
|
});
|
|
|
|
it("preserves standard-trust workspace resolution", async () => {
|
|
const workspace = buildResolvedWorkspace({ cwd: "/tmp/standard-workspace" });
|
|
|
|
await expect(resolveWorkspaceAfterLowTrustPreflight({
|
|
trustPreset: standardTrustResolution(),
|
|
isolatedWorkspacesEnabled: false,
|
|
effectiveExecutionWorkspaceMode: "shared_workspace",
|
|
issue: {
|
|
companyId: "company-1",
|
|
id: "issue-1",
|
|
projectId: "project-1",
|
|
},
|
|
resolveSelectedEnvironmentDriver: async () => {
|
|
throw new Error("standard trust should not inspect the environment driver");
|
|
},
|
|
resolveWorkspace: async () => workspace,
|
|
})).resolves.toEqual({
|
|
selectedEnvironmentDriver: null,
|
|
workspace,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
|
it("keeps a legacy projectless Codex session in the default agent workspace", () => {
|
|
const agentId = "agent-projectless-legacy";
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
|
const previousSessionParams = {
|
|
sessionId: "legacy-session-1",
|
|
cwd: fallbackCwd,
|
|
};
|
|
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId,
|
|
previousSessionParams,
|
|
resolvedWorkspace: buildResolvedWorkspace({
|
|
cwd: fallbackCwd,
|
|
source: "agent_home",
|
|
projectId: null,
|
|
workspaceId: null,
|
|
}),
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionParams: previousSessionParams,
|
|
warning: null,
|
|
});
|
|
});
|
|
|
|
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
|
const agentId = "agent-123";
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
|
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId,
|
|
previousSessionParams: {
|
|
sessionId: "session-1",
|
|
cwd: fallbackCwd,
|
|
workspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
|
|
});
|
|
|
|
expect(result.sessionParams).toMatchObject({
|
|
sessionId: "session-1",
|
|
cwd: "/tmp/new-project-cwd",
|
|
workspaceId: "workspace-1",
|
|
});
|
|
expect(result.warning).toContain("Attempting to resume session");
|
|
});
|
|
|
|
it("does not migrate when previous session cwd is not the fallback workspace", () => {
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId: "agent-123",
|
|
previousSessionParams: {
|
|
sessionId: "session-1",
|
|
cwd: "/tmp/some-other-cwd",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace({ cwd: "/tmp/new-project-cwd" }),
|
|
});
|
|
|
|
expect(result.sessionParams).toEqual({
|
|
sessionId: "session-1",
|
|
cwd: "/tmp/some-other-cwd",
|
|
workspaceId: "workspace-1",
|
|
});
|
|
expect(result.warning).toBeNull();
|
|
});
|
|
|
|
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
|
|
const agentId = "agent-123";
|
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
|
|
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
|
agentId,
|
|
previousSessionParams: {
|
|
sessionId: "session-1",
|
|
cwd: fallbackCwd,
|
|
workspaceId: "workspace-1",
|
|
},
|
|
resolvedWorkspace: buildResolvedWorkspace({
|
|
cwd: "/tmp/new-project-cwd",
|
|
workspaceId: "workspace-2",
|
|
}),
|
|
});
|
|
|
|
expect(result.sessionParams).toEqual({
|
|
sessionId: "session-1",
|
|
cwd: fallbackCwd,
|
|
workspaceId: "workspace-1",
|
|
});
|
|
expect(result.warning).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("applyPersistedExecutionWorkspaceConfig", () => {
|
|
it("does not add workspace runtime when only the project workspace had manual runtime config", () => {
|
|
const result = applyPersistedExecutionWorkspaceConfig({
|
|
config: {},
|
|
workspaceConfig: null,
|
|
mode: "isolated_workspace",
|
|
});
|
|
|
|
expect("workspaceRuntime" in result).toBe(false);
|
|
});
|
|
|
|
it("applies explicit persisted execution workspace runtime config when present", () => {
|
|
const result = applyPersistedExecutionWorkspaceConfig({
|
|
config: {},
|
|
workspaceConfig: {
|
|
provisionCommand: null,
|
|
teardownCommand: null,
|
|
cleanupCommand: null,
|
|
desiredState: null,
|
|
workspaceRuntime: {
|
|
services: [{ name: "workspace-web" }],
|
|
},
|
|
},
|
|
mode: "isolated_workspace",
|
|
});
|
|
|
|
expect(result.workspaceRuntime).toEqual({
|
|
services: [{ name: "workspace-web" }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
|
|
it("persists branch ownership independently of fresh worktree creation", () => {
|
|
const executionWorkspace = {
|
|
created: true,
|
|
branchCreatedByRuntime: false,
|
|
};
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: null,
|
|
source: "task_session",
|
|
createdByRuntime: resolveExecutionWorkspaceBranchOwnership(executionWorkspace),
|
|
strategyType: "git_worktree",
|
|
configSnapshot: null,
|
|
shouldReuseExisting: false,
|
|
baseRef: "origin/main",
|
|
baseRefSha: null,
|
|
});
|
|
|
|
expect(metadata.createdByRuntime).toBe(false);
|
|
expect(metadata.gitBranchOwnershipVersion).toBe(1);
|
|
});
|
|
|
|
it("does not downgrade recorded runtime ownership after worktree reuse", () => {
|
|
const executionWorkspace = {
|
|
created: false,
|
|
branchCreatedByRuntime: true,
|
|
};
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: { createdByRuntime: true },
|
|
source: "task_session",
|
|
createdByRuntime: resolveExecutionWorkspaceBranchOwnership(executionWorkspace),
|
|
strategyType: "git_worktree",
|
|
configSnapshot: null,
|
|
shouldReuseExisting: false,
|
|
baseRef: "origin/main",
|
|
baseRefSha: null,
|
|
});
|
|
|
|
expect(metadata.createdByRuntime).toBe(true);
|
|
expect(metadata.gitBranchOwnershipVersion).toBe(1);
|
|
});
|
|
|
|
it("merges config snapshot for newly realized workspaces", () => {
|
|
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: null,
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/provision.sh",
|
|
},
|
|
shouldReuseExisting: false,
|
|
baseRef: null,
|
|
baseRefSha: null,
|
|
})).toEqual({
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
config: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/provision.sh",
|
|
runtimeProvisionCommand: null,
|
|
teardownCommand: null,
|
|
cleanupCommand: null,
|
|
desiredState: null,
|
|
serviceStates: null,
|
|
workspaceRuntime: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves persisted config snapshot when reusing an existing workspace", () => {
|
|
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: {
|
|
config: {
|
|
environmentId: "env-old",
|
|
provisionCommand: "bash ./scripts/existing-provision.sh",
|
|
},
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
environmentId: "env-new",
|
|
provisionCommand: "bash ./scripts/new-provision.sh",
|
|
},
|
|
shouldReuseExisting: true,
|
|
baseRef: null,
|
|
baseRefSha: null,
|
|
})).toEqual({
|
|
config: {
|
|
environmentId: "env-old",
|
|
provisionCommand: "bash ./scripts/existing-provision.sh",
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
});
|
|
});
|
|
|
|
it("records the resolved base ref SHA for newly realized workspaces", () => {
|
|
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: null,
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
strategyType: "project_primary",
|
|
configSnapshot: null,
|
|
shouldReuseExisting: false,
|
|
baseRef: "origin/main",
|
|
baseRefSha: "abc1234567890",
|
|
})).toEqual({
|
|
source: "task_session",
|
|
createdByRuntime: true,
|
|
baseRefSnapshot: {
|
|
baseRef: "origin/main",
|
|
resolvedSha: "abc1234567890",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
type WorkspaceConfigMetadata = ReturnType<typeof buildEffectiveRunWorkspaceConfigMetadata>;
|
|
|
|
function buildWorkspaceConfigMetadata(
|
|
overrides: Partial<Parameters<typeof buildEffectiveRunWorkspaceConfigMetadata>[0]> = {},
|
|
) {
|
|
return buildEffectiveRunWorkspaceConfigMetadata({
|
|
mode: "isolated_workspace",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "workspace-1",
|
|
strategyType: "git_worktree",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/main",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
repoUrl: "https://github.com/example/repo.git",
|
|
repoRef: "origin/main",
|
|
configSnapshot: {
|
|
provisionCommand: "pnpm install",
|
|
teardownCommand: "pnpm stop",
|
|
cleanupCommand: "pnpm clean",
|
|
desiredState: "running",
|
|
serviceStates: { "0": "running" },
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
|
|
},
|
|
},
|
|
environment: {
|
|
selectedEnvironmentId: "environment-1",
|
|
driver: "local",
|
|
config: { provider: "local" },
|
|
},
|
|
realization: {
|
|
environmentDriver: "local",
|
|
environmentProvider: "local",
|
|
},
|
|
evaluatedAt: "2026-06-26T00:00:00.000Z",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function persistedWorkspaceConfigFingerprint(metadata: WorkspaceConfigMetadata) {
|
|
return {
|
|
configFingerprint: {
|
|
version: metadata.version,
|
|
workspaceHash: metadata.fingerprint,
|
|
categories: metadata.categories,
|
|
categoryFingerprints: metadata.categoryFingerprints,
|
|
lastEvaluatedAt: metadata.evaluatedAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("effective run execution workspace config freshness", () => {
|
|
it("reuses an existing workspace when the stored workspace fingerprint is unchanged", () => {
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
|
|
nextMetadata: metadata,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
action: "reuse",
|
|
shouldReuseExisting: true,
|
|
shouldRefreshConfigSnapshot: false,
|
|
changedCategories: [],
|
|
storedFingerprintPresent: true,
|
|
});
|
|
});
|
|
|
|
it("refreshes metadata and config for runtime-service-only drift without replacing the workspace", () => {
|
|
const base = buildWorkspaceConfigMetadata();
|
|
const next = buildWorkspaceConfigMetadata({
|
|
configSnapshot: {
|
|
provisionCommand: "pnpm install",
|
|
teardownCommand: "pnpm stop",
|
|
cleanupCommand: "pnpm clean",
|
|
desiredState: "running",
|
|
serviceStates: { "0": "running" },
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
|
|
nextMetadata: next,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
action: "refresh",
|
|
shouldReuseExisting: true,
|
|
shouldRefreshConfigSnapshot: true,
|
|
changedCategories: ["runtimeServices"],
|
|
});
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: {
|
|
config: {
|
|
workspaceRuntime: { services: [{ name: "web", command: "pnpm dev", port: 3100 }] },
|
|
},
|
|
...persistedWorkspaceConfigFingerprint(base),
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
desiredState: "running",
|
|
serviceStates: { "0": "running" },
|
|
},
|
|
shouldReuseExisting: true,
|
|
shouldRefreshConfigSnapshot: true,
|
|
workspaceConfigMetadata: next,
|
|
baseRef: "origin/main",
|
|
baseRefSha: "abc123",
|
|
});
|
|
|
|
expect(metadata?.config).toMatchObject({
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
});
|
|
expect(metadata?.configFingerprint).toMatchObject({
|
|
workspaceHash: next.fingerprint,
|
|
categories: next.categories,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "mode",
|
|
category: "mode",
|
|
next: buildWorkspaceConfigMetadata({ mode: "shared_workspace" }),
|
|
},
|
|
{
|
|
name: "workspace strategy",
|
|
category: "strategy",
|
|
next: buildWorkspaceConfigMetadata({
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/main",
|
|
branchTemplate: "custom-{{issue.identifier}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "project workspace",
|
|
category: "projectWorkspace",
|
|
next: buildWorkspaceConfigMetadata({ projectWorkspaceId: "workspace-2" }),
|
|
},
|
|
{
|
|
name: "base ref",
|
|
category: "repo",
|
|
next: buildWorkspaceConfigMetadata({
|
|
repoRef: "origin/release",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/release",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "environment realization",
|
|
category: "environment",
|
|
next: buildWorkspaceConfigMetadata({
|
|
environment: {
|
|
selectedEnvironmentId: "environment-2",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
},
|
|
realization: {
|
|
environmentDriver: "sandbox",
|
|
environmentProvider: "daytona",
|
|
},
|
|
}),
|
|
},
|
|
] as const)("replaces the workspace when $name changes", ({ category, next }) => {
|
|
const base = buildWorkspaceConfigMetadata();
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
|
|
nextMetadata: next,
|
|
});
|
|
|
|
expect(decision.action).toBe("replace");
|
|
expect(decision.shouldReuseExisting).toBe(false);
|
|
expect(decision.changedCategories).toContain(category);
|
|
});
|
|
|
|
it("keeps replacement-class drift visible when explicit reuse restores the old workspace", () => {
|
|
const base = buildWorkspaceConfigMetadata();
|
|
const next = buildWorkspaceConfigMetadata({
|
|
repoRef: "origin/release",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/release",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
configSnapshot: {
|
|
provisionCommand: "pnpm install --frozen-lockfile",
|
|
},
|
|
});
|
|
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
|
|
nextMetadata: next,
|
|
});
|
|
const policy = resolveExecutionWorkspaceReuseProvisioningPolicy({
|
|
requestedShouldReuseExisting: true,
|
|
workspaceConfigFreshness: decision,
|
|
});
|
|
|
|
expect(decision.action).toBe("replace");
|
|
expect(policy).toEqual({
|
|
shouldRestoreExistingWorkspace: true,
|
|
shouldRefreshWorkspaceConfigSnapshot: false,
|
|
shouldPersistLatestWorkspaceConfigMetadata: false,
|
|
});
|
|
|
|
const metadata = mergeExecutionWorkspaceMetadataForPersistence({
|
|
existingMetadata: {
|
|
config: {
|
|
provisionCommand: "pnpm install",
|
|
},
|
|
...persistedWorkspaceConfigFingerprint(base),
|
|
},
|
|
source: "task_session",
|
|
createdByRuntime: false,
|
|
strategyType: "project_primary",
|
|
configSnapshot: {
|
|
provisionCommand: "pnpm install --frozen-lockfile",
|
|
},
|
|
shouldReuseExisting: policy.shouldRestoreExistingWorkspace,
|
|
shouldRefreshConfigSnapshot: policy.shouldRefreshWorkspaceConfigSnapshot,
|
|
workspaceConfigMetadata: policy.shouldPersistLatestWorkspaceConfigMetadata ? next : null,
|
|
baseRef: "origin/release",
|
|
baseRefSha: "release-sha",
|
|
});
|
|
|
|
expect(metadata?.config).toEqual({
|
|
provisionCommand: "pnpm install",
|
|
});
|
|
expect(metadata?.configFingerprint).toMatchObject({
|
|
workspaceHash: base.fingerprint,
|
|
categories: base.categories,
|
|
});
|
|
expect(metadata?.configFingerprint).not.toMatchObject({
|
|
workspaceHash: next.fingerprint,
|
|
});
|
|
});
|
|
|
|
it("fails loudly when explicit reuse restore errors", async () => {
|
|
const base = buildWorkspaceConfigMetadata();
|
|
const next = buildWorkspaceConfigMetadata({
|
|
repoRef: "origin/release",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
baseRef: "origin/release",
|
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
|
worktreeParentDir: ".paperclip/worktrees",
|
|
},
|
|
});
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(base),
|
|
nextMetadata: next,
|
|
});
|
|
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace", warnings: [] }));
|
|
|
|
await expect(provisionExecutionWorkspaceForFreshnessDecision({
|
|
requestedShouldReuseExisting: true,
|
|
existingExecutionWorkspaceId: "workspace-old",
|
|
issueRef: { id: "issue-1", identifier: "PAP-42" },
|
|
runId: "run-1",
|
|
workspaceConfigFreshness: decision,
|
|
restoreExistingWorkspace: async () => {
|
|
throw new Error("restore command failed");
|
|
},
|
|
realizeWorkspace,
|
|
})).rejects.toThrow(/restore command failed/);
|
|
expect(realizeWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ name: "missing", status: null },
|
|
{ name: "archived", status: "archived" },
|
|
])("fails loudly when the inherited workspace row is $name", async ({ status }) => {
|
|
const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
|
|
issueExecutionWorkspaceId: "workspace-old",
|
|
issueExecutionWorkspacePreference: "reuse_existing",
|
|
existingExecutionWorkspaceStatus: status,
|
|
});
|
|
|
|
expect(reuseRequest).toEqual({
|
|
requestedExecutionWorkspaceId: "workspace-old",
|
|
requestedShouldReuseExisting: true,
|
|
existingExecutionWorkspaceAvailable: false,
|
|
});
|
|
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: reuseRequest.requestedShouldReuseExisting &&
|
|
reuseRequest.existingExecutionWorkspaceAvailable,
|
|
existingWorkspaceMetadata: null,
|
|
nextMetadata: metadata,
|
|
});
|
|
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace", warnings: [] }));
|
|
|
|
await expect(provisionExecutionWorkspaceForFreshnessDecision({
|
|
requestedShouldReuseExisting: reuseRequest.requestedShouldReuseExisting,
|
|
existingExecutionWorkspaceId: reuseRequest.requestedExecutionWorkspaceId,
|
|
issueRef: { id: "issue-1", identifier: "PAP-42" },
|
|
runId: "run-1",
|
|
workspaceConfigFreshness: decision,
|
|
restoreExistingWorkspace: reuseRequest.existingExecutionWorkspaceAvailable
|
|
? async () => ({ id: "workspace-old", warnings: [] })
|
|
: null,
|
|
realizeWorkspace,
|
|
})).rejects.toThrow(/could not be restored/);
|
|
expect(realizeWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not mistake a projectless native run-id binding for a missing persisted workspace", () => {
|
|
expect(resolveNativeRecoveryExecutionWorkspaceBinding({
|
|
bindingId: "run-projectless",
|
|
persistedWorkspaceFound: false,
|
|
})).toBeNull();
|
|
expect(resolveNativeRecoveryExecutionWorkspaceBinding({
|
|
bindingId: "workspace-persisted",
|
|
persistedWorkspaceFound: true,
|
|
})).toBe("workspace-persisted");
|
|
});
|
|
|
|
it.each([
|
|
{ name: "a different branch", branchName: "PAP-9001-derived-child-branch" },
|
|
{ name: "no recorded branch", branchName: null },
|
|
])(
|
|
"realizes the pinned existing branch instead of reusing an inherited workspace on $name",
|
|
async ({ branchName }) => {
|
|
const reuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
|
|
issueExecutionWorkspaceId: "workspace-old",
|
|
issueExecutionWorkspacePreference: "reuse_existing",
|
|
existingExecutionWorkspaceStatus: "active",
|
|
requestedExistingBranch: "PAP-14380-salvage-pap-9514",
|
|
existingExecutionWorkspaceBranchName: branchName,
|
|
});
|
|
|
|
expect(reuseRequest).toEqual({
|
|
requestedExecutionWorkspaceId: "workspace-old",
|
|
requestedShouldReuseExisting: false,
|
|
existingExecutionWorkspaceAvailable: false,
|
|
});
|
|
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: false,
|
|
existingWorkspaceMetadata: null,
|
|
nextMetadata: metadata,
|
|
});
|
|
const realizeWorkspace = vi.fn(async () => ({ id: "pinned-branch-workspace", warnings: [] }));
|
|
const restoreExistingWorkspace = vi.fn(async () => ({ id: "workspace-old", warnings: [] }));
|
|
|
|
const result = await provisionExecutionWorkspaceForFreshnessDecision({
|
|
requestedShouldReuseExisting: reuseRequest.requestedShouldReuseExisting,
|
|
existingExecutionWorkspaceId: reuseRequest.requestedExecutionWorkspaceId,
|
|
issueRef: { id: "issue-1", identifier: "PAP-42" },
|
|
runId: "run-1",
|
|
workspaceConfigFreshness: decision,
|
|
restoreExistingWorkspace,
|
|
realizeWorkspace,
|
|
});
|
|
|
|
expect(result.executionWorkspace).toEqual({ id: "pinned-branch-workspace", warnings: [] });
|
|
expect(result.reusedExecutionWorkspace).toBeNull();
|
|
expect(restoreExistingWorkspace).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("keeps reusing an inherited workspace whose branch matches the pinned existing branch", () => {
|
|
expect(resolveExecutionWorkspaceReuseRequestForIssue({
|
|
issueExecutionWorkspaceId: "workspace-old",
|
|
issueExecutionWorkspacePreference: "reuse_existing",
|
|
existingExecutionWorkspaceStatus: "active",
|
|
requestedExistingBranch: "PAP-14380-salvage-pap-9514",
|
|
existingExecutionWorkspaceBranchName: "PAP-14380-salvage-pap-9514",
|
|
})).toEqual({
|
|
requestedExecutionWorkspaceId: "workspace-old",
|
|
requestedShouldReuseExisting: true,
|
|
existingExecutionWorkspaceAvailable: true,
|
|
});
|
|
});
|
|
|
|
it("fails loudly when explicit reuse restore returns no workspace", async () => {
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
|
|
nextMetadata: metadata,
|
|
});
|
|
const realizeWorkspace = vi.fn(async () => ({ id: "fallback-workspace", warnings: [] }));
|
|
|
|
await expect(provisionExecutionWorkspaceForFreshnessDecision({
|
|
requestedShouldReuseExisting: true,
|
|
existingExecutionWorkspaceId: "workspace-old",
|
|
issueRef: { id: "issue-1", identifier: "PAP-42" },
|
|
runId: "run-1",
|
|
workspaceConfigFreshness: decision,
|
|
restoreExistingWorkspace: async () => null,
|
|
realizeWorkspace,
|
|
})).rejects.toThrow(/could not be restored/);
|
|
expect(realizeWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("formats a safe workspace operation payload for config drift decisions", () => {
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(buildWorkspaceConfigMetadata()),
|
|
nextMetadata: buildWorkspaceConfigMetadata({
|
|
configSnapshot: {
|
|
workspaceRuntime: {
|
|
services: [{ name: "web", command: "pnpm dev -- --host 0.0.0.0", port: 3200 }],
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const operation = buildWorkspaceConfigFreshnessOperation({
|
|
decision,
|
|
hasExistingWorkspace: true,
|
|
reuseRequested: true,
|
|
workspaceReused: true,
|
|
configSnapshotRefreshed: true,
|
|
previousWorkspaceId: "workspace-old",
|
|
activeWorkspaceId: "workspace-old",
|
|
});
|
|
|
|
expect(operation).toMatchObject({
|
|
metadata: {
|
|
kind: "config_freshness",
|
|
action: "refresh",
|
|
changedCategories: ["lifecycleCommands", "runtimeServices"],
|
|
changedCategoryLabels: ["workspace lifecycle commands", "runtime services"],
|
|
reuseRequested: true,
|
|
workspaceReused: true,
|
|
configSnapshotRefreshed: true,
|
|
previousWorkspaceId: "workspace-old",
|
|
activeWorkspaceId: "workspace-old",
|
|
},
|
|
system: expect.stringContaining("refreshed execution workspace config"),
|
|
});
|
|
const serialized = JSON.stringify(operation);
|
|
expect(serialized).toContain("runtime services");
|
|
expect(serialized).not.toContain("pnpm dev");
|
|
expect(serialized).not.toContain("0.0.0.0");
|
|
});
|
|
|
|
it("does not record a freshness operation when an unchanged workspace is simply reused", () => {
|
|
const metadata = buildWorkspaceConfigMetadata();
|
|
const decision = resolveExecutionWorkspaceConfigFreshness({
|
|
hasExistingWorkspace: true,
|
|
existingWorkspaceMetadata: persistedWorkspaceConfigFingerprint(metadata),
|
|
nextMetadata: metadata,
|
|
});
|
|
|
|
expect(buildWorkspaceConfigFreshnessOperation({
|
|
decision,
|
|
hasExistingWorkspace: true,
|
|
reuseRequested: true,
|
|
workspaceReused: true,
|
|
configSnapshotRefreshed: false,
|
|
previousWorkspaceId: "workspace-1",
|
|
activeWorkspaceId: "workspace-1",
|
|
})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
|
it("removes workspace runtime before heartbeat execution", () => {
|
|
const input = {
|
|
cwd: "/tmp/project",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
},
|
|
workspaceRuntime: {
|
|
services: [{ name: "web" }],
|
|
},
|
|
};
|
|
|
|
const result = stripWorkspaceRuntimeFromExecutionRunConfig(input);
|
|
|
|
expect(result).toEqual({
|
|
cwd: "/tmp/project",
|
|
workspaceStrategy: {
|
|
type: "git_worktree",
|
|
},
|
|
});
|
|
expect(input.workspaceRuntime).toEqual({
|
|
services: [{ name: "web" }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("shouldResetTaskSessionForWake", () => {
|
|
it("resets session context on assignment wake", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
|
});
|
|
|
|
it("preserves session context on execution review handoff wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_review_requested" })).toBe(false);
|
|
});
|
|
|
|
it("resets session context on execution approval wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_approval_requested" })).toBe(true);
|
|
});
|
|
|
|
it("preserves session context on execution changes-requested handoff wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_changes_requested" })).toBe(false);
|
|
});
|
|
|
|
it("preserves session context on timer heartbeats", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
|
});
|
|
|
|
it("preserves session context on manual on-demand invokes by default", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeSource: "on_demand",
|
|
wakeTriggerDetail: "manual",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("resets session context when a fresh session is explicitly requested", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeSource: "on_demand",
|
|
wakeTriggerDetail: "manual",
|
|
forceFreshSession: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("resets session context for accepted planning confirmations that refresh workspace selection", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeReason: "issue_commented",
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
forceFreshSession: true,
|
|
workspaceRefreshReason: "accepted_plan_confirmation",
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not reset session context on mention wake comment", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeReason: "issue_comment_mentioned",
|
|
wakeCommentId: "comment-1",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset session context when commentId is present", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeReason: "issue_commented",
|
|
commentId: "comment-2",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset for comment wakes", () => {
|
|
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_commented" })).toBe(false);
|
|
});
|
|
|
|
it("does not reset when wake reason is missing", () => {
|
|
expect(shouldResetTaskSessionForWake({})).toBe(false);
|
|
});
|
|
|
|
it("does not reset session context on callback on-demand invokes", () => {
|
|
expect(
|
|
shouldResetTaskSessionForWake({
|
|
wakeSource: "on_demand",
|
|
wakeTriggerDetail: "callback",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldDeferFollowupWakeForSameIssue", () => {
|
|
it("defers a same-agent follow-up for mention-style comment wakes while a run is active", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "running",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: "comment-1",
|
|
forceFreshSession: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("defers a same-agent follow-up when a fresh session is explicitly requested", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "running",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: null,
|
|
forceFreshSession: true,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not defer when the existing run is only queued", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "queued",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: null,
|
|
forceFreshSession: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not defer normal same-agent wakes without a comment or fresh-session request", () => {
|
|
expect(
|
|
shouldDeferFollowupWakeForSameIssue({
|
|
activeRunStatus: "running",
|
|
isSameExecutionAgent: true,
|
|
wakeCommentId: null,
|
|
forceFreshSession: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldResetTaskSessionForModelChange", () => {
|
|
it("resets when configured model differs from persisted session model", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "opencode/mimo-v2-pro-free",
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not reset when models match", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset when persisted session model is missing", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset when configured model is missing", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: null,
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not reset when task session params are missing", () => {
|
|
expect(
|
|
shouldResetTaskSessionForModelChange({
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
type SessionConfigMetadata = Awaited<ReturnType<typeof buildEffectiveRunSessionConfigMetadata>>;
|
|
|
|
async function buildSessionConfigMetadata(
|
|
overrides: Partial<Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0]> = {},
|
|
) {
|
|
return buildEffectiveRunSessionConfigMetadata({
|
|
adapterType: "codex_local",
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
env: {
|
|
OPENAI_API_KEY: "resolved-secret-value",
|
|
PLAIN_FLAG: "plain-value",
|
|
},
|
|
},
|
|
agentRuntimeConfig: {
|
|
heartbeat: {
|
|
maxConcurrentRuns: 1,
|
|
},
|
|
},
|
|
issueOverrides: null,
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
projectConfigRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
environment: {
|
|
selectionSource: "default",
|
|
selectedEnvironmentId: "environment-1",
|
|
selectedEnvironment: {
|
|
id: "environment-1",
|
|
driver: "local",
|
|
configRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
},
|
|
environmentEnv: {
|
|
ENVIRONMENT_FLAG: "enabled",
|
|
},
|
|
projectEnv: {
|
|
PROJECT_FLAG: "enabled",
|
|
},
|
|
routineEnv: null,
|
|
secretManifest: [
|
|
{
|
|
configPath: "env.OPENAI_API_KEY",
|
|
envKey: "OPENAI_API_KEY",
|
|
secretId: "secret-1",
|
|
bindingId: "binding-1",
|
|
secretKey: "openai-api-key",
|
|
version: 7,
|
|
provider: "local_encrypted",
|
|
outcome: "success",
|
|
},
|
|
],
|
|
runtimeSkills: [
|
|
{
|
|
key: "paperclip",
|
|
runtimeName: "paperclip",
|
|
source: "/tmp/paperclip/runtime-skills/paperclip",
|
|
versionId: null,
|
|
currentVersionId: "skill-version-1",
|
|
sourceStatus: "available",
|
|
missingDetail: null,
|
|
},
|
|
],
|
|
agentConfigRevision: {
|
|
id: "agent-config-revision-1",
|
|
changedKeys: ["adapterConfig"],
|
|
configRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function sessionParamsWithConfigMetadata(
|
|
metadata: SessionConfigMetadata,
|
|
configuredModel = "gpt-5.4-mini",
|
|
) {
|
|
return {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: configuredModel,
|
|
__paperclipConfigFingerprint: metadata.fingerprint,
|
|
__paperclipConfigFingerprintVersion: metadata.version,
|
|
__paperclipConfigCategories: metadata.categories,
|
|
__paperclipConfigCategoryFingerprints: metadata.categoryFingerprints,
|
|
};
|
|
}
|
|
|
|
describe("effective run session config freshness", () => {
|
|
it("resets legacy sessions after connection instructions change or disappear", async () => {
|
|
const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: { text: "Use the handbook.", digest: "before" } } });
|
|
for (const instructions of [null, { text: "Use the updated handbook.", digest: "after" }]) {
|
|
const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: instructions } });
|
|
expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next }).reset).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("reuses managed AI sessions across temporary credential homes while preserving configuration boundaries", async () => {
|
|
const config = (home: string) => ({
|
|
model: "gpt-5.4-mini",
|
|
approvalPolicy: "never",
|
|
managedAiConnection: { identity: "account-1:credential-generation-1" },
|
|
env: {
|
|
HOME: home,
|
|
XDG_CONFIG_HOME: path.join(home, "config"),
|
|
XDG_DATA_HOME: path.join(home, "data"),
|
|
CODEX_HOME: path.join(home, "provider"),
|
|
GROK_HOME: path.join(home, "provider"),
|
|
CLAUDE_CONFIG_DIR: path.join(home, "provider"),
|
|
CUSTOM_SETTING: "original",
|
|
},
|
|
});
|
|
const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/tmp/ai-first"), managedAiHome: "/tmp/ai-first" });
|
|
const nextConfig = config("/tmp/ai-next");
|
|
const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: nextConfig, managedAiHome: "/tmp/ai-next" });
|
|
expect(next.fingerprint).toBe(first.fingerprint);
|
|
expect(nextConfig.env.HOME).toBe("/tmp/ai-next");
|
|
expect(resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true, configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next,
|
|
}).reset).toBe(false);
|
|
for (const changed of [
|
|
{ ...nextConfig, model: "different-model" },
|
|
{ ...nextConfig, approvalPolicy: "on-request" },
|
|
{ ...nextConfig, managedAiConnection: { identity: "account-2:credential-generation-1" } },
|
|
{ ...nextConfig, managedAiConnection: { identity: "account-1:credential-generation-2" } },
|
|
{ ...nextConfig, env: { ...nextConfig.env, CUSTOM_SETTING: "changed" } },
|
|
{ ...nextConfig, env: { ...nextConfig.env, CODEX_HOME: "/custom/provider" } },
|
|
]) {
|
|
const metadata = await buildSessionConfigMetadata({ effectiveAdapterConfig: changed, managedAiHome: "/tmp/ai-next" });
|
|
expect(metadata.fingerprint).not.toBe(first.fingerprint);
|
|
}
|
|
const unmanagedFirst = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/custom/first") });
|
|
const unmanagedNext = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/custom/next") });
|
|
expect(unmanagedFirst.fingerprint).not.toBe(unmanagedNext.fingerprint);
|
|
});
|
|
|
|
it("resets when effective adapter config changes after model/profile/env resolution", async () => {
|
|
const base = await buildSessionConfigMetadata();
|
|
const next = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
approvalPolicy: "never",
|
|
},
|
|
});
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: next,
|
|
});
|
|
|
|
expect(decision).toMatchObject({
|
|
reset: true,
|
|
changedCategories: ["adapterConfig"],
|
|
});
|
|
expect(decision.reasons.join("\n")).toContain("adapter config");
|
|
});
|
|
|
|
it("does not reset for issue comment timestamps but still resets for workspace settings", async () => {
|
|
const base = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
issueConfigRevisionAt: "2026-06-01T00:00:00.000Z",
|
|
issueSettings: null,
|
|
},
|
|
});
|
|
const commentOnly = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "agent_default",
|
|
effectiveMode: "agent_default",
|
|
issueConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
|
issueSettings: null,
|
|
},
|
|
});
|
|
const workspaceChanged = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "isolated_workspace",
|
|
effectiveMode: "isolated_workspace",
|
|
issueConfigRevisionAt: "2026-06-01T00:05:00.000Z",
|
|
issueSettings: { mode: "isolated_workspace" },
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: commentOnly,
|
|
}),
|
|
).toMatchObject({
|
|
reset: false,
|
|
changedCategories: [],
|
|
reasons: [],
|
|
});
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: workspaceChanged,
|
|
}),
|
|
).toMatchObject({
|
|
reset: true,
|
|
changedCategories: ["workspaceConfig"],
|
|
});
|
|
});
|
|
|
|
it("does not reset when a reusable execution workspace becomes realized", async () => {
|
|
const base = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "shared_workspace",
|
|
effectiveMode: "shared_workspace",
|
|
reusableExecutionWorkspaceConfig: null,
|
|
existingExecutionWorkspace: null,
|
|
},
|
|
});
|
|
const realized = await buildSessionConfigMetadata({
|
|
workspaceConfig: {
|
|
requestedMode: "shared_workspace",
|
|
effectiveMode: "shared_workspace",
|
|
reusableExecutionWorkspaceConfig: {
|
|
strategyType: "project_primary",
|
|
workspaceGeneration: 1,
|
|
},
|
|
existingExecutionWorkspace: {
|
|
id: "workspace-realized-after-first-turn",
|
|
mode: "shared_workspace",
|
|
strategyType: "project_primary",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: realized,
|
|
}),
|
|
).toMatchObject({
|
|
reset: false,
|
|
changedCategories: [],
|
|
reasons: [],
|
|
});
|
|
});
|
|
|
|
it("keeps model-only compatibility as an additional reset reason", async () => {
|
|
const base = await buildSessionConfigMetadata();
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base, "opencode/mimo-v2-pro-free"),
|
|
configMetadata: base,
|
|
});
|
|
|
|
expect(decision.reset).toBe(true);
|
|
expect(decision.reasons).toEqual([
|
|
'configured model changed from "opencode/mimo-v2-pro-free" to "gpt-5.4-mini"',
|
|
]);
|
|
});
|
|
|
|
it("freshens legacy task sessions that lack versioned config metadata", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
configMetadata: metadata,
|
|
});
|
|
|
|
expect(decision.reset).toBe(true);
|
|
expect(decision.changedCategories).toEqual(metadata.categories);
|
|
expect(decision.reasons).toEqual(["effective run configuration fingerprint metadata is missing"]);
|
|
});
|
|
|
|
it("uses persisted fingerprint metadata even when an adapter codec omits it from resume params", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
const persistedParams = sessionParamsWithConfigMetadata(metadata);
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: persistedParams,
|
|
configMetadata: metadata,
|
|
});
|
|
|
|
expect(decision.reset).toBe(false);
|
|
expect(decision.reasons).toEqual([]);
|
|
});
|
|
|
|
it("preserves legacy metadata gaps only for active accepted-plan continuation sessions", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: {
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
},
|
|
configMetadata: metadata,
|
|
preserveLegacySessionWithoutConfigMetadata: true,
|
|
});
|
|
|
|
expect(decision.reset).toBe(false);
|
|
expect(decision.changedCategories).toEqual([]);
|
|
expect(decision.reasons).toEqual([]);
|
|
});
|
|
|
|
it("names safe categories for issue override, env, secret, and runtime skill drift", async () => {
|
|
const base = await buildSessionConfigMetadata();
|
|
const cases: Array<{
|
|
name: string;
|
|
category: string;
|
|
metadata: SessionConfigMetadata;
|
|
}> = [
|
|
{
|
|
name: "issue overrides",
|
|
category: "issueOverrides",
|
|
metadata: await buildSessionConfigMetadata({
|
|
issueOverrides: {
|
|
adapterConfig: {
|
|
reasoningEffort: "high",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "project env bindings",
|
|
category: "envBindings",
|
|
metadata: await buildSessionConfigMetadata({
|
|
projectEnv: {
|
|
PROJECT_FLAG: "enabled",
|
|
NEW_PROJECT_FLAG: "present",
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
name: "secret version",
|
|
category: "secrets",
|
|
metadata: await buildSessionConfigMetadata({
|
|
secretManifest: [
|
|
{
|
|
configPath: "env.OPENAI_API_KEY",
|
|
envKey: "OPENAI_API_KEY",
|
|
secretId: "secret-1",
|
|
bindingId: "binding-1",
|
|
secretKey: "openai-api-key",
|
|
version: 8,
|
|
provider: "local_encrypted",
|
|
outcome: "success",
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
name: "runtime skills",
|
|
category: "runtimeSkills",
|
|
metadata: await buildSessionConfigMetadata({
|
|
runtimeSkills: [
|
|
{
|
|
key: "paperclip",
|
|
runtimeName: "paperclip",
|
|
source: "/tmp/paperclip/runtime-skills/paperclip",
|
|
versionId: null,
|
|
currentVersionId: "skill-version-2",
|
|
sourceStatus: "available",
|
|
missingDetail: null,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: testCase.metadata,
|
|
});
|
|
|
|
expect(decision.reset, testCase.name).toBe(true);
|
|
expect(decision.changedCategories, testCase.name).toContain(testCase.category);
|
|
}
|
|
});
|
|
|
|
it("detects instructions content drift without storing the contents", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-"));
|
|
const instructionsPath = path.join(root, "AGENTS.md");
|
|
await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8");
|
|
const base = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
instructionsBundleMode: "managed",
|
|
instructionsRootPath: root,
|
|
instructionsEntryFile: "AGENTS.md",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
});
|
|
await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8");
|
|
const next = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
instructionsBundleMode: "managed",
|
|
instructionsRootPath: root,
|
|
instructionsEntryFile: "AGENTS.md",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
});
|
|
|
|
const decision = resolveTaskSessionConfigFreshness({
|
|
hasTaskSession: true,
|
|
configuredModel: "gpt-5.4-mini",
|
|
taskSessionParams: sessionParamsWithConfigMetadata(base),
|
|
configMetadata: next,
|
|
});
|
|
|
|
expect(decision.reset).toBe(true);
|
|
expect(decision.changedCategories).toContain("instructions");
|
|
expect(next.fingerprints.sessionFingerprint.canonicalJson).not.toContain("Version two instructions");
|
|
});
|
|
|
|
it("does not read unbounded legacy instructions paths for config fingerprints", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-session-fingerprint-"));
|
|
const instructionsPath = path.join(root, "AGENTS.md");
|
|
await fs.writeFile(instructionsPath, "Legacy direct-path instructions.\n", "utf8");
|
|
const metadata = await buildSessionConfigMetadata({
|
|
effectiveAdapterConfig: {
|
|
command: "codex",
|
|
model: "gpt-5.4-mini",
|
|
instructionsFilePath: instructionsPath,
|
|
},
|
|
});
|
|
|
|
const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson;
|
|
|
|
expect(canonical).toContain("missing_absolute_root");
|
|
expect(canonical).not.toContain("Legacy direct-path instructions");
|
|
expect(canonical).not.toContain("contentHash");
|
|
});
|
|
|
|
it("does not include raw secret or plain env values in canonical session metadata", async () => {
|
|
const metadata = await buildSessionConfigMetadata();
|
|
const canonical = metadata.fingerprints.sessionFingerprint.canonicalJson;
|
|
|
|
expect(canonical).toContain("secret-1");
|
|
expect(canonical).toContain('"version":7');
|
|
expect(canonical).not.toContain("resolved-secret-value");
|
|
expect(canonical).not.toContain("plain-value");
|
|
expect(canonical).not.toContain("enabled");
|
|
expect(canonical).not.toContain("openai-api-key");
|
|
});
|
|
});
|
|
|
|
describe("stripConfiguredModelFromSessionParams", () => {
|
|
it("removes the internal model key from persisted session params", () => {
|
|
expect(
|
|
stripConfiguredModelFromSessionParams({
|
|
sessionId: "thread-1",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
}),
|
|
).toEqual({ sessionId: "thread-1" });
|
|
});
|
|
|
|
it("returns null when session params are missing", () => {
|
|
expect(stripConfiguredModelFromSessionParams(null)).toBeNull();
|
|
expect(stripConfiguredModelFromSessionParams(undefined)).toBeNull();
|
|
});
|
|
|
|
it("returns a copy without mutating the input", () => {
|
|
const input = { sessionId: "thread-1", __paperclipConfiguredModel: "gpt-5.4-mini" };
|
|
const result = stripConfiguredModelFromSessionParams(input);
|
|
expect(result).not.toBe(input);
|
|
expect(input.__paperclipConfiguredModel).toBe("gpt-5.4-mini");
|
|
});
|
|
|
|
it("returns an empty object when only the internal model key is present (caller must normalize)", () => {
|
|
const stripped = stripConfiguredModelFromSessionParams({
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
});
|
|
expect(stripped).toEqual({});
|
|
// Callers that forward params to adapters must normalize {} back to null so
|
|
// the pre-PR null contract is preserved (adapters distinguishing {} from null).
|
|
expect(normalizeSessionParams(stripped)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("stripPaperclipSessionMetadataFromSessionParams", () => {
|
|
it("removes all internal Paperclip session metadata before adapter invocation", () => {
|
|
expect(
|
|
stripPaperclipSessionMetadataFromSessionParams({
|
|
sessionId: "thread-1",
|
|
cwd: "/tmp/project",
|
|
paperclipAiCredentialIdentity: "grant:user:generation",
|
|
__paperclipConfiguredModel: "gpt-5.4-mini",
|
|
__paperclipConfigFingerprint: "v1:sha256:abc",
|
|
__paperclipConfigFingerprintVersion: 1,
|
|
__paperclipConfigCategories: ["adapterConfig"],
|
|
__paperclipConfigCategoryFingerprints: { adapterConfig: "v1:sha256:def" },
|
|
}),
|
|
).toEqual({
|
|
sessionId: "thread-1",
|
|
cwd: "/tmp/project",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isTaskSessionCredentialCompatible", () => {
|
|
it("retains the server-owned identity even when the Codex codec drops it", () => {
|
|
const saved = { sessionId: "thread-1", paperclipAiCredentialIdentity: "grant:user:generation" };
|
|
const decoded = codexSessionCodec.deserialize(saved);
|
|
expect(decoded).toEqual({ sessionId: "thread-1" });
|
|
expect(isTaskSessionCredentialCompatible(saved, "grant:user:generation")).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
undefined,
|
|
null,
|
|
{},
|
|
{ paperclipAiCredentialIdentity: "other-grant:user:generation" },
|
|
{ paperclipAiCredentialIdentity: "grant:other-user:generation" },
|
|
{ paperclipAiCredentialIdentity: "grant:user:new-generation" },
|
|
])("requires the same saved grant, responsible user, and credential generation: %j", (saved) => {
|
|
expect(isTaskSessionCredentialCompatible(saved, "grant:user:generation")).toBe(false);
|
|
});
|
|
|
|
it("preserves unmanaged session behavior", () => {
|
|
expect(isTaskSessionCredentialCompatible({ sessionId: "thread-1" }, undefined)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("normalizeSessionParams", () => {
|
|
it("collapses an empty object to null", () => {
|
|
expect(normalizeSessionParams({})).toBeNull();
|
|
});
|
|
|
|
it("returns null for null or undefined inputs", () => {
|
|
expect(normalizeSessionParams(null)).toBeNull();
|
|
expect(normalizeSessionParams(undefined)).toBeNull();
|
|
});
|
|
|
|
it("preserves a non-empty object", () => {
|
|
const params = { sessionId: "thread-1" };
|
|
expect(normalizeSessionParams(params)).toBe(params);
|
|
});
|
|
});
|
|
|
|
describe("deriveTaskKeyWithHeartbeatFallback", () => {
|
|
it("returns explicit taskKey when present", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123");
|
|
});
|
|
|
|
it("returns explicit issueId when no taskKey", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ issueId: "issue-456" }, null)).toBe("issue-456");
|
|
});
|
|
|
|
it("returns __heartbeat__ for timer wakes with no explicit key", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ wakeSource: "timer" }, null)).toBe("__heartbeat__");
|
|
});
|
|
|
|
it("prefers explicit key over heartbeat fallback even on timer wakes", () => {
|
|
expect(
|
|
deriveTaskKeyWithHeartbeatFallback({ wakeSource: "timer", taskKey: "issue-789" }, null),
|
|
).toBe("issue-789");
|
|
});
|
|
|
|
it("returns null for non-timer wakes with no explicit key", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({ wakeSource: "on_demand" }, null)).toBeNull();
|
|
});
|
|
|
|
it("returns null for empty context", () => {
|
|
expect(deriveTaskKeyWithHeartbeatFallback({}, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("comment wake batching", () => {
|
|
it("updates the latest comment when coalescing queued follow-up wakes", () => {
|
|
const merged = mergeCoalescedContextSnapshot(
|
|
{
|
|
issueId: "issue-1",
|
|
wakeReason: "issue_commented",
|
|
wakeCommentId: "comment-1",
|
|
wakeCommentIds: ["comment-1"],
|
|
paperclipWake: {
|
|
latestCommentId: "comment-1",
|
|
},
|
|
},
|
|
{
|
|
issueId: "issue-1",
|
|
wakeReason: "issue_commented",
|
|
wakeCommentId: "comment-2",
|
|
},
|
|
);
|
|
|
|
expect(merged.wakeCommentIds).toEqual(["comment-1", "comment-2"]);
|
|
expect(merged.commentId).toBe("comment-2");
|
|
expect(merged.wakeCommentId).toBe("comment-2");
|
|
expect(merged.paperclipWake).toBeUndefined();
|
|
});
|
|
|
|
it("keeps forceFreshSession sticky once any coalesced wake requests it", () => {
|
|
const merged = mergeCoalescedContextSnapshot(
|
|
{
|
|
issueId: "issue-1",
|
|
forceFreshSession: true,
|
|
},
|
|
{
|
|
issueId: "issue-1",
|
|
forceFreshSession: false,
|
|
},
|
|
);
|
|
|
|
expect(merged.forceFreshSession).toBe(true);
|
|
});
|
|
|
|
it("keeps connection tool refresh intent while allowing harness session recovery", () => {
|
|
const merged = mergeCoalescedContextSnapshot(
|
|
{ issueId: "issue-1", wakeReason: "issue_commented", refreshTools: true },
|
|
{ issueId: "issue-1", wakeReason: "issue_commented", refreshTools: false },
|
|
);
|
|
expect(merged.refreshTools).toBe(true);
|
|
expect(shouldResetTaskSessionForWake(merged)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("buildExplicitResumeSessionOverride", () => {
|
|
it("reuses saved task session params when they belong to the selected failed run", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "session-before",
|
|
resumeRunSessionIdAfter: "session-after",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "session-after",
|
|
cwd: "/tmp/project",
|
|
},
|
|
sessionDisplayId: "session-after",
|
|
lastRunId: "run-1",
|
|
},
|
|
sessionCodec: codexSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "session-after",
|
|
sessionParams: {
|
|
sessionId: "session-after",
|
|
cwd: "/tmp/project",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("falls back to the selected run session id when no matching task session params are available", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "session-before",
|
|
resumeRunSessionIdAfter: "session-after",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "other-session",
|
|
cwd: "/tmp/project",
|
|
},
|
|
sessionDisplayId: "other-session",
|
|
lastRunId: "run-2",
|
|
},
|
|
sessionCodec: codexSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "session-after",
|
|
sessionParams: {
|
|
sessionId: "session-after",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not synthesize Hermes resume params from a truncated display id", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "20260601_141000_c861e4",
|
|
},
|
|
sessionDisplayId: "20260601_141000_",
|
|
lastRunId: "run-2",
|
|
},
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("uses validated Hermes run result params before truncated display ids", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
resumeRunSessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
taskSession: null,
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps Hermes run result params and display id together when falling back from a prior session", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "20260601_140000_old123",
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
resumeRunSessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
taskSession: null,
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("ignores invalid Hermes run result params", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
resumeRunSessionParams: {
|
|
sessionId: "from",
|
|
},
|
|
taskSession: null,
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("keeps full Hermes task-session params even when the saved display id is truncated", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: null,
|
|
resumeRunSessionIdAfter: "20260601_141558_",
|
|
taskSession: {
|
|
sessionParamsJson: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
sessionDisplayId: "20260601_141558_",
|
|
lastRunId: "run-1",
|
|
},
|
|
sessionCodec: truncatingHermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("falls back from a poisoned Hermes session-after value to a valid session-before value", () => {
|
|
const result = buildExplicitResumeSessionOverride({
|
|
adapterType: "hermes_local",
|
|
resumeFromRunId: "run-1",
|
|
resumeRunSessionIdBefore: "20260601_141558_c861e4",
|
|
resumeRunSessionIdAfter: "from",
|
|
taskSession: null,
|
|
sessionCodec: hermesSessionCodec,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
sessionDisplayId: "20260601_141558_c861e4",
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveNextSessionState", () => {
|
|
it("preserves previous valid Hermes session state when failed adapter output reports prose tokens", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: {
|
|
sessionId: "from",
|
|
},
|
|
sessionId: "from",
|
|
sessionDisplayId: "from",
|
|
errorMessage: "Session not found: 20260601_141558_",
|
|
},
|
|
outcome: "failed",
|
|
previousParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
previousDisplayId: "20260601_141558_c861e4",
|
|
previousLegacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
displayId: "20260601_141558_c861e4",
|
|
legacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
});
|
|
|
|
it("drops poisoned previous Hermes session state instead of passing it to the next run", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionId: "from",
|
|
sessionDisplayId: "from",
|
|
errorMessage: "Session not found: from",
|
|
},
|
|
outcome: "failed",
|
|
previousParams: {
|
|
sessionId: "from",
|
|
},
|
|
previousDisplayId: "from",
|
|
previousLegacySessionId: "from",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: null,
|
|
displayId: null,
|
|
legacySessionId: null,
|
|
});
|
|
});
|
|
|
|
it("derives Hermes display state from canonical params instead of adapter-truncated display ids", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
sessionDisplayId: "20260601_141558_",
|
|
},
|
|
outcome: "succeeded",
|
|
previousParams: null,
|
|
previousDisplayId: null,
|
|
previousLegacySessionId: null,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
displayId: "20260601_141558_c861e4",
|
|
legacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
});
|
|
|
|
it("uses one canonical Hermes explicit session candidate instead of mixing valid and invalid fields", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "hermes_local",
|
|
codec: truncatingHermesSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionParams: {
|
|
sessionId: "from",
|
|
},
|
|
sessionId: "20260601_141558_c861e4",
|
|
sessionDisplayId: "20260601_141558_",
|
|
},
|
|
outcome: "succeeded",
|
|
previousParams: {
|
|
sessionId: "20260601_140000_previous",
|
|
},
|
|
previousDisplayId: "20260601_140000_previous",
|
|
previousLegacySessionId: "20260601_140000_previous",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
params: {
|
|
sessionId: "20260601_141558_c861e4",
|
|
},
|
|
displayId: "20260601_141558_c861e4",
|
|
legacySessionId: "20260601_141558_c861e4",
|
|
});
|
|
});
|
|
|
|
it("keeps non-Hermes arbitrary session ids unchanged", () => {
|
|
const result = resolveNextSessionState({
|
|
adapterType: "codex_local",
|
|
codec: codexSessionCodec,
|
|
adapterResult: {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
sessionId: "from",
|
|
},
|
|
outcome: "failed",
|
|
previousParams: null,
|
|
previousDisplayId: null,
|
|
previousLegacySessionId: null,
|
|
});
|
|
|
|
expect(result.legacySessionId).toBe("from");
|
|
});
|
|
});
|
|
|
|
describe("formatRuntimeWorkspaceWarningLog", () => {
|
|
it("emits informational workspace warnings on stdout", () => {
|
|
expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({
|
|
stream: "stdout",
|
|
chunk: "[paperclip] Using fallback workspace\n",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
|
|
it("moves the explicitly selected workspace to the front", () => {
|
|
const rows = [
|
|
{ id: "workspace-1", cwd: "/tmp/one" },
|
|
{ id: "workspace-2", cwd: "/tmp/two" },
|
|
{ id: "workspace-3", cwd: "/tmp/three" },
|
|
];
|
|
|
|
expect(
|
|
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
|
|
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
|
|
});
|
|
|
|
it("keeps the original order when no preferred workspace is selected", () => {
|
|
const rows = [
|
|
{ id: "workspace-1" },
|
|
{ id: "workspace-2" },
|
|
];
|
|
|
|
expect(
|
|
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
|
|
).toEqual(["workspace-1", "workspace-2"]);
|
|
});
|
|
|
|
it("keeps the original order when the selected workspace is missing", () => {
|
|
const rows = [
|
|
{ id: "workspace-1" },
|
|
{ id: "workspace-2" },
|
|
];
|
|
|
|
expect(
|
|
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
|
|
).toEqual(["workspace-1", "workspace-2"]);
|
|
});
|
|
});
|
|
|
|
describe("parseSessionCompactionPolicy", () => {
|
|
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
|
|
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 0,
|
|
maxRawInputTokens: 0,
|
|
maxSessionAgeHours: 0,
|
|
});
|
|
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 0,
|
|
maxRawInputTokens: 0,
|
|
maxSessionAgeHours: 0,
|
|
});
|
|
});
|
|
|
|
it("keeps conservative defaults for adapters without confirmed native compaction", () => {
|
|
expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 200,
|
|
maxRawInputTokens: 2_000_000,
|
|
maxSessionAgeHours: 72,
|
|
});
|
|
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 200,
|
|
maxRawInputTokens: 2_000_000,
|
|
maxSessionAgeHours: 72,
|
|
});
|
|
});
|
|
|
|
it("lets explicit agent overrides win over adapter defaults", () => {
|
|
expect(
|
|
parseSessionCompactionPolicy(
|
|
buildAgent("codex_local", {
|
|
heartbeat: {
|
|
sessionCompaction: {
|
|
maxSessionRuns: 25,
|
|
maxRawInputTokens: 500_000,
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
).toEqual({
|
|
enabled: true,
|
|
maxSessionRuns: 25,
|
|
maxRawInputTokens: 500_000,
|
|
maxSessionAgeHours: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isWorkspaceSyncConflictFailure", () => {
|
|
it("matches the git workspace reconciliation failure signatures", () => {
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"Failed to merge concurrent remote git histories for a5d46a8005b3 and c1042c11774a: Command failed: git merge-tree --write-tree",
|
|
)).toBe(true);
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"Failed to integrate concurrent remote git history for a5d46a8005b3 after multiple retries.",
|
|
)).toBe(true);
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"error: /tmp/restore/git-delta.bundle did not send all necessary objects",
|
|
)).toBe(true);
|
|
expect(isWorkspaceSyncConflictFailure(
|
|
"error: Repository lacks these prerequisite commits: 4c631700",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("ignores unrelated adapter failures", () => {
|
|
expect(isWorkspaceSyncConflictFailure("Codex exited with code 2")).toBe(false);
|
|
expect(isWorkspaceSyncConflictFailure("no Codex credentials provisioned for managed home")).toBe(false);
|
|
expect(isWorkspaceSyncConflictFailure(null)).toBe(false);
|
|
expect(isWorkspaceSyncConflictFailure("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("reconcileReusedExecutionWorkspaceProjectWorkspaceId", () => {
|
|
it("backfills a null existing binding from the resolved value", () => {
|
|
expect(
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, "resolved-workspace"),
|
|
).toBe("resolved-workspace");
|
|
});
|
|
|
|
it("never overwrites an existing binding, even when a resolved value is present", () => {
|
|
expect(
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId("existing-workspace", "resolved-workspace"),
|
|
).toBe("existing-workspace");
|
|
});
|
|
|
|
it("returns null when both existing and resolved are absent", () => {
|
|
expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, null)).toBeNull();
|
|
expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, undefined)).toBeNull();
|
|
expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, undefined)).toBeNull();
|
|
});
|
|
|
|
it("backfills when existing is undefined and resolved is present", () => {
|
|
expect(
|
|
reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, "resolved-workspace"),
|
|
).toBe("resolved-workspace");
|
|
});
|
|
});
|