From a7dc86f3e555fb70b58562b31ebca5fd7daed644 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 12:49:27 -0500 Subject: [PATCH 1/2] Align Copilot contract history tests with profile v33 Co-Authored-By: Paperclip --- .../paperclip-runner/src/contracts/native-execution.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 75f62db7eea..4710a757a03 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -391,7 +391,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("does not match"); }); - it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => { + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => { const { qualificationModel: _qualificationModel, reportedModelId: _reportedModelId, permissionPolicy, qualificationStatus: _qualificationStatus, modelPolicy: _modelPolicy, ...snapshot } = QUALIFIED_ACPX_PROFILES.copilot; @@ -403,7 +403,7 @@ describe("NativeExecutionInputV1", () => { const parsed = parseNativeExecutionInput(value); expect(parsed.provider).toEqual(provider); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 33, 1.5, "32", null]) { + for (const unsupportedVersion of [0, 34, 1.5, "33", null]) { expect(() => parseNativeExecutionInput({ ...value, provider: { ...provider, profile: { ...provider.profile, agentProfileVersion: unsupportedVersion } }, })).toThrow("qualified ACPX v1 profile"); From a09afc507cb3c8016b88146f4f5438fa0d6b1780 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 12:49:28 -0500 Subject: [PATCH 2/2] Preserve Copilot task catalogs and repair ordinary startup Co-Authored-By: Paperclip --- .../db/src/migrations/meta/0318_snapshot.json | 6 ++--- server/src/__tests__/idle-admission.test.ts | 14 +++++++++++ server/src/middleware/idle-admission.ts | 2 +- ui/src/components/IssueProperties.test.tsx | 17 +++++++++++++ .../issue-properties/IssueProperties.tsx | 12 +++++++--- .../ComposerRunSettingsPicker.test.tsx | 20 ++++++++++++++++ .../task-chat/ComposerRunSettingsPicker.tsx | 6 +++-- ui/src/lib/copilot-task-model-catalog.test.ts | 24 +++++++++++++++++++ ui/src/lib/copilot-task-model-catalog.ts | 13 ++++++++++ 9 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 ui/src/lib/copilot-task-model-catalog.test.ts create mode 100644 ui/src/lib/copilot-task-model-catalog.ts diff --git a/packages/db/src/migrations/meta/0318_snapshot.json b/packages/db/src/migrations/meta/0318_snapshot.json index 367fbd82f53..ea86eded515 100644 --- a/packages/db/src/migrations/meta/0318_snapshot.json +++ b/packages/db/src/migrations/meta/0318_snapshot.json @@ -4266,7 +4266,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -4739,7 +4739,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google','github')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" } }, "isRLSEnabled": false @@ -56355,4 +56355,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/server/src/__tests__/idle-admission.test.ts b/server/src/__tests__/idle-admission.test.ts index 81f268a1ffa..3725230d938 100644 --- a/server/src/__tests__/idle-admission.test.ts +++ b/server/src/__tests__/idle-admission.test.ts @@ -49,6 +49,20 @@ describe("idle admission", () => { expect(idleWorkSnapshot().active).toBe(0); }); + it("tracks ordinary middleware with non-router stack metadata", async () => { + const server = app(), done = deferred(); + const middleware = Object.assign(async (_req: express.Request, res: express.Response) => { + res.sendStatus(202); await done.promise; + }, { stack: { name: "middleware metadata" } }); + server.use(middleware); + expect(() => trackIdleRequestHandlers(server)).not.toThrow(); + await request(server).get("/work").expect(202); + expect(idleWorkSnapshot().active).toBe(1); + done.resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(idleWorkSnapshot().active).toBe(0); + }); + it("does not mistake a client disconnect for completed accepted work", async () => { const server = app(); const entered = deferred(), done = deferred(), closed = deferred(); diff --git a/server/src/middleware/idle-admission.ts b/server/src/middleware/idle-admission.ts index c9de5f03eca..2016b9ca85c 100644 --- a/server/src/middleware/idle-admission.ts +++ b/server/src/middleware/idle-admission.ts @@ -61,7 +61,7 @@ export function trackIdleRequestHandlers(app: Application): void { if (seen.has(layer)) continue; seen.add(layer); if (layer.route) { visit(layer.route.stack, true); continue; } - if (layer.handle.stack) { visit(layer.handle.stack); continue; } + if (Array.isArray(layer.handle.stack)) { visit(layer.handle.stack); continue; } const original = layer.handle; if (original === idleAdmissionMiddleware) continue; const invoke = (req: Request, args: unknown[]) => { diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 683acb658ec..8fbfd14cc00 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -2359,6 +2359,23 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("discovers Copilot overrides with the assignee connection and environment", async () => { + const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" }; + mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Copilot", role: "engineer", status: "active", + adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1", + adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, + runtimeConfig: { aiConnection: binding } }]); + mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]); + const root = renderProperties(container, { + issue: createIssue({ assigneeAgentId: "agent-1", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-5.6-luna" } } }), + childIssues: [], onUpdate: vi.fn(), + }); + await waitForAssertion(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("company-1", "paperclip_runner", { + environmentId: "daytona-1", provider: "acpx", acpxAgent: "copilot", agentId: "agent-1", aiConnection: binding, + })); + act(() => root.unmount()); + }); + it("edits existing custom assignee model options from the properties pane", async () => { const onUpdate = vi.fn(); mockAgentsApi.list.mockResolvedValue([ diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index db6de10d240..febd9b46eb4 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -34,6 +34,7 @@ import { projectsApi } from "../../api/projects"; import { useCompany } from "../../context/CompanyContext"; import { useSidebar } from "../../context/SidebarContext"; import { queryKeys } from "../../lib/queryKeys"; +import { copilotTaskModelCatalogOptions } from "../../lib/copilot-task-model-catalog"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members"; import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides"; import { useProjectOrder } from "../../hooks/useProjectOrder"; @@ -729,11 +730,12 @@ export function IssueProperties({ const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; + const copilotCatalog = copilotTaskModelCatalogOptions(assignee); const assigneeAdapterType = assignee?.adapterType ?? null; const assigneeAdapterOverrides = issue.assigneeAdapterOverrides ?? null; const showAssigneeAdapterOptions = assigneeAdapterOverrides !== null; const supportsAssigneeOverrides = Boolean( - assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), + assigneeAdapterType && (ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType) || copilotCatalog), ); const assigneeOverrideLane = overrideLane(assigneeAdapterOverrides); const assigneeOverrideAdapterConfig = asRecord(assigneeAdapterOverrides?.adapterConfig); @@ -750,12 +752,16 @@ export function IssueProperties({ const assigneeOverrideChrome = assigneeAdapterType === "claude_local" && assigneeOverrideAdapterConfig.chrome === true; const catalogProvider = assigneeAdapterType === "paperclip_runner" ? String(normalizeLegacyRunnerProvider(assigneePrimaryAdapterConfig).provider ?? "codex") : undefined; + const catalogEnvironmentId = copilotCatalog ? assignee?.defaultEnvironmentId ?? null : null; const { data: assigneeAdapterModels } = useQuery({ queryKey: companyId && assigneeAdapterType - ? queryKeys.agents.adapterModels(companyId, assigneeAdapterType, null, catalogProvider) + ? [...queryKeys.agents.adapterModels(companyId, assigneeAdapterType, catalogEnvironmentId, catalogProvider), copilotCatalog ?? null] : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], - queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { provider: catalogProvider }), + queryFn: () => agentsApi.adapterModels(companyId!, assigneeAdapterType!, { + provider: catalogProvider, + ...(copilotCatalog ? { environmentId: catalogEnvironmentId, ...copilotCatalog } : {}), + }), enabled: Boolean(companyId) && showAssigneeAdapterOptions && supportsAssigneeOverrides, }); const modelOverrideOptions = useMemo(() => { diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx index 9a61b1779e4..35bfb43b3d5 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx @@ -259,6 +259,26 @@ describe("composer assignee picker", () => { expect(choices.some((item) => item.includes("GPT-6 Sol"))).toBe(false); }); + it("discovers Copilot task models with the assignee connection and environment", async () => { + const binding = { provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" } as const; + const copilot = { ...agent, adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1", + adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, + runtimeConfig: { aiConnection: binding } } as Agent; + const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([ + { id: "gpt-5.6-luna", label: "Copilot Luna" }, + ]); + render(vi.fn(), vi.fn(), true, { agents: new Map([[agent.id, copilot]]), settings: null }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + expect(loadModels).toHaveBeenCalledWith("company-1", "paperclip_runner", { + environmentId: "daytona-1", provider: "acpx", poolId: undefined, + acpxAgent: "copilot", agentId: agent.id, aiConnection: binding, + }); + await click("Select model and effort"); + await click("Choose exact model"); + expect(document.body.textContent).toContain("Copilot Luna"); + expect(document.body.textContent).not.toContain("Claude Sonnet"); + }); + it("preserves settings when the selected assignee is chosen again", async () => { const onAssigneeChange = vi.fn(); const onSettingsChange = vi.fn(); diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx index 27e9cae0b53..f0e12ba5cd6 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from " import { aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; import { agentsApi, type AdapterModel } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; +import { copilotTaskModelCatalogOptions } from "@/lib/copilot-task-model-catalog"; import { cn } from "@/lib/utils"; import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle"; import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort"; @@ -106,12 +107,13 @@ export function ComposerRunSettingsPicker({ const provider = composerCatalogProvider(agent); const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data; const poolId = binding?.mode === "router" ? binding.connectionId : undefined; + const copilotCatalog = copilotTaskModelCatalogOptions(agent); const { data: fetchedModels = [], isPending: modelsPending } = useQuery({ queryKey: agent && modelSupported - ? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null] + ? [...queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider), poolId ?? null, copilotCatalog ?? null] : ["agents", "composer-models", "none"], queryFn: () => agentsApi.adapterModels(companyId, agent!.adapterType, { - environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId, + environmentId: agent!.defaultEnvironmentId ?? null, provider, poolId, ...copilotCatalog, }), enabled: Boolean(agent && modelSupported && !modelOptionsOverride), }); diff --git a/ui/src/lib/copilot-task-model-catalog.test.ts b/ui/src/lib/copilot-task-model-catalog.test.ts new file mode 100644 index 00000000000..aa8c4c9a9c0 --- /dev/null +++ b/ui/src/lib/copilot-task-model-catalog.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { copilotTaskModelCatalogOptions } from "./copilot-task-model-catalog"; + +describe("Copilot task model catalog identity", () => { + const agent = { id: "copilot", adapterType: "paperclip_runner", adapterConfig: { + provider: "acpx", acpxAgent: "copilot", + } } as unknown as Agent; + it("changes the catalog identity when a saved connection changes", () => { + const a = copilotTaskModelCatalogOptions({ ...agent, runtimeConfig: { aiConnection: { + provider: "github", method: "api_key", mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333", + } } }); + const b = copilotTaskModelCatalogOptions({ ...agent, runtimeConfig: { aiConnection: { + provider: "github", method: "api_key", mode: "shared", connectionId: "22222222-2222-4222-8222-222222222222", grantId: "33333333-3333-4333-8333-333333333333", + } } }); + expect(a).not.toEqual(b); + }); + it("allows normal responsible-user discovery when no explicit binding is saved", () => { + expect(copilotTaskModelCatalogOptions(agent)).toEqual({ acpxAgent: "copilot", agentId: "copilot", aiConnection: undefined }); + }); + it("does not change catalogs for other runners", () => { + expect(copilotTaskModelCatalogOptions({ ...agent, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/copilot-task-model-catalog.ts b/ui/src/lib/copilot-task-model-catalog.ts new file mode 100644 index 00000000000..c8282aa8d13 --- /dev/null +++ b/ui/src/lib/copilot-task-model-catalog.ts @@ -0,0 +1,13 @@ +import { aiConnectionBindingSchema, type Agent } from "@paperclipai/shared"; + +/** Task overrides must discover models through the assignee's saved connection. */ +export function copilotTaskModelCatalogOptions(agent: Agent | null | undefined) { + if (agent?.adapterType !== "paperclip_runner" + || agent.adapterConfig.provider !== "acpx" + || agent.adapterConfig.acpxAgent !== "copilot") return undefined; + return { + acpxAgent: "copilot", + agentId: agent.id, + aiConnection: aiConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data, + }; +}