mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents receive task constraints and completion tools from Paperclip. > - Completion tools already define the procedure for reporting a result. > - Repeated procedure text adds instructions to each full task turn. > - The final reply must still explain a blocker and link a saved document. > - This pull request removes repeated procedure text and keeps these visible outcome requirements explicit. > - A document receipt supplies the exact link, and stricter evals check the persisted reply and browser navigation. ## Linked Issues or Issue Description Refs: #14961. Related: #14948 and #15007. **What happened?** Native task envelopes repeat completion procedure text. A reduced envelope needs explicit final-reply requirements. The `write_document` receipt also lacks a canonical document link. **Expected behavior** Keep the completion tools as the source of procedure details. Require one accepted completion result before the final reply. A blocked reply must explain the reason, owner and unblock action. A document reply must contain a working link to the saved document. **Steps to reproduce** 1. Run the native assigned-skill document case and native blocker case. 2. Inspect the run-attributed provider final and its persisted comment. 3. Check the blocker explanation or open the final reply's document link. ## What Changed - Remove repeated completion procedure text from the native task constraints and backend instructions. - Keep explicit blocker and document-link requirements in full task turns. - Return a company/task-scoped `documentHref` from `write_document`. Preserve the link in the idempotent mutation receipt. - Repeat canonical links for this run's current saved revisions in accepted completion feedback. Give blocked providers final-response guidance for the cause, owner and unblock action. - Keep internal document/comment anchors when Markdown issue links load cached issue details. - Add a manual six-cell comparison suite with strict source, build, default-instruction and budget admission. - Capture eighteen shared runnerd RPC projections and six direct OpenCode HTTP projections across start, resume and continuation phases, using scripted local transports and no provider execution. - Apply v3 checks only to the manual instruction comparison; preserve v2 checks for the existing native completion suite. Check the actual persisted blocker reason and exact saved-document link. Click the rendered document link and check the original content marker in the classic document card or the new document tab. - Forward exact OpenCode finishing calls through the controller. Wait for acceptance, keep accepted feedback and concrete rejection text, and reject malformed responses. Preserve ordinary dynamic-tool response handling. - Settle the completion decision and tool response before mapping a racing idle/error/abort event or handling explicit close/interruption. Reject a concurrent finishing call before controller admission. - Add a provider-free regression through real runnerd, the OpenCode proxy and a fake provider. Reject the first completion, accept the corrected report in the same turn, and propose one result. - Keep all original verdicts unchanged. Treat replay under new checks as separate diagnostics. ## Verification - `pnpm -r typecheck` and `pnpm build` pass locally. - Native document-authority tests pass, including company/run authorization and idempotent replay. - Native runtime-context, backend and measurement tests pass. - Final-answer calibration, protocol scoring, source-admission and catalog tests pass. Wrong reasons, absent links and wrong link targets fail. - `pnpm test:e2e:runner:typecheck` passes. Discovery lists exactly six single-attempt local cells with the declared models. - Exported `prepareNativeInstructionPreflight` then `verifyNativeInstructionPreflight` pass on this clean committed source. They build locally and make zero provider calls. - Corrective live confirmation is incomplete. Source3a7349dpassed both Claude and both Codex cases. OpenCode saved the correct document but omitted its final link; its blocker case was canceled before paid execution. Preserve this failure. Thee171282confirmation was stopped during build after fresh review found a completion-settlement race; it executed zero providers. Sourcec3e0cb303fixes that race. Two affected OpenCode cases await fresh review and one bounded confirmation; earlier results remain attributed to their original source. - OpenCode proxy parsing, driver, factory and input tests: 81 pass across retained focused runs, including six settlement races. Evaluator/scoring/admission checks: 122 pass. The real proxy regression passes. Fresh local prepare then verify passes with 18 shared and 6 direct scripted captures, fresh SDK/Rust builds and zero providers. - The full local suite recorded two failures: a webhook timeout and a Git-scan load count mismatch. Both files pass in isolation with unchanged assertions/time budgets; preserve the original failure log. Freshc3e0cb303CI and review are pending. This PR remains draft. ## Risks - Final-answer wording can vary by provider. The checks cover the declared release-access blocker and saved document fixture, not general answer quality. - A single trial does not establish general equivalence, cause, speed, cost or live resume behavior. - `documentHref` is an additive receipt field. It points to the current saved document, not an immutable historical revision. Replaying an older receipt does not fabricate a new link. - The correction adds four production paths for document receipts, accepted completion feedback and UI navigation, plus four OpenCode controller/proxy paths, beyond the original three instruction paths. Completion rejection must remain repairable; the production-boundary regression covers it. - Preserve the frozen comparison context for live measurement. A merge-tree check against current master is clean. Do not relabel earlier live results as results from a later source tree. ## Model Used - OpenAI Codex, GPT-6 family. The exact serving model ID and context window are unavailable in this session. Capabilities used: reasoning, code editing, shell execution, test authoring and evidence review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { NATIVE_COMPLETION_BUDGET_CENTS } from "./native-completion-defaults.js";
|
|
import path from "node:path";
|
|
import { isManagedHiringCase } from "./chat-cases.js";
|
|
import { FixtureRegistry } from "./fixture-registry.js";
|
|
import { TASK_TITLE_BUDGET_CENTS } from "./task-titles.js";
|
|
import { stageGrokSubscriptionFixture } from "./grok-subscription-fixture.js";
|
|
import type { RunnerApi } from "./api.js";
|
|
import type {
|
|
CredentialName,
|
|
MatrixExecution,
|
|
SecretReference,
|
|
SecretReferenceMap,
|
|
} from "./types.js";
|
|
|
|
interface CompanyRecord {
|
|
id: string;
|
|
issuePrefix?: string | null;
|
|
name: string;
|
|
}
|
|
|
|
interface SecretRecord {
|
|
id: string;
|
|
}
|
|
interface PluginRecord {
|
|
id: string;
|
|
pluginKey: string;
|
|
status: string;
|
|
}
|
|
interface EnvironmentRecord {
|
|
id: string;
|
|
driver: string;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
interface AgentRecord {
|
|
id: string;
|
|
name: string;
|
|
companyId: string;
|
|
}
|
|
interface ManagedAccountFixture {
|
|
connectionId: string;
|
|
binding: {
|
|
provider: "openai" | "anthropic";
|
|
method: "api_key";
|
|
mode: "responsible_user";
|
|
};
|
|
}
|
|
|
|
interface ProjectRecord {
|
|
id: string;
|
|
name: string;
|
|
primaryWorkspace?: {
|
|
id: string;
|
|
cwd?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface LiveFixtureValues {
|
|
company: CompanyRecord;
|
|
secretRefs: SecretReferenceMap;
|
|
environment: EnvironmentRecord;
|
|
agent: AgentRecord;
|
|
project?: ProjectRecord;
|
|
aiConnection?: ManagedAccountFixture;
|
|
|
|
onboardingRuntime?: {
|
|
mode: "production-wizard" | "post-onboarding-runtime-switch";
|
|
originalAdapterType: string;
|
|
originalModel: string | null;
|
|
testedAdapterType: string;
|
|
};
|
|
teardown(): Promise<void>;
|
|
}
|
|
|
|
function value<T>(resolved: ReadonlyMap<string, unknown>, id: string): T {
|
|
const result = resolved.get(id);
|
|
if (!result) throw new Error(`Missing resolved fixture ${id}`);
|
|
return result as T;
|
|
}
|
|
|
|
async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) {
|
|
const deadlineAt = Date.now() + 120_000;
|
|
let lastError: unknown;
|
|
while (Date.now() < deadlineAt) {
|
|
try {
|
|
await api.delete(
|
|
`/api/environments/${environmentId}?destroyReusableSandboxLeases=true`,
|
|
{
|
|
allowNotFound: true,
|
|
},
|
|
);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Daytona lease cleanup failed: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
);
|
|
}
|
|
|
|
export async function setupLiveFixtures(input: {
|
|
api: RunnerApi;
|
|
execution: MatrixExecution;
|
|
executionNonce: string;
|
|
workspacePath: string;
|
|
credentials: Partial<Record<CredentialName, string>>;
|
|
daytonaImage?: string;
|
|
}): Promise<LiveFixtureValues> {
|
|
const { api, execution } = input;
|
|
const registry = new FixtureRegistry();
|
|
|
|
if (execution.environment.id === "daytona") {
|
|
registry.register<PluginRecord>({
|
|
id: "sandbox-provider",
|
|
async setup() {
|
|
return api.post<PluginRecord>("/api/plugins/install", {
|
|
packageName: path.resolve(
|
|
import.meta.dirname,
|
|
"../../packages/plugins/sandbox-providers/daytona",
|
|
),
|
|
isLocalPath: true,
|
|
});
|
|
},
|
|
async teardown() {
|
|
// The plugin is installed only in the isolated instance/database. The
|
|
// launcher removes that complete instance after the environment lease
|
|
// has been destroyed, so no global uninstall mutation is necessary.
|
|
},
|
|
});
|
|
}
|
|
|
|
registry.register<CompanyRecord>({
|
|
id: "company",
|
|
async setup() {
|
|
return api.post<CompanyRecord>("/api/companies", {
|
|
name: `Runner E2E ${execution.id} ${input.executionNonce}`,
|
|
description: "Ephemeral paid full-stack runner acceptance fixture",
|
|
budgetMonthlyCents: ["native-completion", "native-instruction-consolidation"].includes(execution.suite.id) ? NATIVE_COMPLETION_BUDGET_CENTS
|
|
: execution.suite.id === "task-titles" ? TASK_TITLE_BUDGET_CENTS
|
|
: execution.suite.id === "stock-harness" ? 1_000 : 0,
|
|
});
|
|
},
|
|
async teardown() {
|
|
// The isolated instance/database is removed by the launcher. Do not call
|
|
// company deletion here: metered runs intentionally retain cost-event
|
|
// references until that instance-wide teardown.
|
|
},
|
|
});
|
|
|
|
registry.register<SecretReferenceMap>({
|
|
id: "secrets",
|
|
dependencies: ["company"],
|
|
async setup(resolved) {
|
|
const company = value<CompanyRecord>(resolved, "company");
|
|
const refs: SecretReferenceMap = {};
|
|
for (const credentialName of execution.requiredCredentials) {
|
|
if (credentialName === "GROK_AUTH_JSON") continue;
|
|
const rawValue = input.credentials[credentialName];
|
|
if (!rawValue) throw new Error(`Missing credential ${credentialName}`);
|
|
const secret = await api.postSensitive<SecretRecord>(
|
|
`/api/companies/${company.id}/secrets`,
|
|
{
|
|
name: `Runner E2E ${credentialName} ${input.executionNonce}`,
|
|
key: credentialName,
|
|
value: rawValue,
|
|
description: `Ephemeral credential for ${execution.id}`,
|
|
},
|
|
);
|
|
refs[credentialName] = {
|
|
type: "secret_ref",
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
} satisfies SecretReference;
|
|
}
|
|
return refs;
|
|
},
|
|
});
|
|
|
|
const grokSubscription = execution.profile.credential === "GROK_AUTH_JSON";
|
|
if (grokSubscription) {
|
|
registry.register<() => Promise<void>>({
|
|
id: "subscription-login",
|
|
dependencies: ["company"],
|
|
async setup(resolved) {
|
|
const raw = input.credentials.GROK_AUTH_JSON;
|
|
if (!raw) throw new Error("Missing credential GROK_AUTH_JSON");
|
|
return stageGrokSubscriptionFixture({
|
|
raw, companyId: value<CompanyRecord>(resolved, "company").id,
|
|
environment: process.env,
|
|
});
|
|
},
|
|
async teardown(remove) { await remove(); },
|
|
});
|
|
}
|
|
|
|
registry.register<EnvironmentRecord>({
|
|
id: "environment",
|
|
dependencies: [
|
|
"company",
|
|
"secrets",
|
|
...(grokSubscription ? ["subscription-login"] : []),
|
|
...(execution.environment.id === "daytona" ? ["sandbox-provider"] : []),
|
|
],
|
|
async setup(resolved) {
|
|
const company = value<CompanyRecord>(resolved, "company");
|
|
const secretRefs = value<SecretReferenceMap>(resolved, "secrets");
|
|
if (execution.environment.id === "local") {
|
|
// Paperclip has one instance-managed local environment. The company
|
|
// creation API ensures it exists; creating a second local environment
|
|
// is intentionally rejected by the public API.
|
|
const environments = await api.get<EnvironmentRecord[]>(
|
|
`/api/companies/${company.id}/environments?driver=local`,
|
|
);
|
|
const local = environments.find(
|
|
(candidate) => candidate.driver === "local",
|
|
);
|
|
if (!local)
|
|
throw new Error(
|
|
"Isolated Paperclip instance did not create its managed local environment",
|
|
);
|
|
return local;
|
|
}
|
|
return api.post<EnvironmentRecord>(
|
|
`/api/companies/${company.id}/environments`,
|
|
execution.environment.buildEnvironment({
|
|
secretRefs,
|
|
daytonaImage: input.daytonaImage,
|
|
executionId: input.executionNonce,
|
|
}),
|
|
);
|
|
},
|
|
async teardown(environment) {
|
|
if (execution.environment.id === "daytona") {
|
|
await deleteDaytonaEnvironment(api, environment.id);
|
|
}
|
|
},
|
|
});
|
|
|
|
const managedHiring = isManagedHiringCase(execution.suite.id, execution.task.id);
|
|
if (managedHiring) {
|
|
registry.register<ManagedAccountFixture>({
|
|
id: "ai-connection",
|
|
dependencies: ["company"],
|
|
async setup(resolved) {
|
|
const company = value<CompanyRecord>(resolved, "company");
|
|
const provider =
|
|
execution.profile.provider === "acpx" ? "anthropic" : "openai";
|
|
const key =
|
|
provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
const apiKey = input.credentials[key];
|
|
if (!apiKey) throw new Error(`Missing credential ${key}`);
|
|
const account = await api.postSensitive<{ connectionId: string }>(
|
|
`/api/companies/${company.id}/ai-connections`,
|
|
{
|
|
provider,
|
|
method: "api_key",
|
|
name: `Runner E2E account ${input.executionNonce}`,
|
|
ownership: "personal",
|
|
apiKey,
|
|
agentIds: [],
|
|
allAgents: false,
|
|
},
|
|
);
|
|
return {
|
|
connectionId: account.connectionId,
|
|
binding: { provider, method: "api_key", mode: "responsible_user" },
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
registry.register<AgentRecord>({
|
|
id: "agent",
|
|
dependencies: [
|
|
"company",
|
|
"secrets",
|
|
"environment",
|
|
...(grokSubscription ? ["subscription-login"] : []),
|
|
...(managedHiring ? ["ai-connection"] : []),
|
|
],
|
|
async setup(resolved) {
|
|
const company = value<CompanyRecord>(resolved, "company");
|
|
const environment = value<EnvironmentRecord>(resolved, "environment");
|
|
const secretRefs = value<SecretReferenceMap>(resolved, "secrets");
|
|
const agent = execution.profile.buildAgent({
|
|
environmentId: environment.id,
|
|
environmentFixtureId: execution.environment.id,
|
|
workspacePath: input.workspacePath,
|
|
secretRefs,
|
|
executionId: input.executionNonce,
|
|
});
|
|
if (execution.suite.id === "stock-harness") agent.budgetMonthlyCents = 1_000;
|
|
if (managedHiring) {
|
|
const account = value<ManagedAccountFixture>(resolved, "ai-connection");
|
|
const config = agent.adapterConfig as Record<string, unknown>;
|
|
delete config.env;
|
|
agent.runtimeConfig = {
|
|
...(agent.runtimeConfig as Record<string, unknown>),
|
|
aiConnection: account.binding,
|
|
};
|
|
}
|
|
return api.post<AgentRecord>(
|
|
`/api/companies/${company.id}/agents`,
|
|
agent,
|
|
);
|
|
},
|
|
async teardown() {
|
|
// Agent state is instance-local. Daytona environment teardown below is
|
|
// the only fixture cleanup that must reach an external provider.
|
|
},
|
|
});
|
|
|
|
if (execution.environment.configurationKey === "warm-reuse-v1"
|
|
|| (execution.suite.id === "extended-harnesses" && execution.task.id === "file-edit-validate")) {
|
|
registry.register<ProjectRecord>({
|
|
id: "project",
|
|
dependencies: ["company", "environment"],
|
|
async setup(resolved) {
|
|
const company = value<CompanyRecord>(resolved, "company");
|
|
const environment = value<EnvironmentRecord>(resolved, "environment");
|
|
return api.post<ProjectRecord>(
|
|
`/api/companies/${company.id}/projects`,
|
|
{
|
|
name: `Runner E2E workspace project ${input.executionNonce}`,
|
|
description:
|
|
"Ephemeral project anchoring the fixture execution workspace and file copy-back",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
sharedWorkspaceConcurrency: "serialize",
|
|
allowIssueOverride: false,
|
|
environmentId: environment.id,
|
|
workspaceStrategy: { type: "project_primary" },
|
|
},
|
|
workspace: {
|
|
name: "Primary",
|
|
sourceType: "local_path",
|
|
cwd: input.workspacePath,
|
|
isPrimary: true,
|
|
},
|
|
},
|
|
);
|
|
},
|
|
async teardown() {
|
|
// The isolated instance is deleted after provider resources are gone.
|
|
},
|
|
});
|
|
}
|
|
|
|
const setup = await registry.setupAll();
|
|
return {
|
|
company: value<CompanyRecord>(setup.values, "company"),
|
|
secretRefs: value<SecretReferenceMap>(setup.values, "secrets"),
|
|
environment: value<EnvironmentRecord>(setup.values, "environment"),
|
|
agent: value<AgentRecord>(setup.values, "agent"),
|
|
...(setup.values.has("project")
|
|
? { project: value<ProjectRecord>(setup.values, "project") }
|
|
: {}),
|
|
...(managedHiring
|
|
? {
|
|
aiConnection: value<ManagedAccountFixture>(
|
|
setup.values,
|
|
"ai-connection",
|
|
),
|
|
}
|
|
: {}),
|
|
teardown: setup.teardown,
|
|
};
|
|
}
|