Merge verified provider-routing task-card repair

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 13:05:36 -05:00
commit f485863c21
4 files changed
+45 -8

No files matched your search

+11
View File
@@ -651,3 +651,14 @@ subscription combinations remain unqualified. Gemini CLI 0.58.0 has an upstream
ACP new-file error conversion defect. The provider-free filesystem probe exposes
that defect without modifying the installed CLI. Provider overloads and one
follow-up timeout also remain live qualification limits.
### Gateway completion compatibility
Provider-facing `paperclip_finish` accepts an omitted or `null` continuation for
`done`, `completed`, and `needs_review`. This supports gateways that require all
declared tool properties to be present. Normalization removes only `null`;
non-yielding tool calls still reject a continuation object, and `yielded` still
requires a complete `response_wake` object.
Task-card account repair uses the provider reconnect form for routed accounts,
retaining the saved endpoint, protocol, model aliases, and connection identity.
-6
View File
@@ -66,12 +66,6 @@ effects, audit, retries, conflicts, redaction, continuation, and terminal
decisions. The production adapter stays App-owned and invokes Paperclip's real
route/service authorities; it does not copy those rules into this package.
Provider-facing `paperclip_finish` accepts an omitted or `null` continuation for
`done`, `completed`, and `needs_review`. This supports gateways that require all
declared tool properties to be present. Normalization removes only `null`;
non-yielding tool calls still reject a continuation object, and `yielded` still
requires a complete `response_wake` object.
## Quick start
Native provider debug-trace correlation uses an incremental index owned by its transport. Pending
@@ -30,6 +30,7 @@ vi.mock("@/api/email", () => ({ emailApi: {
} }));
const credentialRender = vi.hoisted(() => vi.fn());
const routedCredentialRender = vi.hoisted(() => vi.fn());
const setupOptionsMock = vi.hoisted(() => vi.fn());
const completeMock = vi.hoisted(() => vi.fn());
const declineMock = vi.hoisted(() => vi.fn());
@@ -68,6 +69,16 @@ vi.mock("@/components/ai-connections/AiConnectionCredentialStep", () => ({
</div>; },
}));
vi.mock("@/components/ai-connections/AiProviderSetup", () => ({
AiProviderSetup: (props: { reconnect: { id: string }; onComplete: (binding: { connectionId: string; grantId: string; method: "api_key" }) => void; onCancel: () => void }) => {
routedCredentialRender(props);
return <div data-testid="routed-ai-repair">
<button onClick={() => props.onComplete({ connectionId: props.reconnect.id, grantId: "grant", method: "api_key" })}>Reconnect routed account</button>
<button onClick={props.onCancel}>Cancel routed repair</button>
</div>;
},
}));
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
@@ -477,6 +488,21 @@ describe("ConnectionIntentInteractionBody dialog behavior", () => {
describe("AI repair inside the card", () => {
const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } };
const connection = { id: "selected-account", name: "My Codex account", provider: "openai", method: "api_key", ownership: "personal", ownerName: "Dotta", status: "revoked" };
it.each(["openrouter", "bedrock", "gateway"] as const)("retains the selected %s route through task-card account repair", async kind => {
const routing = kind === "bedrock"
? { kind, protocol: "bedrock", region: "us-east-1", auth: "bearer", models: [] }
: { kind, protocol: "responses", auth: "bearer", models: [], ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}) };
const routedConnection = { ...connection, provider: kind === "bedrock" ? "anthropic" : kind === "openrouter" ? "openrouter" : "openai", routing };
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection: routedConnection, canReconnect: true } });
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
expect(routedCredentialRender.mock.lastCall![0]).toMatchObject({ companyId: interaction.companyId, agentId: interaction.payload.requestingAgentId, reconnect: routedConnection });
await act(() => button("Reconnect routed account")!.click());
expect(completeMock).toHaveBeenCalledWith(interaction.id, connection.id);
});
it.each([false, true])("requires atomic validated legacy adoption (validation fails: %s)", async (fails) => {
const binding = { provider: "openai", method: "subscription", mode: "responsible_user" };
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: binding, aiConnectionRequiresAdoption: true });
@@ -1,4 +1,3 @@
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -15,6 +14,7 @@ import { connectionIntentsApi } from "@/api/connection-intents";
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentsApi } from "@/api/agents";
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { defaultAiConnectionName } from "@/components/ai-connections/model";
import { AppLogo } from "@/pages/apps/AppLogo";
import { AgentAvatar } from "@/components/AgentAvatar";
@@ -394,7 +394,13 @@ export function ConnectionIntentInteractionBody({
{completeMutation.isPending ? "Continuing…" : "Continue task"}
</Button>
</div>
: repair ? repair.canReconnect ? repair.connection.routing ? <AiProviderSetup companyId={interaction.companyId} agentId={interaction.payload.requestingAgentId} reconnect={repair.connection} onComplete={result => selectAiAccountMutation.mutate({ ...result, generation })} onCancel={() => { closeSetup(); returnFocusToCard(); }} /> : <AiConnectionCredentialStep
: repair ? repair.canReconnect ? repair.connection.routing ? <AiProviderSetup
companyId={interaction.companyId}
agentId={interaction.payload.requestingAgentId}
reconnect={repair.connection}
onComplete={(binding) => selectAiAccountMutation.mutate({ connectionId: binding.connectionId, grantId: binding.grantId, method: binding.method ?? "api_key", generation })}
onCancel={() => { closeSetup(); returnFocusToCard(); }}
/> : <AiConnectionCredentialStep
companyId={interaction.companyId}
provider={repair.connection.provider}
initialMethod={repair.connection.method}