mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip manages AI agents and the tools they may use. > - Connection setup separates provider preference from permission to use a tool. > - The first native connection baseline could not exercise its intended decisions. > - The browser used mutable task titles, and the provider fixture already granted access. > - Native provider-choice instructions also disagreed with the preferred question format. Schema rejection gave no field guidance. > - This pull request repairs those test preconditions and native guidance, then fixes restart/approval defects exposed by the corrected baseline. It also restores missing OpenCode tool-error evidence. > - The benefit is an inspectable baseline before any further instruction reduction. ## Linked Issues or Issue Description Refs #15407. The original 15-cell baseline remains 0 PASS / 15 FAIL. Ten cells stopped on stale titles, two Codex cells had schema denials, two OpenCode cells used already-granted tools, and one Claude cell returned no native result. No intended user decisions were submitted. The exact invalid Codex field and underlying Claude failure cause remain unknown. [Original campaign](https://github.com/paperclipai/paperclip/actions/runs/37562577199) · [Original report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37562577199-1/index.html) ## What Changed - Match the browser's task route and visible identifier instead of a title the agent can change. - Start native provider-choice fixtures with no agent tool access. Verify the public effective-access records. - In the positive case, select Arcade, then grant its exact HubSpot tool through the real access card. Require both saved decisions and exactly one observed call. - Return a canonical `providerQuestionSet` for native input and retain the equivalent legacy `providerQuestion`. - Keep invalid input rejected. Return bounded schema locations and required field names without submitted values. - Preserve Claude's exact session/content identity while allowing authenticated registered instruction-copy paths to rotate on a new run. - Reject duplicate approval reports for an existing exact tool-action card before they create another human review. - Wait for a recorded service-approval continuation within the existing deadline; retain missing or failed continuation grades. - Forward OpenCode tool activity through the runner facade, preserving bounded errors and execution-part identity without inventing host-call joins or exposing arguments. - Preserve original grades, costs, scope limits and diagnoses in the dated repair report. ## Verification - Eval typecheck passes. Support suite: 1,801 PASS, one intentional skip; Node checks: 128 PASS. - Connection/schema tests: 51 PASS. Real-server public fixture setup: one PASS with zero providers. - Browser support regression: five PASS, including renamed and wrong tasks. - Focused Rust safe-feedback test: one PASS. - Repository typecheck and build pass before the latest master replay. Post-replay connection/shared/real-server fixture checks: 52 PASS; eval typecheck passes. The browser review fix additionally passes all five browser checks and seven suite checks. - The full local repository run was interrupted incomplete after about 45 minutes, with five integration failures retained. All five pass in a separate targeted invocation (1,250 unrelated tests skipped). No full local-suite pass or root cause for the initial local failures is claimed. - Corrected frozen source `162cc90fdabe7f505b88ae095044531b82784c92`: **10 PASS / 5 FAIL** across the [passing Codex canary](https://github.com/paperclipai/paperclip/actions/runs/37575158761) and [remaining 14 cells](https://github.com/paperclipai/paperclip/actions/runs/37576261807). The canary passes all 17 checks. Claude's two provider-choice continuations fail on restart, Claude service approval exposes an early evaluator rejection, Codex service approval creates a duplicate approval, and OpenCode provider-second times out after both decisions with no HubSpot call. No original result is regraded. - Final ledgers count 31 actual runs: 27 succeeded, two failed, two cancelled during cleanup. All 15 cleanup/budget checks pass. The late Claude continuation is absent from its earlier workflow snapshot; it remains in the result/API/final ledger. Original evidence retains 279 hashes. Recorded LLM subtotal $0.04553787 is incomplete billing, not actual total cost; local runtime is unmetered. - New repair regressions reproduce the Claude attach failure, duplicate approval acceptance and dropped OpenCode tool events before their respective fixes. Nine Rust attachment checks, 127 ACPX host/adapter tests, 33 completion/control-plane checks, nine eval deadline tests, 59 OpenCode proxy/driver tests, one Rust tool-error/redaction check, and TypeScript/Rust composer parity pass. Eval typecheck, repository typecheck and build pass. Existing support coverage is 1,802 PASS plus 128 Node PASS, one intentional support skip; two additional deadline tests also pass. - New-source full CI/review and live canaries are pending. The next bounded selection is Claude provider-decline, Codex service-approve and one OpenCode provider-second diagnostic with repaired event evidence. No broader campaign or instruction-reduction qualification is claimed. - Initial corrected campaign [37574251834](https://github.com/paperclipai/paperclip/actions/runs/37574251834) was cancelled during shared build after review found the breadcrumb whitespace assumption. Its matrix job has zero steps and no provider execution. The real adjacent-span browser regression now reproduces the old failure and passes after the fix. ## Risks - The corrected baseline remains 10/15. The new restart/approval fixes require live qualification; OpenCode evidence forwarding does not itself establish or fix its prior behavioral failure. - The positive provider case now expects three runs, including separate access approval. Its new results are distinct from the original invalid fixture. - The old Claude missing-result cause and rejected Codex field are unknown. These repairs do not retroactively explain or erase either failure. - Path rotation must preserve prompt, custom instruction, skill/content identity and protected provider settings; regression checks reject stale or changed content. No connection authorization, JSON schema, budget, cleanup, or final-result requirement is relaxed. Historical Everyday prompts and gateway setup remain unchanged. ## Model Used OpenAI Codex, GPT-6. The exact deployment variant and context window are not exposed in this session. Used repository inspection, code editing, test execution and retained-evidence analysis. ## 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>
162 lines
5.4 KiB
TypeScript
162 lines
5.4 KiB
TypeScript
/** Forward bounded tool activity without inventing a host-call identity or arguments. */
|
|
export function openCodeProxyToolNotification(input: {
|
|
eventType: string; threadId: string; turnId?: string; payload: Record<string, unknown>;
|
|
}): { method: string; params: Record<string, unknown> } | null {
|
|
const phase = ({ "tool.execution.started": "started", "tool.execution.progressed": "updated", "tool.execution.completed": "completed" } as Record<string, string>)[input.eventType];
|
|
if (!phase) return null;
|
|
const p = input.payload;
|
|
if (!input.turnId || typeof p.executionId !== "string" || !p.executionId || p.schema !== "paperclip.tool.execution.v1")
|
|
throw new Error("OpenCode tool activity omitted its bound identity");
|
|
const type = ({ builtin: "builtinToolCall", mcp: "mcpToolCall", dynamic: "dynamicToolCall", process: "commandExecution" } as Record<string, string>)[String(p.transport)];
|
|
if (!type) throw new Error("OpenCode tool activity has an unsupported transport");
|
|
return { method: `item/${phase}`, params: {
|
|
threadId: input.threadId, turnId: input.turnId, itemId: p.executionId,
|
|
item: { id: p.executionId, type, tool: p.name, server: p.namespace,
|
|
readOnlyHint: p.readOnly, status: p.status, durationMs: p.durationMs, exitCode: p.exitCode,
|
|
// This is the already bounded provider-visible result, not raw input or a private trace.
|
|
output: typeof p.output === "string" ? p.output.slice(0, 16_384) : "",
|
|
},
|
|
} };
|
|
}
|
|
|
|
export function shouldForwardOpenCodeProxyItem(input: {
|
|
turnId?: string;
|
|
kind?: unknown;
|
|
}): boolean {
|
|
return !(input.turnId === undefined && input.kind === "model");
|
|
}
|
|
|
|
export function openCodeProxyAssistantText(
|
|
payload: Record<string, unknown>,
|
|
): string | null {
|
|
return (payload.kind === "agentMessage" || payload.kind === "text") &&
|
|
typeof payload.text === "string"
|
|
? payload.text
|
|
: null;
|
|
}
|
|
|
|
export function openCodeProxyItemNotification(input: {
|
|
eventType: "item.started" | "item.delta" | "item.completed";
|
|
threadId: string;
|
|
turnId?: string;
|
|
itemId?: string;
|
|
payload: Record<string, unknown>;
|
|
}): { method: string; params: Record<string, unknown> } {
|
|
const assistantText = openCodeProxyAssistantText(input.payload);
|
|
const method =
|
|
input.eventType === "item.delta" && assistantText !== null
|
|
? "item/agentMessage/delta"
|
|
: input.eventType.replace(".", "/");
|
|
const providerItem = record(input.payload.item);
|
|
const item =
|
|
assistantText === null
|
|
? (input.payload.item ?? {
|
|
id: input.itemId,
|
|
type: input.payload.kind,
|
|
text: input.payload.text,
|
|
})
|
|
: {
|
|
...providerItem,
|
|
id: input.itemId,
|
|
type: "agentMessage",
|
|
text: assistantText,
|
|
};
|
|
return {
|
|
method,
|
|
params: {
|
|
threadId: input.threadId,
|
|
turnId: input.turnId,
|
|
itemId: input.itemId,
|
|
...input.payload,
|
|
item,
|
|
...(method === "item/agentMessage/delta" ? { delta: assistantText } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function shouldAnnounceOpenCodeProxyTurn(
|
|
announcedTurnIds: Set<string>,
|
|
turnId: string,
|
|
): boolean {
|
|
if (announcedTurnIds.has(turnId)) return false;
|
|
announcedTurnIds.add(turnId);
|
|
return true;
|
|
}
|
|
|
|
const MAX_TERMINAL_ERROR_BYTES = 64 * 1024;
|
|
const MAX_TERMINAL_ERROR_TEXT_CHARS = 4_000;
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function boundedText(value: unknown, maxChars: number): string | undefined {
|
|
return typeof value === "string" && value.length > 0
|
|
? [...value].slice(0, maxChars).join("")
|
|
: undefined;
|
|
}
|
|
|
|
/**
|
|
* Keeps the provider's actionable failure payload on the Codex-facade frame.
|
|
* The OpenCode driver already bounds native payloads, but the proxy is a
|
|
* separate process boundary and must enforce its own limit before writing to
|
|
* runnerd's JSON-RPC stream.
|
|
*/
|
|
export function boundedOpenCodeTerminalError(value: unknown): unknown {
|
|
if (value === undefined || value === null) return undefined;
|
|
try {
|
|
const encoded = JSON.stringify(value);
|
|
if (
|
|
encoded !== undefined &&
|
|
Buffer.byteLength(encoded, "utf8") <= MAX_TERMINAL_ERROR_BYTES
|
|
) {
|
|
return value;
|
|
}
|
|
} catch {
|
|
// Fall through to the bounded diagnostic below.
|
|
}
|
|
|
|
const object = record(value);
|
|
const code = boundedText(object?.code, 160);
|
|
const message = boundedText(
|
|
object?.message ??
|
|
object?.reason ??
|
|
(typeof value === "string" ? value : undefined),
|
|
MAX_TERMINAL_ERROR_TEXT_CHARS,
|
|
);
|
|
return {
|
|
...(code ? { code } : {}),
|
|
...(message ? { message } : {}),
|
|
omitted: true,
|
|
reason: "payload_limit",
|
|
};
|
|
}
|
|
|
|
export function openCodeProxyTerminalNotification(input: {
|
|
eventType:
|
|
"turn.completed" | "turn.failed" | "turn.interrupted" | "turn.cancelled";
|
|
threadId: string;
|
|
turnId?: string;
|
|
payload: Record<string, unknown>;
|
|
}): { method: string; params: Record<string, unknown> } {
|
|
const status = input.eventType.slice("turn.".length);
|
|
const error =
|
|
input.eventType === "turn.failed"
|
|
? boundedOpenCodeTerminalError(input.payload.error)
|
|
: undefined;
|
|
return {
|
|
method: input.eventType.replace(".", "/"),
|
|
params: {
|
|
threadId: input.threadId,
|
|
turnId: input.turnId,
|
|
turn: {
|
|
id: input.turnId,
|
|
status,
|
|
...(error === undefined ? {} : { error }),
|
|
},
|
|
},
|
|
};
|
|
}
|