mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat connectors let people use the same tasks and agent tools from external conversations. > - Agents need communication guidance that fits the conversation medium. > - That guidance belongs in the original task context, without repeated instructions on each turn. > - Connection owners also need clear settings and a consistent way to remove a connection. > - This pull request adds initial Slack guidance, optional connection instructions, and chat connection menus. > - The benefit is clearer Slack replies with the existing Paperclip workflow and permissions. ## Linked Issues or Issue Description **What existing behavior does this improve?** Agent replies in Slack and chat connection management in the Apps catalog. **Current behavior** Slack tasks do not carry a saved communication profile. The catalog shows a separate Manage button and does not offer removal on every chat connection row. **Proposed behavior** Save Slack guidance when a new conversation creates a task. Restore that original guidance when a model session is rebuilt. Do not append it to ordinary follow-ups. Expose optional additional instructions in Slack Settings. Put Manage and Remove connection in a three-dot menu for all chat providers. Keep Finish setup visible for drafts. **Reason and benefit** Small answers fit in Slack. Substantial deliverables use ordinary document or artifact tools with a useful Slack summary. Connection settings apply to new tasks and cannot change permissions. Users can remove both active and unfinished chat connections from the catalog. **Breaking changes** Two additive database columns store endpoint preferences and the initial conversation snapshot. Existing endpoints default to empty preferences. Existing conversations keep their original behavior. Non-Slack guidance is unchanged. Related public context: https://github.com/paperclipai/paperclip/pull/13741 improves native chat recovery. This change adds communication context to those existing execution paths. A search found no duplicate communication-guidance PR. ## What Changed - Add a provider-guidance registry, enabled for Slack first. - Persist optional endpoint communication instructions and capture an immutable snapshot when a conversation creates a task. - Resolve guidance from the verified company-scoped connection. Restore it for fresh native and legacy sessions without per-turn reminders, extra model calls, or extra context queries. - Add the Slack Settings field, validation, audit coverage, and Storybook save/error states. - Add Manage and Remove connection menus for all seven chat providers. Keep the draft setup button. Require removal confirmation and allow retry after failure. - Add regression coverage, an active/draft menu story, and connector documentation. ## Verification All CI checks are green for5f48df4e0. Greptile scored this head 5/5 with no actionable findings. No review threads remain unresolved. - Passed `pnpm -r typecheck` and `pnpm build` on PR head5f48df4e0. - Passed design-token checks, UI typecheck, and all 20 catalog tests after rebase. Tests cover all seven providers, active/draft removal, confirmation, cache refresh, errors, and cancellation. - Verified the active/draft menu in Storybook. The interaction test runs without browser console errors. - Passed focused guidance, endpoint persistence/isolation, heartbeat trust, native context, ACPX, adapter utility, and CLI recovery tests. Full UI and CLI groups passed (6,512 and 502 tests). - Tested real Slack conversations on staging: concise updates with public links, a planning question with buttons, a saved plan, a saved report, task creation and assignment, and explicit detailed output. Old tasks retained original preferences after an edit; a new task used the changed preferences. Restored the staging setting afterward. - Existing safe progress remained visible without duplicate final replies or private reasoning. - Broad local tests found resource/time-sensitive failures that passed targeted reruns. One Cursor archive-download fixture failed on both this branch and the unchanged main checkout. The full local suite is not claimed clean. All PR-head CI test shards passed, including general, serialized, Runner, and browser suites. The redundant local full-suite rerun was stopped after CI completed successfully. - Live delegation was not tested because the staging company has only one agent. Live testing also found separate latency and runner task-editing capability gaps; this PR does not add connector-specific workflow behavior to hide them. ## Risks - Prompt guidance changes the form of new Slack replies. Explicit requests for detail still take precedence. - The additive migration is idempotent. Conversation snapshots remain fixed when connection settings change. - Native and legacy recovery must preserve the initial context without duplicates; targeted tests cover these paths. - Removing a connection stops new work through the existing lifecycle action. It retains Paperclip task history and does not delete the external app or bot. ## Model Used OpenAI GPT-6 through Codex, with repository editing, shell tools, and browser testing. The host does not expose a more specific model ID or context-window size. No separate model calls were added to the product. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (targeted suites; broad local limitations are listed above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInputV1 } from "./native-execution.js";
|
|
import {
|
|
NATIVE_RUNTIME_ASSET_SCHEMA,
|
|
PAPERCLIP_EXECUTION_PROMPT,
|
|
PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
|
canonicalNativeRuntimeContextDigest,
|
|
composeNativeSystemInstructions,
|
|
nativeRuntimePromptDigest,
|
|
} from "./runtime-context.js";
|
|
|
|
const input: NativeExecutionInputV1 = {
|
|
schema: "paperclip.native-execution-input.v1",
|
|
binding: {
|
|
companyId: "company-1",
|
|
runId: "run-1",
|
|
issueId: "issue-1",
|
|
agentId: "agent-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
},
|
|
task: {
|
|
identifier: "PAP-1",
|
|
title: "Safe task",
|
|
description: null,
|
|
prompt: "# PAP-1: Safe task\n\nPlease address the latest comment.",
|
|
workMode: "standard",
|
|
},
|
|
workspace: { cwd: "/safe/workspace", repoUrl: null, repoRef: null, branchName: null },
|
|
session: {
|
|
normalizedSessionId: null,
|
|
driverKind: "codex_app_server",
|
|
protocolVersion: 1,
|
|
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
|
},
|
|
provider: { kind: "codex", model: null },
|
|
completionContract: {
|
|
id: "contract-1",
|
|
sha256: "abc123",
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
contract: {
|
|
revision: "1",
|
|
objective: "Complete the safe task.",
|
|
criteria: [{ id: "objective", requirement: "The task is complete." }],
|
|
},
|
|
},
|
|
interactionResponses: [],
|
|
credentialBindings: [{
|
|
bindingId: "opaque-binding",
|
|
service: "github",
|
|
destination: "github.com",
|
|
expiresAt: null,
|
|
displayName: "GitHub",
|
|
}],
|
|
};
|
|
|
|
describe("NativeExecutionInputV1", () => {
|
|
it("parses v3 immutable runtime context without changing the model task envelope", () => {
|
|
const digest = "0".repeat(64);
|
|
const context = {
|
|
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
|
instructions: {
|
|
entryPath: "AGENTS.md",
|
|
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 2, totalBytes: 42 },
|
|
},
|
|
skills: [{
|
|
key: "company/research",
|
|
runtimeName: "research",
|
|
versionId: "version-1",
|
|
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/skills/research", fileCount: 2, totalBytes: 42 },
|
|
}],
|
|
mcp: { assignmentSetId: "sha256:test", digest, bindingId: "native-mcp:run-1" },
|
|
} as const;
|
|
const parsed = parseNativeExecutionInput({
|
|
...input,
|
|
schema: "paperclip.native-execution-input.v3",
|
|
executionMode: "default",
|
|
planningContext: null,
|
|
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
|
});
|
|
expect(parsed.schema).toBe("paperclip.native-execution-input.v3");
|
|
const envelope = buildNativeModelEnvelope(parsed);
|
|
expect(envelope.task).toEqual(buildNativeModelEnvelope(input).task);
|
|
expect(envelope.completionContract).toEqual(buildNativeModelEnvelope(input).completionContract);
|
|
expect(JSON.stringify(envelope)).not.toContain("runtimeContext");
|
|
expect(JSON.stringify(envelope)).not.toContain(PAPERCLIP_EXECUTION_PROMPT);
|
|
expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).toBe(
|
|
`${PAPERCLIP_EXECUTION_PROMPT}\n\nFollow sibling.md\n\nRead-only instruction sibling root: /runtime/instructions`,
|
|
);
|
|
expect(canonicalNativeRuntimeContextDigest({
|
|
...context,
|
|
mcp: { ...context.mcp, bindingId: "native-mcp:run-2" },
|
|
})).toBe(parsed.runtimeContext.aggregateDigest);
|
|
const current = parseNativeExecutionInput({
|
|
...parsed,
|
|
schema: "paperclip.native-execution-input.v4",
|
|
provider: { kind: "codex", model: null, approvalPolicy: "on-request" },
|
|
});
|
|
const withDelta = parseNativeExecutionInput({ ...current, continuationPrompt: '{"messages":[{"authorType":"user","body":"Just this new comment"}]}' });
|
|
// No checkpoint / failed provider recovery must retain full bootstrap input.
|
|
expect(buildNativeModelEnvelope(withDelta)).toEqual(buildNativeModelEnvelope(current));
|
|
const delta = buildNativeModelEnvelope(withDelta, { resumedSession: true });
|
|
expect(delta).toEqual({
|
|
schema: "paperclip.native-continuation.v1",
|
|
events: '{"messages":[{"authorType":"user","body":"Just this new comment"}]}',
|
|
completion: { revision: "1", criterionIds: ["objective"] },
|
|
});
|
|
expect(JSON.stringify(delta)).not.toContain(input.task.title);
|
|
expect(JSON.stringify(delta)).not.toContain(input.completionContract.contract.objective);
|
|
expect(JSON.stringify(delta)).not.toContain("opaque-binding");
|
|
const withGuidance = parseNativeExecutionInput({ ...current, initialCommunicationGuidance: "Saved Slack instructions" });
|
|
expect(buildNativeModelEnvelope(withGuidance).task.prompt).toBe(`Saved Slack instructions\n\n${current.task.prompt}`);
|
|
// Structured question/approval resumes may use a full envelope rather than
|
|
// a delta. They still must not repeat initial communication instructions.
|
|
expect(buildNativeModelEnvelope(withGuidance, { resumedSession: true })).toEqual(buildNativeModelEnvelope(current));
|
|
expect(buildNativeModelEnvelope(parseNativeExecutionInput({ ...withGuidance, continuationPrompt: "new message" }), { resumedSession: true })).not.toHaveProperty("task");
|
|
expect(buildNativeModelEnvelope(withGuidance).task.prompt.match(/Saved Slack instructions/g)).toHaveLength(1);
|
|
expect(current).toMatchObject({
|
|
schema: "paperclip.native-execution-input.v4",
|
|
provider: { kind: "codex", approvalPolicy: "on-request" },
|
|
});
|
|
expect(() => parseNativeExecutionInput({
|
|
...parsed,
|
|
schema: "paperclip.native-execution-input.v4",
|
|
provider: { kind: "codex", model: null, approvalPolicy: "sometimes" },
|
|
})).toThrow("approvalPolicy");
|
|
});
|
|
|
|
it("rejects runtime-context traversal and aggregate digest drift", () => {
|
|
const digest = "0".repeat(64);
|
|
const context = {
|
|
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
|
instructions: {
|
|
entryPath: "../AGENTS.md",
|
|
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 },
|
|
},
|
|
skills: [],
|
|
mcp: { assignmentSetId: "none", digest, bindingId: null },
|
|
aggregateDigest: digest,
|
|
} as const;
|
|
expect(() => parseNativeExecutionInput({ ...input, schema: "paperclip.native-execution-input.v3", executionMode: "default", planningContext: null, runtimeContext: context })).toThrow("bundle root");
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
schema: "paperclip.native-execution-input.v3",
|
|
executionMode: "default",
|
|
planningContext: null,
|
|
runtimeContext: { ...context, instructions: { ...context.instructions, entryPath: "AGENTS.md" } },
|
|
})).toThrow("aggregateDigest");
|
|
});
|
|
|
|
it("builds a model envelope without authority or credential bindings", () => {
|
|
const parsed = parseNativeExecutionInput(input);
|
|
const model = buildNativeModelEnvelope(parsed);
|
|
const serialized = JSON.stringify(model);
|
|
expect(serialized).not.toContain("company-1");
|
|
expect(serialized).not.toContain("run-1");
|
|
expect(serialized).not.toContain("opaque-binding");
|
|
expect(model.task.title).toBe("Safe task");
|
|
expect(model.task.prompt).toContain("latest comment");
|
|
});
|
|
|
|
it("rejects unknown context or environment escape hatches", () => {
|
|
expect(() => parseNativeExecutionInput({ ...input, context: { secret: "canary" } })).toThrow(
|
|
"unknown field context",
|
|
);
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
workspace: { ...input.workspace, env: { PAPERCLIP_API_KEY: "canary" } },
|
|
})).toThrow("unknown field env");
|
|
});
|
|
|
|
it("accepts a persisted OpenCode driver/model pair and rejects mismatches", () => {
|
|
const opencode = parseNativeExecutionInput({
|
|
...input,
|
|
session: { ...input.session, driverKind: "opencode_server" },
|
|
provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
|
});
|
|
expect(opencode.provider).toEqual({
|
|
kind: "opencode",
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
});
|
|
expect(parseNativeExecutionInput(opencode)).toEqual(opencode);
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
session: { ...input.session, driverKind: "opencode_server" },
|
|
provider: { kind: "codex", model: null },
|
|
})).toThrow("does not match");
|
|
});
|
|
|
|
it("deserializes pre-provider Codex state as Codex", () => {
|
|
const legacy = structuredClone(input) as Record<string, unknown>;
|
|
delete legacy.provider;
|
|
expect(parseNativeExecutionInput(legacy).provider).toEqual({
|
|
kind: "codex",
|
|
model: null,
|
|
});
|
|
expect(parseNativeExecutionInput(parseNativeExecutionInput(legacy))).toEqual(
|
|
parseNativeExecutionInput(legacy),
|
|
);
|
|
});
|
|
|
|
it("accepts an immutable Claude Managed Agent profile and rejects driver or beta drift", () => {
|
|
const claudeManaged = {
|
|
...input,
|
|
session: { ...input.session, driverKind: "claude_managed_agents_api" },
|
|
provider: {
|
|
kind: "claude_managed",
|
|
model: "claude-sonnet-5",
|
|
managedProfile: {
|
|
profileId: "managed-profile-1",
|
|
anthropicAgentId: "agent_01",
|
|
agentVersion: "3",
|
|
environmentId: "environment_01",
|
|
betaVersion: "managed-agents-2026-04-01",
|
|
},
|
|
maxSessionListCostUsd: 1,
|
|
},
|
|
} as const;
|
|
const parsed = parseNativeExecutionInput(claudeManaged);
|
|
expect(parsed.provider).toEqual(claudeManaged.provider);
|
|
expect(buildNativeModelEnvelope(parsed).workspace).toBeNull();
|
|
expect(() => parseNativeExecutionInput({
|
|
...claudeManaged,
|
|
session: { ...claudeManaged.session, driverKind: "codex_app_server" },
|
|
})).toThrow("does not match");
|
|
expect(() => parseNativeExecutionInput({
|
|
...claudeManaged,
|
|
provider: {
|
|
...claudeManaged.provider,
|
|
managedProfile: { ...claudeManaged.provider.managedProfile, betaVersion: "future-beta" },
|
|
},
|
|
})).toThrow("betaVersion");
|
|
});
|
|
|
|
it("accepts a closed AWS AgentCore Harness snapshot and rejects drift or unsafe limits", () => {
|
|
const awsAgentCore = {
|
|
...input,
|
|
session: { ...input.session, driverKind: "aws_agentcore_harness_api" },
|
|
provider: {
|
|
kind: "aws_agentcore",
|
|
model: "global.anthropic.claude-sonnet-4-6",
|
|
agentCoreProfile: {
|
|
profileId: "agentcore-development",
|
|
region: "us-east-1",
|
|
accountId: "123456789012",
|
|
harnessArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/harness-1",
|
|
harnessVersion: "3",
|
|
endpointArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness-endpoint/harness-1/paperclip",
|
|
endpointQualifier: "paperclip",
|
|
agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1",
|
|
memoryArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/memory-1",
|
|
memoryId: "memory-1",
|
|
invocationRoleArn: "arn:aws:iam::123456789012:role/paperclip-agentcore-runner",
|
|
contextBucket: "paperclip-agentcore-context",
|
|
contextPrefix: "paperclip/runtime",
|
|
contextKmsKeyArn: "arn:aws:kms:us-east-1:123456789012:key/test",
|
|
qualificationRevision: "aws-agentcore-harness-context-v2",
|
|
eventExpiryDays: 90,
|
|
},
|
|
maxEstimatedSessionCostUsd: 1,
|
|
invocationLimits: { maxIterations: 8, maxOutputTokens: 4096, timeoutSeconds: 300 },
|
|
},
|
|
} as const;
|
|
const parsed = parseNativeExecutionInput(awsAgentCore);
|
|
expect(parsed.provider).toEqual(awsAgentCore.provider);
|
|
expect(buildNativeModelEnvelope(parsed).workspace).toBeNull();
|
|
expect(JSON.stringify(parsed)).not.toContain("AWS_SECRET_ACCESS_KEY");
|
|
expect(() => parseNativeExecutionInput({
|
|
...awsAgentCore,
|
|
session: { ...awsAgentCore.session, driverKind: "codex_app_server" },
|
|
})).toThrow("does not match");
|
|
expect(() => parseNativeExecutionInput({
|
|
...awsAgentCore,
|
|
provider: { ...awsAgentCore.provider, invocationLimits: { ...awsAgentCore.provider.invocationLimits, maxIterations: 9 } },
|
|
})).toThrow("maxIterations");
|
|
expect(() => parseNativeExecutionInput({
|
|
...awsAgentCore,
|
|
provider: { ...awsAgentCore.provider, agentCoreProfile: { ...awsAgentCore.provider.agentCoreProfile, eventExpiryDays: 30 } },
|
|
})).toThrow("eventExpiryDays");
|
|
});
|
|
|
|
it("accepts only a closed ACPX profile matching the driver and agent", () => {
|
|
const provider = {
|
|
kind: "acpx",
|
|
agent: "pi",
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
permissionPolicy: "interactive",
|
|
profile: {
|
|
driverKind: "acpx_runtime",
|
|
protocolVersion: 1,
|
|
acpxVersion: "0.13.1",
|
|
agent: "pi",
|
|
agentProfileVersion: 1,
|
|
agentServerPackage: "pi-acp",
|
|
agentServerVersion: "0.0.33",
|
|
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
|
agentRuntimeVersion: "0.84.2",
|
|
commandDigest: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc",
|
|
},
|
|
} as const;
|
|
const parsed = parseNativeExecutionInput({
|
|
...input,
|
|
session: { ...input.session, driverKind: "acpx_runtime" },
|
|
provider,
|
|
});
|
|
expect(parsed.provider).toEqual({
|
|
kind: "acpx",
|
|
agent: "pi",
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
permissionPolicy: "interactive",
|
|
profile: provider.profile,
|
|
});
|
|
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
|
expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" });
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
session: { ...input.session, driverKind: "acpx_runtime" },
|
|
provider: { ...provider, profile: { ...provider.profile, agent: "claude" } },
|
|
})).toThrow("qualified ACPX v1 profile");
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
session: { ...input.session, driverKind: "opencode_server" },
|
|
provider,
|
|
})).toThrow("does not match");
|
|
});
|
|
|
|
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
|
|
const legacy = structuredClone(input) as Record<string, unknown>;
|
|
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
|
|
expect(parseNativeExecutionInput(legacy).session.lifecyclePolicy).toEqual({
|
|
mode: "per_turn",
|
|
idleTimeoutMs: null,
|
|
});
|
|
expect(parseNativeExecutionInput({
|
|
...input,
|
|
session: {
|
|
...input.session,
|
|
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
|
},
|
|
}).session.lifecyclePolicy).toEqual({ mode: "warm", idleTimeoutMs: 300_000 });
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
session: {
|
|
...input.session,
|
|
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 0 },
|
|
},
|
|
})).toThrow("positive integer");
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
session: {
|
|
...input.session,
|
|
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 86_400_001 },
|
|
},
|
|
})).toThrow("no greater than 86400000");
|
|
});
|
|
});
|
|
|
|
describe("NativeExecutionInputV2 planning", () => {
|
|
const planning = {
|
|
...input,
|
|
schema: "paperclip.native-execution-input.v2",
|
|
executionMode: "plan",
|
|
task: { ...input.task, workMode: "planning" },
|
|
planningContext: {
|
|
documentId: "document-1",
|
|
baseRevisionId: "revision-3",
|
|
baseRevisionNumber: 3,
|
|
markdown: "# Existing plan",
|
|
sha256: "plan-digest",
|
|
reviewContext: { threads: [{ id: "annotation-1" }] },
|
|
},
|
|
} as const;
|
|
|
|
it("round-trips native plan mode and its pinned canonical revision into model context", () => {
|
|
const parsed = parseNativeExecutionInput(planning);
|
|
expect(parsed).toMatchObject({ schema: "paperclip.native-execution-input.v2", executionMode: "plan" });
|
|
expect(buildNativeModelEnvelope(parsed)).toMatchObject({
|
|
schema: "paperclip.native-model-envelope.v2",
|
|
executionMode: "plan",
|
|
planningContext: { baseRevisionId: "revision-3", baseRevisionNumber: 3 },
|
|
});
|
|
});
|
|
|
|
it("fails closed when plan mode omits its pinned Paperclip context", () => {
|
|
expect(() => parseNativeExecutionInput({ ...planning, planningContext: null }))
|
|
.toThrow("planningContext is required");
|
|
expect(() => parseNativeExecutionInput({
|
|
...planning,
|
|
task: { ...planning.task, workMode: "standard" },
|
|
})).toThrow("requires planning work mode");
|
|
});
|
|
|
|
it("allows an accepted planning issue to continue in fresh default execution mode", () => {
|
|
expect(parseNativeExecutionInput({
|
|
...planning,
|
|
executionMode: "default",
|
|
planningContext: null,
|
|
})).toMatchObject({ task: { workMode: "planning" }, executionMode: "default" });
|
|
});
|
|
});
|
|
|
|
describe("NativeExecutionInputV2 ask mode", () => {
|
|
it("round-trips ask mode as a default execution without planning context", () => {
|
|
const parsed = parseNativeExecutionInput({
|
|
...input,
|
|
schema: "paperclip.native-execution-input.v2",
|
|
executionMode: "default",
|
|
task: { ...input.task, workMode: "ask" },
|
|
planningContext: null,
|
|
});
|
|
expect(parsed).toMatchObject({
|
|
schema: "paperclip.native-execution-input.v2",
|
|
executionMode: "default",
|
|
task: { workMode: "ask" },
|
|
});
|
|
expect(buildNativeModelEnvelope(parsed)).toMatchObject({
|
|
schema: "paperclip.native-model-envelope.v2",
|
|
task: { workMode: "ask" },
|
|
executionMode: "default",
|
|
planningContext: null,
|
|
});
|
|
});
|
|
|
|
it("rejects plan execution for ask mode", () => {
|
|
expect(() => parseNativeExecutionInput({
|
|
...input,
|
|
schema: "paperclip.native-execution-input.v2",
|
|
executionMode: "plan",
|
|
task: { ...input.task, workMode: "ask" },
|
|
planningContext: {
|
|
documentId: null,
|
|
baseRevisionId: null,
|
|
baseRevisionNumber: 0,
|
|
markdown: "",
|
|
sha256: "digest",
|
|
reviewContext: {},
|
|
},
|
|
})).toThrow("plan execution mode requires planning work mode");
|
|
});
|
|
});
|