From 9bc4eea79a440e82de8649e47505bf5cef10b965 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:00:18 -0500 Subject: [PATCH] refactor(core): replace integration prompts with forms (#40997) --- packages/ai/src/providers/github-copilot.ts | 71 ----- packages/ai/src/providers/index.ts | 1 - packages/ai/test/auth-options.types.ts | 5 - packages/ai/test/exports.test.ts | 18 -- .../provider-options/github-copilot.types.ts | 13 - .../components/dialog-connect-provider.tsx | 116 ++++--- packages/app/src/context/server-sync.tsx | 3 +- .../src/commands/handlers/console/login.ts | 2 +- .../cli/src/commands/handlers/mcp/auth.ts | 2 +- packages/client/src/effect/api/api.ts | 5 +- .../client/src/effect/generated/client.ts | 4 +- .../client/src/promise/generated/client.ts | 4 +- .../client/src/promise/generated/types.ts | 150 +++++---- packages/client/test/promise.test.ts | 43 +++ packages/core/src/form.ts | 10 +- .../convert-to-openai-responses-input.ts | 41 +-- .../responses/openai-responses-api-types.ts | 2 +- .../openai-responses-language-model.ts | 75 ++++- packages/core/src/integration.ts | 49 +-- packages/core/src/model-resolver.ts | 4 +- packages/core/src/plugin/host.ts | 15 +- packages/core/src/plugin/promise.ts | 20 +- packages/core/src/plugin/provider/azure.ts | 26 ++ .../plugin/provider/cloudflare-ai-gateway.ts | 45 ++- .../plugin/provider/cloudflare-workers-ai.ts | 26 ++ .../core/src/plugin/provider/configured.ts | 15 + .../src/plugin/provider/github-copilot.ts | 98 +++--- packages/core/src/plugin/provider/opencode.ts | 7 +- .../openai-responses-language-model.test.ts | 212 ++++++++++++- packages/core/test/integration.test.ts | 27 +- packages/core/test/model-resolver.test.ts | 8 +- packages/core/test/plugin/host.ts | 44 +-- .../core/test/plugin/provider-azure.test.ts | 32 ++ .../provider-cloudflare-ai-gateway.test.ts | 29 ++ .../provider-cloudflare-workers-ai.test.ts | 36 +++ .../plugin/provider-github-copilot.test.ts | 53 +++- .../test/plugin/provider-opencode.test.ts | 19 +- packages/core/test/plugin/websearch.test.ts | 5 +- packages/core/test/shared-schema.test.ts | 5 - packages/plugin/src/effect/integration.ts | 44 ++- packages/plugin/src/promise/integration.ts | 49 ++- packages/protocol/src/groups/integration.ts | 6 +- packages/schema/src/credential.ts | 2 + packages/schema/src/integration.ts | 41 +-- packages/server/src/handlers/integration.ts | 3 +- .../tui/src/component/dialog-integration.tsx | 297 +++++++++++++++--- 46 files changed, 1232 insertions(+), 550 deletions(-) delete mode 100644 packages/ai/src/providers/github-copilot.ts delete mode 100644 packages/ai/test/provider-options/github-copilot.types.ts create mode 100644 packages/core/src/plugin/provider/configured.ts diff --git a/packages/ai/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts deleted file mode 100644 index ec7a64ea20..0000000000 --- a/packages/ai/src/providers/github-copilot.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" -import type { RouteDefaultsInput } from "../route/client" -import { ProviderID, type ModelID } from "../schema" -import * as OpenAIChat from "../protocols/openai-chat" -import * as OpenAIResponses from "../protocols/openai-responses" -import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" - -export const id = ProviderID.make("github-copilot") - -// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must -// supply `baseURL` explicitly. -export type LanguageModelOptions = Omit & - ProviderAuthOption<"optional"> & { - readonly baseURL: string - readonly endpoint?: "chat" | "responses" - readonly providerOptions?: OpenAIProviderOptionsInput - } - -export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => { - if (endpoint) return endpoint === "responses" - const model = String(modelID) - const match = /^gpt-(\d+)/.exec(model) - if (!match) return false - return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini") -} - -export const routes = [OpenAIResponses.route, OpenAIChat.route] - -const chatRoute = OpenAIChat.route.with({ provider: id }) -const responsesRoute = OpenAIResponses.route.with({ provider: id }) - -const defaults = (options: LanguageModelOptions) => { - const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options - return rest -} - -const configuredResponsesRoute = (options: LanguageModelOptions) => - responsesRoute.with({ - endpoint: { baseURL: options.baseURL }, - auth: AuthOptions.bearer(options, []), - }) - -const configuredChatRoute = (options: LanguageModelOptions) => - chatRoute.with({ - endpoint: { baseURL: options.baseURL }, - auth: AuthOptions.bearer(options, []), - }) - -export const configure = (options: LanguageModelOptions) => { - const responsesRoute = configuredResponsesRoute(options) - const chatRoute = configuredChatRoute(options) - const responses = (modelID: string | ModelID) => - responsesRoute - .with(withOpenAIOptions(modelID, defaults(options))) - .model({ id: modelID }) - const chat = (modelID: string | ModelID) => - chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID }) - return { - id, - model: (modelID: string | ModelID) => - shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID), - responses, - chat, - configure, - } -} - -export const provider = { - id, - configure, -} diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 752e7e854b..e530fefc52 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -5,7 +5,6 @@ export * as AmazonBedrockMantle from "./amazon-bedrock-mantle" export * as Azure from "./azure" export * as Cloudflare from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" -export * as GitHubCopilot from "./github-copilot" export * as Google from "./google" export * as GoogleVertex from "./google-vertex" export * as GoogleVertexChat from "./google-vertex-chat" diff --git a/packages/ai/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index 36dd985580..9a75be2d47 100644 --- a/packages/ai/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -7,7 +7,6 @@ import * as Anthropic from "../src/providers/anthropic" import * as AnthropicCompatible from "../src/providers/anthropic-compatible" import * as Azure from "../src/providers/azure" import * as Cloudflare from "../src/providers/cloudflare" -import * as GitHubCopilot from "../src/providers/github-copilot" import * as Google from "../src/providers/google" import * as GoogleVertex from "../src/providers/google-vertex" import * as GoogleVertexChat from "../src/providers/google-vertex-chat" @@ -270,7 +269,3 @@ OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek- Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama") // @ts-expect-error Cloudflare Workers AI model selectors only accept model ids. Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {}) - -GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1") -// @ts-expect-error GitHub Copilot model selectors only accept model ids. -GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {}) diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index b0003b55d7..4821aa2dca 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -10,7 +10,6 @@ import { OpenRouter, XAI, } from "@opencode-ai/ai/providers" -import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot" import { OpenAIChat, OpenAICompatibleChat, @@ -60,23 +59,6 @@ describe("public exports", () => { expect(XAI.provider.chat).toBe(XAI.chat) expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses") expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat") - expect( - GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model, - ).toBeFunction() - expect( - GitHubCopilot.configure({ - baseURL: "https://api.githubcopilot.test", - apiKey: "fixture", - endpoint: "responses", - }).model("mai-code-1-flash-picker").route.id, - ).toBe("openai-responses") - expect( - GitHubCopilot.configure({ - baseURL: "https://api.githubcopilot.test", - apiKey: "fixture", - endpoint: "chat", - }).model("gpt-5").route.id, - ).toBe("openai-chat") }) test("protocol barrels expose supported low-level routes", () => { diff --git a/packages/ai/test/provider-options/github-copilot.types.ts b/packages/ai/test/provider-options/github-copilot.types.ts deleted file mode 100644 index 6f1ea24432..0000000000 --- a/packages/ai/test/provider-options/github-copilot.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { LLM } from "../../src" -import { GitHubCopilot } from "../../src/providers" - -const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5") - -LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } }) - -LLM.request({ - model, - prompt: "Hello", - // @ts-expect-error Copilot reasoning summaries use the OpenAI union. - providerOptions: { openai: { reasoningSummary: "full" } }, -}) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 8291bfc947..a6fcf19173 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" +import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -40,6 +40,8 @@ import { decode64 } from "@/utils/base64" const CUSTOM_ID = "_custom" type ConnectMethod = Extract +type IntegrationForm = NonNullable[number] +type StringForm = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) @@ -434,16 +436,16 @@ function ProviderConnection(props: { const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], - promptInputs: undefined as undefined | Record, - state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", + formAnswer: undefined as FormAnswer | undefined, + state: "pending" as undefined | "pending" | "complete" | "error" | "form", error: undefined as string | undefined, }) type Action = | { type: "method.select"; index: number } | { type: "method.reset" } - | { type: "auth.prompt" } - | { type: "auth.inputs"; inputs: Record } + | { type: "auth.form" } + | { type: "auth.answer"; answer: FormAnswer | undefined } | { type: "auth.pending" } | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } @@ -454,7 +456,7 @@ function ProviderConnection(props: { if (action.type === "method.select") { draft.methodIndex = action.index draft.authorization = undefined - draft.promptInputs = undefined + draft.formAnswer = undefined draft.state = undefined draft.error = undefined return @@ -462,18 +464,18 @@ function ProviderConnection(props: { if (action.type === "method.reset") { draft.methodIndex = undefined draft.authorization = undefined - draft.promptInputs = undefined + draft.formAnswer = undefined draft.state = undefined draft.error = undefined return } - if (action.type === "auth.prompt") { - draft.state = "prompt" + if (action.type === "auth.form") { + draft.state = "form" draft.error = undefined return } - if (action.type === "auth.inputs") { - draft.promptInputs = action.inputs + if (action.type === "auth.answer") { + draft.formAnswer = action.answer draft.state = undefined draft.error = undefined return @@ -531,7 +533,7 @@ function ProviderConnection(props: { return fallback } - async function selectMethod(index: number, inputs?: Record) { + async function selectMethod(index: number, answer?: FormAnswer) { if (timer.current !== undefined) { clearTimeout(timer.current) timer.current = undefined @@ -540,9 +542,17 @@ function ProviderConnection(props: { const method = methods()[index] dispatch({ type: "method.select", index }) + if (method.form?.length && !answer) { + dispatch({ type: "auth.form" }) + return + } + if (method.type === "key") { + dispatch({ type: "auth.answer", answer }) + return + } if (method.type === "oauth") { - if (method.prompts?.length && !inputs) { - dispatch({ type: "auth.prompt" }) + if (method.form?.some((field) => field.type !== "string")) { + dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" }) return } dispatch({ type: "auth.pending" }) @@ -550,7 +560,7 @@ function ProviderConnection(props: { .api.integration.oauth.connect({ integrationID: props.provider, methodID: method.id, - inputs: inputs ?? {}, + ...(answer ? { answer } : {}), location: location(), }) .then((x) => { @@ -564,41 +574,42 @@ function ProviderConnection(props: { } } - function AuthPromptsView() { + function AuthFormView() { const [formStore, setFormStore] = createStore({ value: {} as Record, index: 0, }) - const prompts = createMemo(() => { + const fields = createMemo(() => { const value = method() - return value?.type === "oauth" ? (value.prompts ?? []) : [] + return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : [])) }) - const matches = (prompt: NonNullable[number]>, value: Record) => { - if (!prompt.when) return true - const actual = value[prompt.when.key] - if (actual === undefined) return false - return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value + const matches = (field: StringForm, value: Record) => { + return (field.when ?? []).every((condition) => { + const actual = value[condition.key] + if (actual === undefined) return false + return condition.op === "eq" ? actual === condition.value : actual !== condition.value + }) } const current = createMemo(() => { - const all = prompts() - const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) + const all = fields() + const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value)) if (index === -1) return return { index, - prompt: all[index], + field: all[index], } }) const valid = createMemo(() => { const item = current() - if (!item || item.prompt.type !== "text") return false - const value = formStore.value[item.prompt.key] ?? "" - return value.trim().length > 0 + if (!item || item.field.options) return false + if (!item.field.required) return true + return (formStore.value[item.field.key] ?? "").trim().length > 0 }) async function next(index: number, value: Record) { if (store.methodIndex === undefined) return - const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) + const next = fields().findIndex((field, i) => i > index && matches(field, value)) if (next !== -1) { setFormStore("index", next) return @@ -609,60 +620,60 @@ function ProviderConnection(props: { async function handleSubmit(e: SubmitEvent) { e.preventDefault() const item = current() - if (!item || item.prompt.type !== "text") return + if (!item || item.field.options) return if (!valid()) return await next(item.index, formStore.value) } const item = () => current() const text = createMemo(() => { - const prompt = item()?.prompt - if (!prompt || prompt.type !== "text") return - return prompt + const field = item()?.field + if (!field || field.options) return + return field }) const select = createMemo(() => { - const prompt = item()?.prompt - if (!prompt || prompt.type !== "select") return - return prompt + const field = item()?.field + if (!field?.options) return + return field }) return (
- + { - const prompt = text() - if (!prompt) return - setFormStore("value", prompt.key, value) + const field = text() + if (!field) return + setFormStore("value", field.key, value) }} /> - +
-
{select()?.message}
+
{select()?.title}
x.value} - current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} + current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])} onSelect={(value) => { if (!value) return - const prompt = select() - if (!prompt) return + const field = select() + if (!field) return const nextValue = { ...formStore.value, - [prompt.key]: value.value, + [field.key]: value.value, } - setFormStore("value", prompt.key, value.value) + setFormStore("value", field.key, value.value) void next(item()!.index, nextValue) }} > @@ -672,7 +683,7 @@ function ProviderConnection(props: { {option.label} - {option.hint} + {option.description}
)} @@ -820,6 +831,7 @@ function ProviderConnection(props: { integrationID: props.provider, location: location(), key: apiKey, + ...(store.formAnswer ? { answer: store.formAnswer } : {}), }) await complete() } @@ -1143,8 +1155,8 @@ function ProviderConnection(props: {
- - + +
diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 40942a8424..28558727c1 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -662,13 +662,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { integrationID: server.integrationID, location: { directory: key }, }) - const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length) + const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length) if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`) const attempt = await serverSDK.api.integration.oauth.connect({ integrationID: server.integrationID, methodID: method.id, - inputs: {}, location: { directory: key }, }) platform.openLink(attempt.data.url) diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 380944d527..0964f76dfb 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -50,7 +50,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo { integrationID, methodID: method.id, - inputs: server ? { server } : {}, + ...(server ? { answer: { server } } : {}), location, }, { signal }, diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 2ca5259953..cf4f216f92 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -32,7 +32,7 @@ export default Runtime.handler( return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) const started = yield* Effect.promise(() => - client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), + client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, location }), ) const attempt = started.data if (attempt.mode === "code") diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index e0867bedd9..52b848b2ea 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -23,9 +23,9 @@ import type { Shell } from "@opencode-ai/schema/shell" import type { DateTime } from "effect" import type { Provider } from "@opencode-ai/schema/provider" import type { Integration } from "@opencode-ai/schema/integration" +import type { Form } from "@opencode-ai/schema/form" import type { Mcp } from "@opencode-ai/schema/mcp" import type { Credential } from "@opencode-ai/schema/credential" -import type { Form } from "@opencode-ai/schema/form" import type { Permission } from "@opencode-ai/schema/permission" import type { PermissionSaved } from "@opencode-ai/schema/permission-saved" import type { FileSystem } from "@opencode-ai/schema/filesystem" @@ -1054,6 +1054,7 @@ export type Endpoint10_3Input = { readonly integrationID: Integration.ID readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly key: string + readonly answer?: Form.Answer | undefined readonly label?: string | undefined } export type Endpoint10_3Output = void @@ -1065,7 +1066,7 @@ export type Endpoint10_4Input = { readonly integrationID: Integration.ID readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly methodID: Integration.MethodID - readonly inputs: { readonly [x: string]: string } + readonly answer?: Form.Answer | undefined readonly label?: string | undefined } export type Endpoint10_4Output = { readonly location: Location.Info; readonly data: Integration.Attempt } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 4441e14f6f..cc334c9709 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -717,7 +717,7 @@ const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint1 raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { key: input["key"], label: input["label"] }, + payload: { key: input["key"], answer: input["answer"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)), ) @@ -726,7 +726,7 @@ const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint1 raw["integration.oauth.connect"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + payload: { methodID: input["methodID"], answer: input["answer"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)), ) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index dda31d908a..8dbcd8c966 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1032,7 +1032,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, query: { location: input["location"] }, - body: { key: input["key"], label: input["label"] }, + body: { key: input["key"], answer: input["answer"], label: input["label"] }, successStatus: 204, declaredStatuses: [400, 401], empty: true, @@ -1047,7 +1047,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, query: { location: input["location"] }, - body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] }, successStatus: 200, declaredStatuses: [400, 401], empty: false, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 090811bc07..747f0492e0 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -195,12 +195,18 @@ export type ProviderInfo = { body?: { [x: string]: any } } -export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string } +export type FormWhen = { + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean +} + +export type FormOption = { value: string; label: string; description?: string } + +export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string } export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array } -export type IntegrationKeyMethod = { type: "key"; label?: string } - export type IntegrationEnvMethod = { type: "env"; names: Array } export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string } @@ -285,16 +291,6 @@ export type ProjectDirectory = { directory: string; strategy?: string } export type FormMetadata = { [x: string]: JsonValue } -export type FormWhen = { - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean -} - -export type FormOption = { value: string; label: string; description?: string } - -export type FormExternalField = { key: string; type: "external"; url: string; title?: string; description?: string } - export type FormValue = string | number | boolean | Array export type PermissionSource = { type: "tool"; messageID: string; id: string } @@ -1277,45 +1273,6 @@ export type ModelCost = { cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens } } -export type IntegrationTextPrompt = { - type: "text" - key: string - message: string - placeholder?: string - when?: IntegrationWhen -} - -export type IntegrationSelectPrompt = { - type: "select" - key: string - message: string - options: Array<{ label: string; value: string; hint?: string }> - when?: IntegrationWhen -} - -export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo - -export type McpServer = { - name: string - status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth - integrationID?: string -} - -export type McpResourceCatalog = { resources: Array; templates: Array } - -export type Project = { - id: string - canonical: string - vcs?: ProjectVcs - name?: string - icon?: ProjectIcon - commands?: ProjectCommands - time: ProjectTime - sandboxes: Array -} - -export type ProjectDirectories = Array - export type FormNumberField = { key: string title?: string @@ -1381,6 +1338,29 @@ export type FormMultiselectField = { default?: Array } +export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo + +export type McpServer = { + name: string + status: McpStatusConnected | McpStatusPending | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth + integrationID?: string +} + +export type McpResourceCatalog = { resources: Array; templates: Array } + +export type Project = { + id: string + canonical: string + vcs?: ProjectVcs + name?: string + icon?: ProjectIcon + commands?: ProjectCommands + time: ProjectTime + sandboxes: Array +} + +export type ProjectDirectories = Array + export type FormAnswer = { [x: string]: FormValue } export type PermissionRequest = { @@ -1664,13 +1644,6 @@ export type ModelInfo = { limit: { context: number; input?: number; output: number } } -export type IntegrationOAuthMethod = { - id: string - type: "oauth" - label: string - prompts?: Array -} - export type FormField = | FormStringField | FormNumberField @@ -1924,15 +1897,9 @@ export type SessionMessageAssistantTool = { time: { created: number; ran?: number; completed?: number } } -export type IntegrationMethod = - | IntegrationOAuthMethod - | IntegrationCommandMethod - | IntegrationKeyMethod - | IntegrationEnvMethod - export type FormFields = [FormField, ...Array] -export type FormFields1 = [FormField1, ...Array] +export type FormFields3 = [FormField1, ...Array] export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction @@ -1954,16 +1921,13 @@ export type SessionMessageAssistant = { retry?: SessionMessageAssistantRetry } -export type IntegrationInfo = { - id: string - name: string - methods: Array - connections: Array -} +export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields } + +export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields } export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields } -export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 } +export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 } export type SessionInputAdmitted = { id: string @@ -1986,6 +1950,12 @@ export type SessionMessageInfo = | SessionMessageAssistant | SessionMessageCompaction +export type IntegrationMethod = + | IntegrationOAuthMethod + | IntegrationCommandMethod + | IntegrationKeyMethod + | IntegrationEnvMethod + export type FormCreated = { id: string created: number @@ -2046,6 +2016,13 @@ export type SessionMessagesResponse = { cursor: { previous?: string | null; next?: string | null } } +export type IntegrationInfo = { + id: string + name: string + methods: Array + connections: Array +} + export type V2Event = | ModelsDevRefreshed | IntegrationUpdated @@ -4045,8 +4022,21 @@ export type IntegrationConnectKeyInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] - readonly key: { readonly key: string; readonly label?: string | undefined }["key"] - readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] + readonly key: { + readonly key: string + readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined + readonly label?: string | undefined + }["key"] + readonly answer?: { + readonly key: string + readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined + readonly label?: string | undefined + }["answer"] + readonly label?: { + readonly key: string + readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined + readonly label?: string | undefined + }["label"] } export type IntegrationConnectKeyOutput = void @@ -4058,17 +4048,17 @@ export type IntegrationOauthConnectInput = { }["location"] readonly methodID: { readonly methodID: string - readonly inputs: { readonly [x: string]: string } + readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined readonly label?: string | undefined }["methodID"] - readonly inputs: { + readonly answer?: { readonly methodID: string - readonly inputs: { readonly [x: string]: string } + readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined readonly label?: string | undefined - }["inputs"] + }["answer"] readonly label?: { readonly methodID: string - readonly inputs: { readonly [x: string]: string } + readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray } | undefined readonly label?: string | undefined }["label"] } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 45dd27b54d..4936833375 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -148,6 +148,49 @@ test("experimental wellknown integration add uses the public HTTP contract", asy expect(await request?.json()).toEqual({ url: "https://example.com" }) }) +test("integration connections optionally submit a form answer", async () => { + const requests: Request[] = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + if (request.url.endsWith("/connect/key")) return new Response(null, { status: 204 }) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { + attemptID: "con_test", + url: "https://example.com/authorize", + instructions: "Authorize", + mode: "auto", + time: { created: 1, expires: 2 }, + }, + }) + }, + }) + + await client.integration.connect.key({ + integrationID: "cloudflare-workers-ai", + key: "secret", + answer: { accountId: "account" }, + }) + await client.integration.oauth.connect({ + integrationID: "github-copilot", + methodID: "device", + answer: { deploymentType: "enterprise", enabled: true, scopes: ["read:user"] }, + }) + await client.integration.connect.key({ integrationID: "openai", key: "secret" }) + await client.integration.oauth.connect({ integrationID: "openai", methodID: "device" }) + + expect(await requests[0].json()).toEqual({ key: "secret", answer: { accountId: "account" } }) + expect(await requests[1].json()).toEqual({ + methodID: "device", + answer: { deploymentType: "enterprise", enabled: true, scopes: ["read:user"] }, + }) + expect(await requests[2].json()).toEqual({ key: "secret" }) + expect(await requests[3].json()).toEqual({ methodID: "device" }) +}) + test("health.stop sends exact replacement identity", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 6d0716b12f..8200f1d243 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -180,7 +180,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const entry = yield* find(input.id) if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id }) - const invalid = validateAnswer(entry.form, input.answer) + const invalid = validateAnswer(entry.form.fields, input.answer) if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid }) const next: TerminalState = { status: "answered", answer: input.answer } yield* bus.publish(Form.Event.Replied, { @@ -227,12 +227,12 @@ export const locationLayer = layer export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] }) -function validateAnswer(form: Info, answer: Answer) { - const fields = new Map(form.fields.map((field) => [field.key, field] as const)) +export function validateAnswer(form: ReadonlyArray, answer: Answer) { + const fields = new Map(form.map((field) => [field.key, field] as const)) for (const key of Object.keys(answer)) { if (!fields.has(key)) return `Unknown form field: ${key}` } - for (const field of form.fields) { + for (const field of form) { const value = answer[field.key] if (field.type === "external") { if (value !== true) return `External form field must be acknowledged: ${field.key}` @@ -268,7 +268,7 @@ function matches(when: Form.When, value: Form.Value | undefined) { // carry a value matching that field's type, and use a declared option when the field's options // are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of // silently never matching. -function validateFields(fields: ReadonlyArray) { +export function validateFields(fields: ReadonlyArray) { if (fields.length === 0) return "Form must have at least one field" const earlier = new Map() const keys = new Set() diff --git a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts index 1e4f86d933..e68c86e765 100644 --- a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts +++ b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts @@ -127,7 +127,7 @@ export async function convertToOpenAIResponsesInput({ input.push({ role: "assistant", content: [{ type: "output_text", text: part.text }], - id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, + id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined, }) break } @@ -143,7 +143,7 @@ export async function convertToOpenAIResponsesInput({ input.push({ type: "local_shell_call", call_id: part.toolCallId, - id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, + id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined, action: { type: "exec", command: parsedInput.action.command, @@ -162,7 +162,7 @@ export async function convertToOpenAIResponsesInput({ call_id: part.toolCallId, name: part.toolName, arguments: JSON.stringify(part.input), - id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, + id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined, }) break } @@ -206,35 +206,14 @@ export async function convertToOpenAIResponsesInput({ summary: [], } } - } else { - const summaryParts: Array<{ - type: "summary_text" - text: string - }> = [] - - if (part.text.length > 0) { - summaryParts.push({ - type: "summary_text", - text: part.text, - }) - } else if (reasoningMessage !== undefined) { - warnings.push({ - type: "other", - message: `Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: ${JSON.stringify(part)}.`, - }) - } - - if (reasoningMessage === undefined) { - reasoningMessages[reasoningId] = { - type: "reasoning", - id: reasoningId, - encrypted_content: providerOptions?.reasoningEncryptedContent, - summary: summaryParts, - } - input.push(reasoningMessages[reasoningId]) - } else { - reasoningMessage.summary.push(...summaryParts) + } else if (providerOptions?.reasoningEncryptedContent != null && reasoningMessage === undefined) { + reasoningMessages[reasoningId] = { + type: "reasoning", + id: reasoningId, + encrypted_content: providerOptions.reasoningEncryptedContent, + summary: [], } + input.push(reasoningMessages[reasoningId]) } } else { warnings.push({ diff --git a/packages/core/src/github-copilot/responses/openai-responses-api-types.ts b/packages/core/src/github-copilot/responses/openai-responses-api-types.ts index dfdd066750..5124f9ead9 100644 --- a/packages/core/src/github-copilot/responses/openai-responses-api-types.ts +++ b/packages/core/src/github-copilot/responses/openai-responses-api-types.ts @@ -71,7 +71,7 @@ export type OpenAIResponsesComputerCall = { export type OpenAIResponsesLocalShellCall = { type: "local_shell_call" - id: string + id?: string call_id: string action: { type: "exec" diff --git a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts index 8df1dcedad..2f1378ce8c 100644 --- a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts +++ b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts @@ -198,12 +198,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { providerOptions, schema: openaiResponsesProviderOptionsSchema, }) + const store = openaiOptions?.store ?? false const { input, warnings: inputWarnings } = await convertToOpenAIResponsesInput({ prompt, systemMessageMode: modelConfig.systemMessageMode, fileIdPrefixes: this.config.fileIdPrefixes, - store: openaiOptions?.store ?? true, + store, hasLocalShellTool: hasOpenAITool("openai.local_shell"), }) @@ -214,9 +215,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { let include: OpenAIResponsesIncludeOptions = openaiOptions?.include function addInclude(key: OpenAIResponsesIncludeValue) { + if (include?.includes(key)) return include = include != null ? [...include, key] : [key] } + addInclude("reasoning.encrypted_content") + function hasOpenAITool(id: string) { return tools?.find((tool) => tool.type === "provider" && tool.id === id) != null } @@ -282,7 +286,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { metadata: openaiOptions?.metadata, parallel_tool_calls: openaiOptions?.parallelToolCalls, previous_response_id: openaiOptions?.previousResponseId, - store: openaiOptions?.store, + store, user: openaiOptions?.user, instructions: openaiOptions?.instructions, service_tier: openaiOptions?.serviceTier, @@ -840,7 +844,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { { canonicalId: string // the item.id from output_item.added encryptedContent?: string | null - summaryParts: number[] + summaryParts: Record } > = {} @@ -960,10 +964,14 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { }, }) } else if (isResponseOutputItemAddedReasoningChunk(value)) { + if (activeReasoning[value.output_index]) { + currentReasoningOutputIndex = value.output_index + return + } activeReasoning[value.output_index] = { canonicalId: value.item.id, encryptedContent: value.item.encrypted_content, - summaryParts: [0], + summaryParts: { 0: "active" }, } currentReasoningOutputIndex = value.output_index @@ -1117,13 +1125,14 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } else if (isResponseOutputItemDoneReasoningChunk(value)) { const activeReasoningPart = activeReasoning[value.output_index] if (activeReasoningPart) { - for (const summaryIndex of activeReasoningPart.summaryParts) { + for (const [summaryIndex, status] of Object.entries(activeReasoningPart.summaryParts)) { + if (status === "concluded") continue controller.enqueue({ type: "reasoning-end", id: `${activeReasoningPart.canonicalId}:${summaryIndex}`, providerMetadata: { copilot: { - itemId: activeReasoningPart.canonicalId, + itemId: value.item.id, reasoningEncryptedContent: value.item.encrypted_content ?? null, }, }, @@ -1228,8 +1237,19 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk. - if (activeItem && value.summary_index > 0) { - activeItem.summaryParts.push(value.summary_index) + if (activeItem && value.summary_index > 0 && activeItem.summaryParts[value.summary_index] === undefined) { + for (const [summaryIndex, status] of Object.entries(activeItem.summaryParts)) { + if (status !== "can-conclude") continue + controller.enqueue({ + type: "reasoning-end", + id: `${activeItem.canonicalId}:${summaryIndex}`, + providerMetadata: { + copilot: { itemId: activeItem.canonicalId }, + }, + }) + activeItem.summaryParts[Number(summaryIndex)] = "concluded" + } + activeItem.summaryParts[value.summary_index] = "active" controller.enqueue({ type: "reasoning-start", @@ -1242,6 +1262,22 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { }, }) } + } else if (isResponseReasoningSummaryPartDoneChunk(value)) { + const activeItem = + currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null + if (!activeItem || activeItem.summaryParts[value.summary_index] !== "active") return + if (body.store === false) { + activeItem.summaryParts[value.summary_index] = "can-conclude" + return + } + controller.enqueue({ + type: "reasoning-end", + id: `${activeItem.canonicalId}:${value.summary_index}`, + providerMetadata: { + copilot: { itemId: activeItem.canonicalId }, + }, + }) + activeItem.summaryParts[value.summary_index] = "concluded" } else if (isResponseReasoningSummaryTextDeltaChunk(value)) { const activeItem = currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null @@ -1304,6 +1340,16 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { controller.enqueue({ type: "text-end", id: currentTextId }) currentTextId = null } + for (const activeItem of Object.values(activeReasoning)) { + for (const [summaryIndex, status] of Object.entries(activeItem.summaryParts)) { + if (status === "concluded") continue + controller.enqueue({ + type: "reasoning-end", + id: `${activeItem.canonicalId}:${summaryIndex}`, + providerMetadata: { copilot: { itemId: activeItem.canonicalId } }, + }) + } + } const providerMetadata: SharedV3ProviderMetadata = { copilot: { @@ -1552,6 +1598,12 @@ const responseReasoningSummaryTextDeltaSchema = z.object({ delta: z.string(), }) +const responseReasoningSummaryPartDoneSchema = z.object({ + type: z.literal("response.reasoning_summary_part.done"), + item_id: z.string(), + summary_index: z.number(), +}) + const openaiResponsesChunkSchema = z.union([ textDeltaChunkSchema, responseFinishedChunkSchema, @@ -1564,6 +1616,7 @@ const openaiResponsesChunkSchema = z.union([ responseCodeInterpreterCallCodeDoneSchema, responseAnnotationAddedSchema, responseReasoningSummaryPartAddedSchema, + responseReasoningSummaryPartDoneSchema, responseReasoningSummaryTextDeltaSchema, errorChunkSchema, z.object({ type: z.string() }).loose(), // fallback for unknown chunks @@ -1652,6 +1705,12 @@ function isResponseReasoningSummaryPartAddedChunk( return chunk.type === "response.reasoning_summary_part.added" } +function isResponseReasoningSummaryPartDoneChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.reasoning_summary_part.done" +} + function isResponseReasoningSummaryTextDeltaChunk( chunk: z.infer, ): chunk is z.infer { diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index a14635a576..8339e1f38f 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -24,6 +24,7 @@ import { Bus } from "./bus" import { IntegrationConnection } from "./integration/connection" import { AppProcess } from "@opencode-ai/util/process" import { ChildProcess } from "effect/unstable/process" +import { Form } from "./form" export const ID = Integration.ID export type ID = Integration.ID @@ -34,18 +35,6 @@ export type MethodID = Integration.MethodID export const AttemptID = Integration.AttemptID export type AttemptID = typeof AttemptID.Type -export const When = Integration.When -export type When = Integration.When - -export const TextPrompt = Integration.TextPrompt -export type TextPrompt = Integration.TextPrompt - -export const SelectPrompt = Integration.SelectPrompt -export type SelectPrompt = Integration.SelectPrompt - -export const Prompt = Integration.Prompt -export type Prompt = Integration.Prompt - export const OAuthMethod = Integration.OAuthMethod export type OAuthMethod = Integration.OAuthMethod @@ -64,9 +53,6 @@ export type Method = Integration.Method export const Info = Integration.Info export type Info = Integration.Info -export const Inputs = Integration.Inputs -export type Inputs = Integration.Inputs - export type OAuthAuthorization = { readonly url: string readonly instructions: string @@ -85,7 +71,7 @@ export type OAuthAuthorization = { export interface OAuthImplementation { readonly integrationID: ID readonly method: OAuthMethod - readonly authorize: (inputs: Inputs) => Effect.Effect + readonly authorize: (answer: Form.Answer) => Effect.Effect readonly refresh?: (credential: Credential.OAuth) => Effect.Effect readonly label?: (credential: Credential.OAuth) => string | undefined } @@ -175,6 +161,8 @@ export interface Interface extends State.Transformable { readonly integrationID: ID /** Secret entered by the user. */ readonly key: string + /** Values collected from the method's form fields. */ + readonly answer?: Form.Answer /** User-facing label for the stored credential. */ readonly label?: string }) => Effect.Effect @@ -191,7 +179,7 @@ export interface Interface extends State.Transformable { readonly connect: (input: { readonly integrationID: ID readonly methodID: MethodID - readonly inputs: Inputs + readonly answer?: Form.Answer readonly label?: string }) => Effect.Effect /** Returns the current state of an OAuth attempt. */ @@ -356,7 +344,7 @@ const layer = Layer.effect( return [...credentials, ...env] } - const project = (entry: Entry, connections: IntegrationConnection.Info[]) => + const project = (entry: Entry, connections: IntegrationConnection.Info[]): Info => Info.make({ id: entry.ref.id, name: entry.ref.name, @@ -547,15 +535,20 @@ const layer = Layer.effect( const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: { readonly integrationID: ID readonly methodID: MethodID - readonly inputs: Inputs + readonly answer?: Form.Answer readonly label?: string }) { const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) if (!method) { return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) } + const answer = input.answer ?? {} + if (method.method.form) { + const invalid = Form.validateFields(method.method.form) ?? Form.validateAnswer(method.method.form, answer) + if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) }) + } const attemptScope = yield* Scope.fork(scope) - const authorization = yield* authorize(method.authorize(input.inputs)).pipe( + const authorization = yield* authorize(method.authorize(answer)).pipe( Scope.provide(attemptScope), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), ) @@ -699,12 +692,24 @@ const layer = Layer.effect( const method = state .get() .integrations.get(input.integrationID) - ?.methods.some((method) => method.type === "key") + ?.methods.find((method) => method.type === "key") if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`)) + const answer = input.answer ?? {} + if (method.type === "key" && method.form) { + const invalid = Form.validateFields(method.form) ?? Form.validateAnswer(method.form, answer) + if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) }) + } + if (method.type === "key" && !method.form && Object.keys(answer).length > 0) { + return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") }) + } yield* credentials.create({ integrationID: input.integrationID, label: input.label, - value: Credential.Key.make({ type: "key", key: input.key }), + value: Credential.Key.make({ + type: "key", + key: input.key, + ...(Object.keys(answer).length > 0 ? { configuration: answer } : {}), + }), }) yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID }) yield* bus.publish(Integration.Event.Updated, {}) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index fbf345a02a..0f9424fdb9 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -149,6 +149,7 @@ export const fromCatalogModel = ( }) const packageName = Provider.packageName(resolved.package) const key = apiKey(resolved, credential) + const configuration = credential?.type === "key" ? credential.configuration : undefined if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { return Effect.succeed( @@ -175,7 +176,7 @@ export const fromCatalogModel = ( .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), ) } - const configured = { ...resolved.settings, ...credential?.metadata } + const configured = { ...resolved.settings, ...credential?.metadata, ...configuration } const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map({ packageName, @@ -190,6 +191,7 @@ export const fromCatalogModel = ( draft.settings = Provider.mergeOverlay(draft.settings, { ...nativeCredentialSettings(resolved.package ?? "", credential), ...credential?.metadata, + ...configuration, }) }) return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index b273f217d7..a563be0579 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -190,6 +190,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p integration.connection.key({ integrationID: Integration.ID.make(input.integrationID), key: input.key, + answer: input.answer, label: input.label, }), }, @@ -199,7 +200,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p integration.oauth.connect({ integrationID: Integration.ID.make(input.integrationID), methodID: Integration.MethodID.make(input.methodID), - inputs: input.inputs, + answer: input.answer, label: input.label, }), ), @@ -260,7 +261,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p update: (id, update) => draft.update(Integration.ID.make(id), update), remove: (id) => draft.remove(Integration.ID.make(id)), method: { - list: (id) => mutable(draft.method.list(Integration.ID.make(id))), + list: (id) => draft.method.list(Integration.ID.make(id)), update: (input) => draft.method.update(methodImplementation(input)), remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), @@ -363,8 +364,8 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration return { integrationID: Integration.ID.make(input.integrationID), method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, - authorize: (inputs) => - input.authorize(inputs).pipe( + authorize: (answer) => + input.authorize(answer).pipe( Effect.map((authorization) => { if (authorization.mode === "auto") { return { @@ -385,18 +386,18 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration if (input.method.type === "env") { return { integrationID: Integration.ID.make(input.integrationID), - method: { type: "env", names: input.method.names }, + method: input.method, } } if (input.method.type === "command") { return { integrationID: Integration.ID.make(input.integrationID), - method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method), + method: { ...input.method, id: Integration.MethodID.make(input.method.id) }, } } return { integrationID: Integration.ID.make(input.integrationID), - method: { type: "key", label: input.method.label }, + method: input.method, } } diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index a2d7f7b68d..d256fb8964 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -180,8 +180,8 @@ export function fromPromise(plugin: Plugin) { const refresh = input.refresh draft.method.update({ ...input, - authorize: (inputs) => - Effect.promise(() => input.authorize(inputs)).pipe( + authorize: (answer) => + Effect.promise(() => input.authorize(answer)).pipe( Effect.map((authorization) => authorization.mode === "auto" ? { @@ -362,11 +362,17 @@ type Wire = unknown extends Value ? Value : Value extends DateTime.DateTime ? number - : Value extends ReadonlyArray - ? Array> - : Value extends object - ? { -readonly [Key in keyof Value]: Wire } - : Value + : Value extends readonly [infer Head, ...infer Tail] + ? [Wire, ...WireTuple] + : Value extends ReadonlyArray + ? Array> + : Value extends object + ? { -readonly [Key in keyof Value]: Wire } + : Value + +type WireTuple> = { + -readonly [Key in keyof Value]: Wire +} function wire(value: Value): Wire function wire(value: unknown): unknown { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index d2cd18897e..2dc6ce670d 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,6 +1,9 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Form } from "@opencode-ai/schema/form" import { Provider } from "../../provider" +import { iife } from "../../util/iife" +import { configuredSettings } from "./configured" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { if (useChat && sdk.chat) return sdk.chat(modelID) @@ -13,6 +16,29 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { export const AzurePlugin = define({ id: "opencode.provider.azure", effect: Effect.fn(function* (ctx) { + const configured = yield* configuredSettings(Provider.ID.azure) + const form = iife(() => { + if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return + return Form.Fields.make([ + { + type: "string", + key: "resourceName", + title: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + required: true, + }, + ]) + }) + yield* ctx.integration.transform((draft) => { + draft.method.update({ + integrationID: Provider.ID.azure, + method: { + type: "key", + label: "API key", + form, + }, + }) + }) yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { if (item.provider.id !== Provider.ID.azure && Provider.packageName(item.provider.package) !== "@ai-sdk/azure") diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index 6e42347115..a959a2cf7d 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -2,10 +2,53 @@ import os from "os" import { App } from "../../app" import { Effect, Option, Schema } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Form } from "@opencode-ai/schema/form" +import { Provider } from "../../provider" +import { iife } from "../../util/iife" +import { configuredSettings } from "./configured" + +const providerID = Provider.ID.make("cloudflare-ai-gateway") export const CloudflareAIGatewayPlugin = define({ id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { + const configured = yield* configuredSettings(providerID) + const form = iife(() => { + if (typeof configured?.baseURL === "string") return + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId") + const gatewayId = + process.env.CLOUDFLARE_GATEWAY_ID || + stringOption(configured ?? {}, "gatewayId") || + stringOption(configured ?? {}, "gateway") + if (accountId && gatewayId) return + const accountIdForm = Form.StringField.make({ + type: "string", + key: "accountId", + title: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + required: true, + }) + const gatewayIdForm = Form.StringField.make({ + type: "string", + key: "gatewayId", + title: "Enter your Cloudflare AI Gateway ID", + placeholder: "e.g. my-gateway", + required: true, + }) + if (accountId) return Form.Fields.make([gatewayIdForm]) + if (gatewayId) return Form.Fields.make([accountIdForm]) + return Form.Fields.make([accountIdForm, gatewayIdForm]) + }) + yield* ctx.integration.transform((draft) => { + draft.method.update({ + integrationID: providerID, + method: { + type: "key", + label: "Gateway API token", + form, + }, + }) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { @@ -46,7 +89,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) function gatewayConfig(options: Record): GatewayConfig | undefined { const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // Credential projection copies key metadata into options. The prompt stores the + // Credential projection copies key metadata into options. The form stores the // gateway as gatewayId, while older config examples may use gateway. const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 1ac31d77f9..0746a880c8 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -2,13 +2,39 @@ import os from "os" import { App } from "../../app" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Form } from "@opencode-ai/schema/form" import { Provider } from "../../provider" +import { iife } from "../../util/iife" +import { configuredSettings } from "./configured" const providerID = Provider.ID.make("cloudflare-workers-ai") export const CloudflareWorkersAIPlugin = define({ id: "opencode.provider.cloudflare-workers-ai", effect: Effect.fn(function* (ctx) { + const configured = yield* configuredSettings(providerID) + const form = iife(() => { + if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return + return Form.Fields.make([ + { + type: "string", + key: "accountId", + title: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + required: true, + }, + ]) + }) + yield* ctx.integration.transform((draft) => { + draft.method.update({ + integrationID: providerID, + method: { + type: "key", + label: "API key", + form, + }, + }) + }) yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(providerID) if (!item) return diff --git a/packages/core/src/plugin/provider/configured.ts b/packages/core/src/plugin/provider/configured.ts new file mode 100644 index 0000000000..fb21a9818b --- /dev/null +++ b/packages/core/src/plugin/provider/configured.ts @@ -0,0 +1,15 @@ +import { Effect, Option } from "effect" +import type { Document } from "@opencode-ai/schema/config" +import { Catalog } from "../../catalog" +import { Config } from "../../config" +import { Provider } from "../../provider" + +export const configuredSettings = Effect.fn("ProviderPlugin.configuredSettings")(function* (id: Provider.ID) { + const catalog = yield* Catalog.Service + const current = (yield* catalog.provider.get(id))?.settings + const service = yield* Effect.serviceOption(Config.Service) + const entries = Option.isSome(service) ? yield* service.value.entries() : [] + return entries + .filter((entry): entry is Document => entry.type === "document") + .reduce((settings, entry) => Provider.mergeOverlay(settings, entry.info.providers?.[id]?.settings), current) +}) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 3184f0f98c..1f4a38297a 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,5 +1,4 @@ import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" -import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot" import { Effect, Option, Schema, Semaphore, Stream } from "effect" import { Catalog } from "../../catalog" import { Credential } from "../../credential" @@ -47,30 +46,33 @@ const oauth = (app: App.Info) => id: methodID, type: "oauth", label: "Login with GitHub Copilot", - prompts: [ + form: [ { - type: "select", + type: "string", key: "deploymentType", - message: "Select GitHub deployment type", + title: "Select GitHub deployment type", + required: true, options: [ - { label: "GitHub.com", value: "github.com", hint: "Public" }, - { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" }, + { label: "GitHub.com", value: "github.com", description: "Public" }, + { label: "GitHub Enterprise", value: "enterprise", description: "Data residency or self-hosted" }, ], }, { - type: "text", + type: "string", key: "enterpriseUrl", - message: "Enter your GitHub Enterprise URL or domain", + title: "Enter your GitHub Enterprise URL or domain", placeholder: "company.ghe.com or https://company.ghe.com", - when: { key: "deploymentType", op: "eq", value: "enterprise" }, + required: true, + when: [{ key: "deploymentType", op: "eq", value: "enterprise" }], }, ], }, - authorize: (inputs) => + authorize: (answer) => Effect.gen(function* () { - const enterprise = inputs.deploymentType === "enterprise" - if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required")) - const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com" + const enterprise = answer.deploymentType === "enterprise" + const enterpriseUrl = typeof answer.enterpriseUrl === "string" ? answer.enterpriseUrl : undefined + if (enterprise && !enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required")) + const domain = enterprise ? normalizeDomain(enterpriseUrl ?? "") : "github.com" const urls = oauthURLs(domain) const device = yield* request(urls.device, { method: "POST", @@ -188,6 +190,7 @@ export const GithubCopilotPlugin = define({ }) yield* ctx.integration.transform((draft) => { + draft.method.remove("github-copilot", { type: "key" }) draft.method.update(oauth(ctx.app)) }) yield* ctx.catalog.transform((evt) => { @@ -226,26 +229,26 @@ export const GithubCopilotPlugin = define({ "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== Provider.ID.githubCopilot) return - if (evt.package !== "@ai-sdk/github-copilot" && evt.package !== "@ai-sdk/anthropic") return + if (evt.package !== "@ai-sdk/github-copilot") return evt.options.fetch = copilotFetch( typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined, evt.options.fetch, - evt.package === "@ai-sdk/anthropic", ctx.app, ) - if (evt.package === "@ai-sdk/anthropic") { - evt.options.headers = { - ...evt.options.headers, - "anthropic-beta": "interleaved-thinking-2025-05-14", - } - const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) - evt.sdk = mod.createAnthropic(evt.options) - return - } const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) + yield* ctx.session.hook("http.request", (evt) => + Effect.gen(function* () { + if (evt.model.providerID !== Provider.ID.githubCopilot) return + const token = evt.request.headers.get("x-api-key") + if (!token) return + const text = yield* Effect.promise(() => evt.request.clone().text()) + const body = Option.getOrUndefined(decodeBody(text)) + applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true) + }), + ) yield* ctx.aisdk.hook( "language", Effect.fn(function* (evt) { @@ -263,7 +266,9 @@ export const GithubCopilotPlugin = define({ return } const id = evt.model.modelID ?? evt.model.id - evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id) + const match = /^gpt-(\d+)/.exec(id) + evt.language = + match && Number(match[1]) >= 5 && !id.startsWith("gpt-5-mini") ? evt.sdk.responses(id) : evt.sdk.chat(id) }), ) }), @@ -312,34 +317,39 @@ function request(url: string, init: RequestInit) { type Fetch = (input: Parameters[0], init?: RequestInit) => Promise -export function copilotFetch( - token: string | undefined, - upstream: Fetch | undefined, - anthropic: boolean, - app: App.Info, -): Fetch { +export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch { const send = upstream ?? fetch return async (input, init) => { const requestHeaders = new Headers(init?.headers) - if (token) { - requestHeaders.delete("authorization") - requestHeaders.delete("x-api-key") - requestHeaders.set("Authorization", `Bearer ${token}`) - } - requestHeaders.set("User-Agent", App.useragent(app)) - requestHeaders.set("Openai-Intent", "conversation-edits") - requestHeaders.set("X-GitHub-Api-Version", apiVersion) - if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14") - const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined - const metadata = requestMetadata(url, body) - requestHeaders.set("x-initiator", metadata.agent ? "agent" : "user") - if (metadata.vision) requestHeaders.set("Copilot-Vision-Request", "true") + applyHeaders(requestHeaders, token, app, requestMetadata(url, body), false) return send(input, { ...init, headers: requestHeaders }) } } +function applyHeaders( + headers: Headers, + token: string | undefined, + app: App.Info, + metadata: RequestMetadata, + anthropic: boolean, +) { + if (token) { + headers.delete("authorization") + headers.delete("x-api-key") + headers.set("Authorization", `Bearer ${token}`) + } + headers.set("User-Agent", App.useragent(app)) + headers.set("Openai-Intent", "conversation-edits") + headers.set("X-GitHub-Api-Version", apiVersion) + headers.set("x-initiator", metadata.agent ? "agent" : "user") + if (metadata.vision) headers.set("Copilot-Vision-Request", "true") + if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14") +} + +type RequestMetadata = ReturnType + function requestMetadata(url: string, body: unknown) { if (!record(body)) return { agent: false, vision: false } if (Array.isArray(body.input)) { diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 75704a1c7b..2ca9e42a92 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -43,9 +43,9 @@ function oauth(http: HttpClient.HttpClient) { type: "oauth", label: "OpenCode Console account", }, - authorize: (inputs) => + authorize: (answer) => Effect.gen(function* () { - const server = yield* normalizeServer(inputs.server ?? defaultServer) + const server = yield* normalizeServer(answer.server ?? defaultServer) const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device) const verification = URL.canParse(device.verification_uri_complete) ? new URL(device.verification_uri_complete) @@ -226,9 +226,10 @@ function withoutCredentials(body: Readonly> | undefined) return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) } -function normalizeServer(input: string) { +function normalizeServer(input: unknown) { return Effect.try({ try: () => { + if (typeof input !== "string") throw new Error("expected string") const url = new URL(input) if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") return `${url.origin}${url.pathname.replace(/\/+$/, "")}` diff --git a/packages/core/test/github-copilot/openai-responses-language-model.test.ts b/packages/core/test/github-copilot/openai-responses-language-model.test.ts index ab047d04eb..71bb78f286 100644 --- a/packages/core/test/github-copilot/openai-responses-language-model.test.ts +++ b/packages/core/test/github-copilot/openai-responses-language-model.test.ts @@ -1,7 +1,7 @@ import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model" import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input" import { describe, test, expect, mock } from "bun:test" -import type { LanguageModelV3Prompt } from "@ai-sdk/provider" +import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider" const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] @@ -11,6 +11,16 @@ function createMockFetch(body: unknown) { ) } +function createStreamFetch(events: ReadonlyArray>) { + return mock( + async () => + new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) +} + function createModel(fetchFn: ReturnType) { return new OpenAIResponsesLanguageModel("test-model", { provider: "copilot", @@ -77,10 +87,173 @@ describe("doGenerate", () => { expect(providerMetadata?.copilot?.responseId).toBe("resp_1") expect(providerMetadata?.openai).toBeUndefined() }) + + test("defaults to stateless encrypted reasoning and keeps previousResponseId opt-in", async () => { + const requests: Array> = [] + const fetchFn = mock(async (_input: Parameters[0], init?: RequestInit) => { + requests.push(JSON.parse(init?.body as string)) + return new Response( + JSON.stringify({ + id: "resp_1", + created_at: 0, + model: "gpt-5.5", + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) + const model = createModel(fetchFn) + + await model.doGenerate({ prompt: TEST_PROMPT, includeRawChunks: false } as any) + await model.doGenerate({ + prompt: TEST_PROMPT, + includeRawChunks: false, + providerOptions: { copilot: { previousResponseId: "resp_previous", store: false } }, + } as any) + await model.doGenerate({ + prompt: TEST_PROMPT, + includeRawChunks: false, + providerOptions: { copilot: { store: true } }, + } as any) + + expect(requests[0]?.previous_response_id).toBeUndefined() + expect(requests[0]?.store).toBe(false) + expect(requests[0]?.include).toEqual(["reasoning.encrypted_content"]) + expect(requests[1]?.previous_response_id).toBe("resp_previous") + expect(requests[1]?.store).toBe(false) + expect(requests[1]?.include).toEqual(["reasoning.encrypted_content"]) + expect(requests[2]?.store).toBe(true) + expect(requests[2]?.include).toEqual(["reasoning.encrypted_content"]) + }) +}) + +describe("doStream", () => { + test("streams sequential Copilot reasoning summary blocks", async () => { + const model = createModel( + createStreamFetch([ + { + type: "response.output_item.added", + output_index: 0, + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "reasoning", id: "rs_rotated", encrypted_content: null }, + }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 }, + { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" }, + { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 }, + { + type: "response.output_item.done", + output_index: 0, + item: { type: "reasoning", id: "rs_rotated", encrypted_content: "encrypted-state" }, + }, + ]), + ) + const result = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + providerOptions: { copilot: { store: false } }, + } as any) + const reader = result.stream.getReader() + const events: LanguageModelV3StreamPart[] = [] + while (true) { + const item = await reader.read() + if (item.done) break + if (item.value.type.startsWith("reasoning-")) events.push(item.value) + } + + expect(events).toMatchObject([ + { + type: "reasoning-start", + id: "rs_1:0", + providerMetadata: { copilot: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:0", delta: "First" }, + { type: "reasoning-end", id: "rs_1:0", providerMetadata: { copilot: { itemId: "rs_1" } } }, + { + type: "reasoning-start", + id: "rs_1:1", + providerMetadata: { copilot: { itemId: "rs_1", reasoningEncryptedContent: null } }, + }, + { type: "reasoning-delta", id: "rs_1:1", delta: "Second" }, + { + type: "reasoning-end", + id: "rs_1:1", + providerMetadata: { copilot: { itemId: "rs_rotated", reasoningEncryptedContent: "encrypted-state" } }, + }, + ]) + + const deltas = new Map( + events.filter((event) => event.type === "reasoning-delta").map((event) => [event.id, event.delta] as const), + ) + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: events + .filter((event) => event.type === "reasoning-end") + .map((event) => ({ + type: "reasoning" as const, + text: deltas.get(event.id) ?? "", + providerOptions: event.providerMetadata, + })), + }, + ], + systemMessageMode: "system", + store: false, + }) + expect(input).toEqual([ + { + type: "reasoning", + id: "rs_rotated", + encrypted_content: "encrypted-state", + summary: [], + }, + ]) + }) + + test("closes reasoning when a Copilot stream ends before output_item.done", async () => { + const model = createModel( + createStreamFetch([ + { + type: "response.output_item.added", + output_index: 0, + item: { type: "reasoning", id: "rs_1", encrypted_content: null }, + }, + { type: "response.reasoning_summary_text.delta", item_id: "rs_rotated", summary_index: 0, delta: "First" }, + ]), + ) + const result = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + providerOptions: { copilot: { store: false } }, + } as any) + const reader = result.stream.getReader() + const events: LanguageModelV3StreamPart[] = [] + while (true) { + const item = await reader.read() + if (item.done) break + if (item.value.type.startsWith("reasoning-")) events.push(item.value) + } + + expect(events.map((event) => event.type)).toEqual(["reasoning-start", "reasoning-delta", "reasoning-end"]) + expect(events.at(-1)).toMatchObject({ + type: "reasoning-end", + id: "rs_1:0", + providerMetadata: { copilot: { itemId: "rs_1" } }, + }) + }) }) describe("convertToOpenAIResponsesInput", () => { - test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => { + test("omits response item IDs from stateless function calls", async () => { const { input } = await convertToOpenAIResponsesInput({ prompt: [ { @@ -106,12 +279,11 @@ describe("convertToOpenAIResponsesInput", () => { call_id: "call_1", name: "bash", arguments: JSON.stringify({ command: "ls" }), - id: "fc_999", }, ]) }) - test("omits the function_call id once the stale copilot itemId has been stripped", async () => { + test("preserves response item IDs for stored function calls", async () => { const { input } = await convertToOpenAIResponsesInput({ prompt: [ { @@ -122,16 +294,16 @@ describe("convertToOpenAIResponsesInput", () => { toolCallId: "call_1", toolName: "bash", input: { command: "ls" }, - providerOptions: {}, + providerOptions: { copilot: { itemId: "fc_999" } }, }, ], }, ], systemMessageMode: "system", - store: false, + store: true, }) - expect((input[0] as any).id).toBeUndefined() + expect((input[0] as any).id).toBe("fc_999") }) test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => { @@ -158,12 +330,34 @@ describe("convertToOpenAIResponsesInput", () => { type: "reasoning", id: "rs_1", encrypted_content: "enc_1", - summary: [{ type: "summary_text", text: "thinking..." }], + summary: [], }, ]) }) - test("drops reasoning items with no copilot itemId and warns, as before", async () => { + test("drops encrypted reasoning with no completed copilot itemId", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { copilot: { reasoningEncryptedContent: "enc_1" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([]) + expect(warnings).toHaveLength(1) + }) + + test("drops reasoning with neither a copilot itemId nor encrypted content", async () => { const { input, warnings } = await convertToOpenAIResponsesInput({ prompt: [ { diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index a4553780a6..8597c9bbdb 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -140,7 +140,11 @@ describe("Integration", () => { yield* integrations.transform((editor) => editor.method.update({ integrationID, - method: { type: "key", label: "API key" }, + method: { + type: "key", + label: "API key", + form: [{ type: "string", key: "accountId", title: "Account ID", required: true }], + }, }), ) const updated = yield* bus @@ -148,9 +152,17 @@ describe("Integration", () => { .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow + expect( + yield* integrations.connection.key({ integrationID, key: "secret" }).pipe( + Effect.flip, + Effect.map((error) => error.cause), + ), + ).toEqual(expect.objectContaining({ message: "Missing required form field: accountId" })) + yield* integrations.connection.key({ integrationID, key: "secret", + answer: { accountId: "account" }, label: "Work", }) @@ -158,7 +170,7 @@ describe("Integration", () => { expect.objectContaining({ integrationID, label: "Work", - value: Credential.Key.make({ type: "key", key: "secret" }), + value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }), }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) @@ -243,7 +255,6 @@ describe("Integration", () => { const attempt = yield* integrations.oauth.connect({ integrationID, methodID, - inputs: {}, label: "Personal", }) expect(attempt.mode).toBe("code") @@ -289,7 +300,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) expect( yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.flip), ).toBeInstanceOf(Integration.CodeRequiredError) @@ -327,7 +338,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) yield* Effect.yieldNow expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "complete", @@ -365,7 +376,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) const exit = yield* integrations.oauth .complete({ integrationID, attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit) @@ -401,7 +412,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) yield* TestClock.adjust(Duration.minutes(10)) yield* Effect.yieldNow @@ -442,7 +453,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) expect(attempt.time).toEqual({ created, expires: expiresAt }) }) }) diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 66ad981815..2c3a3d6af1 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -736,7 +736,11 @@ describe("ModelResolver", () => { headers: { "x-aisdk": "header" }, body: { custom: true }, }), - Credential.Key.make({ type: "key", key: "fallback-secret" }), + Credential.Key.make({ + type: "key", + key: "fallback-secret", + configuration: { accountId: "account" }, + }), { loadAISDK: (runtime) => Effect.sync(() => { @@ -745,7 +749,7 @@ describe("ModelResolver", () => { modelID: "mistral-api-model", providerID: "test-provider", package: Provider.aisdk("@ai-sdk/mistral"), - settings: { project: "test", apiKey: "fallback-secret" }, + settings: { project: "test", apiKey: "fallback-secret", accountId: "account" }, headers: { "x-aisdk": "header" }, body: { custom: true }, }) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index f35beaa1ec..18ea39d3c3 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,5 +1,5 @@ import { Plugin } from "@opencode-ai/plugin/effect" -import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration" +import type { IntegrationMethod, IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration" import { Agent } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -15,7 +15,6 @@ import { Effect, Stream } from "effect" type Overrides = Partial> & { readonly session?: Partial } - export function host(overrides: Overrides = {}): Plugin.Context { return { app: overrides.app ?? { name: "test", version: "test", channel: "test" }, @@ -278,7 +277,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont update: (id, update) => draft.update(Integration.ID.make(id), update), remove: (id) => draft.remove(Integration.ID.make(id)), method: { - list: (id) => draft.method.list(Integration.ID.make(id)).map(method), + list: (id) => draft.method.list(Integration.ID.make(id)), update: (input) => { if ("authorize" in input) { const methodID = Integration.MethodID.make(input.method.id) @@ -286,8 +285,8 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont draft.method.update({ integrationID: Integration.ID.make(input.integrationID), method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( + authorize: (answer) => + input.authorize(answer).pipe( Effect.map((authorization) => { if (authorization.mode === "auto") { return { @@ -336,7 +335,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont if (input.method.type === "env") { draft.method.update({ integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, names: [...input.method.names] }, + method: input.method, }) return } @@ -346,7 +345,6 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont method: { ...input.method, id: Integration.MethodID.make(input.method.id), - command: [...input.method.command], }, }) return @@ -401,35 +399,11 @@ function oauthCredential(value: Credential.OAuth) { return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) }) } -function method(value: Integration.Method) { - if (value.type === "env") return { type: value.type, names: [...value.names] } - if (value.type === "key") return { type: value.type, label: value.label } - if (value.type === "command") return { ...value, command: [...value.command] } - return { - type: value.type, - id: value.id, - label: value.label, - prompts: value.prompts?.map((prompt) => { - if (prompt.type === "text") return { ...prompt } - return { ...prompt, options: prompt.options.map((option) => ({ ...option })) } - }), - } -} - -function internalMethod(value: IntegrationMethodRegistration["method"]): Integration.Method { - if (value.type === "env") return value - if (value.type === "key") return value - if (value.type === "command") { - return { - ...value, - id: Integration.MethodID.make(value.id), - command: [...value.command], - } - } - return { - ...value, - id: Integration.MethodID.make(value.id), +function internalMethod(value: IntegrationMethod): Integration.Method { + if (value.type === "oauth" || value.type === "command") { + return { ...value, id: Integration.MethodID.make(value.id) } } + return value } function agentInfo(value: Agent.Info) { diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 266f6bcc94..ce8718e6c8 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -8,6 +8,7 @@ import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { Provider } from "@opencode-ai/core/provider" +import { Integration } from "@opencode-ai/core/integration" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -60,6 +61,27 @@ function fakeSelectorSdk(calls: string[]) { } describe("AzurePlugin", () => { + it.effect("registers a resource name form when the environment does not provide one", () => + withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("azure")))?.methods).toContainEqual({ + type: "key", + label: "API key", + form: [ + { + type: "string", + key: "resourceName", + title: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + required: true, + }, + ], + }) + }), + ), + ) + it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { @@ -195,7 +217,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* Plugin.Service const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => + catalog.provider.update(Provider.ID.azure, (provider) => { + provider.settings = { ...provider.settings, baseURL: "https://proxy.example.com/openai" } + }), + ) yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("azure")))?.methods).toContainEqual({ + type: "key", + label: "API key", + }) const result = yield* aisdk.runSDK({ model: Model.Info.make({ ...Model.Info.default(Provider.ID.azure, Model.ID.make("deployment")), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 2dab4d93ba..9eb5fbba69 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,11 +1,13 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" import { Model } from "@opencode-ai/core/model" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" import { Provider } from "@opencode-ai/core/provider" +import { Integration } from "@opencode-ai/core/integration" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -102,6 +104,24 @@ mock.module("ai-gateway-provider/providers/unified", () => ({ })) describe("CloudflareAIGatewayPlugin", () => { + it.effect("registers account and gateway forms when the environment does not provide them", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () => + Effect.gen(function* () { + yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods, + ).toContainEqual({ + type: "key", + label: "Gateway API token", + form: [ + expect.objectContaining({ type: "string", key: "accountId", required: true }), + expect.objectContaining({ type: "string", key: "gatewayId", required: true }), + ], + }) + }), + ), + ) + it.effect("requires account, gateway, and token before creating the unified SDK", () => withEnv( { @@ -357,7 +377,16 @@ describe("CloudflareAIGatewayPlugin", () => { resetCalls() const plugin = yield* Plugin.Service const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => + catalog.provider.update(Provider.ID.make("cloudflare-ai-gateway"), (provider) => { + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } + }), + ) yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods, + ).toContainEqual({ type: "key", label: "Gateway API token" }) const result = yield* aisdk.runSDK({ model: Model.Info.make({ diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 7179786dc7..533ba82924 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -7,6 +7,7 @@ import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { Provider } from "@opencode-ai/core/provider" +import { Integration } from "@opencode-ai/core/integration" import type { LanguageModelV3 } from "@ai-sdk/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -79,6 +80,29 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") { } describe("CloudflareWorkersAIPlugin", () => { + it.effect("registers an account form when the environment does not provide one", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () => + Effect.gen(function* () { + yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods, + ).toContainEqual({ + type: "key", + label: "API key", + form: [ + { + type: "string", + key: "accountId", + title: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + required: true, + }, + ], + }) + }), + ), + ) + it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { @@ -91,6 +115,9 @@ describe("CloudflareWorkersAIPlugin", () => { }), ) yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods, + ).toContainEqual({ type: "key", label: "API key" }) const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai"))) const sdk = yield* aisdk.runSDK({ model: Model.Info.make({ @@ -135,7 +162,16 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* Plugin.Service const aisdk = yield* AISDK.Service + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => + catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { + provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" } + }), + ) yield* addPlugin() + expect( + (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods, + ).toContainEqual({ type: "key", label: "API key" }) const result = yield* aisdk.runSDK({ model: Model.Info.make({ ...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")), diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 0f70aa628b..ee01993c2b 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,11 +1,14 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { App } from "@opencode-ai/core/app" +import { Agent } from "@opencode-ai/schema/agent" +import { Session } from "@opencode-ai/schema/session" import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Model } from "@opencode-ai/core/model" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { Provider } from "@opencode-ai/core/provider" import { Integration } from "@opencode-ai/core/integration" @@ -57,11 +60,37 @@ describe("GithubCopilotPlugin", () => { id: Integration.MethodID.make("device"), type: "oauth", label: "Login with GitHub Copilot", - prompts: expect.any(Array), + form: expect.any(Array), }) }), ) + it.effect("removes the generic key method", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* integrations.transform((draft) => { + draft.method.update({ + integrationID: Integration.ID.make("github-copilot"), + method: { type: "key" }, + }) + draft.method.update({ + integrationID: Integration.ID.make("github-copilot"), + method: { type: "env", names: ["GITHUB_TOKEN"] }, + }) + }) + yield* addPlugin() + expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toEqual([ + { type: "env", names: ["GITHUB_TOKEN"] }, + { + id: Integration.MethodID.make("device"), + type: "oauth", + label: "Login with GitHub Copilot", + form: expect.any(Array), + }, + ]) + }), + ) + it.live("adds Copilot authentication and request metadata headers", () => Effect.gen(function* () { const requests: Headers[] = [] @@ -71,7 +100,6 @@ describe("GithubCopilotPlugin", () => { requests.push(new Headers(init?.headers)) return Response.json({ ok: true }) }, - false, App.make({ name: "test", version: "1.2.3", channel: "beta" }), ) yield* Effect.promise(() => @@ -92,6 +120,27 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("adds Copilot authentication to native Anthropic requests", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { + sessionID: Session.ID.make("ses_test"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }), + request: new Request("https://api.githubcopilot.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "token" }, + body: JSON.stringify({ messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }), + }), + }) + expect(event.request.headers.get("authorization")).toBe("Bearer token") + expect(event.request.headers.has("x-api-key")).toBe(false) + expect(event.request.headers.get("x-initiator")).toBe("user") + expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14") + expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01") + }), + ) + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* Plugin.Service diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index e8f8323dbf..7762094fd3 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -128,7 +128,7 @@ describe("OpencodePlugin", () => { const attempt = yield* integrations.oauth.connect({ integrationID, methodID: Integration.MethodID.make("device"), - inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` }, + answer: { server: `${server.url.origin}/console///?ignored=true#ignored` }, }) expect(attempt.url).toBe(`${server.url.origin}/verify`) yield* eventually( @@ -155,7 +155,7 @@ describe("OpencodePlugin", () => { .connect({ integrationID: Integration.ID.make("opencode"), methodID: Integration.MethodID.make("device"), - inputs: { server: "ftp://console.example.com" }, + answer: { server: "ftp://console.example.com" }, }) .pipe(Effect.flip) expect(error).toBeInstanceOf(Integration.AuthorizationError) @@ -163,6 +163,21 @@ describe("OpencodePlugin", () => { }), ) + it.effect("rejects non-string OpenCode servers", () => + Effect.gen(function* () { + yield* addPlugin() + const error = yield* (yield* Integration.Service).oauth + .connect({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + answer: { server: true }, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Integration.AuthorizationError) + expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected string") + }), + ) + it.live("loads providers and models from the connected OpenCode server", () => Effect.acquireUseRelease( Effect.sync(() => { diff --git a/packages/core/test/plugin/websearch.test.ts b/packages/core/test/plugin/websearch.test.ts index a45785e40d..9f43a066fe 100644 --- a/packages/core/test/plugin/websearch.test.ts +++ b/packages/core/test/plugin/websearch.test.ts @@ -129,7 +129,10 @@ describe("built-in web search providers", () => { yield* WebSearchParallel.Plugin.effect( host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), ) - yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" }) + yield* integrations.connection.key({ + integrationID: Integration.ID.make("parallel"), + key: "parallel-secret", + }) const output = yield* websearch.query({ query: "effect layers", diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index ac57342e8d..508fba49a2 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -90,15 +90,10 @@ test("Core reuses the canonical shared schemas", async () => { [coreFileSystem.Match, FileSystem.Match], [coreIntegration.ID, Integration.ID], [coreIntegration.MethodID, Integration.MethodID], - [coreIntegration.When, Integration.When], - [coreIntegration.TextPrompt, Integration.TextPrompt], - [coreIntegration.SelectPrompt, Integration.SelectPrompt], - [coreIntegration.Prompt, Integration.Prompt], [coreIntegration.OAuthMethod, Integration.OAuthMethod], [coreIntegration.KeyMethod, Integration.KeyMethod], [coreIntegration.EnvMethod, Integration.EnvMethod], [coreIntegration.Method, Integration.Method], - [coreIntegration.Inputs, Integration.Inputs], [coreIntegration.Ref, Integration.Ref], [coreLocation.Ref, Location.Ref], [coreAI.ProviderMetadata, AI.ProviderMetadata], diff --git a/packages/plugin/src/effect/integration.ts b/packages/plugin/src/effect/integration.ts index d4c9c76b27..c56dfd8467 100644 --- a/packages/plugin/src/effect/integration.ts +++ b/packages/plugin/src/effect/integration.ts @@ -1,19 +1,43 @@ -import type { - ConnectionInfo, - IntegrationCommandMethod, - IntegrationEnvMethod, - IntegrationKeyMethod, - IntegrationMethod, - IntegrationOAuthMethod, -} from "@opencode-ai/client" +import type { ConnectionInfo } from "@opencode-ai/client" import type { IntegrationApi } from "@opencode-ai/client/effect/api" import { Credential } from "@opencode-ai/schema/credential" +import { Form } from "@opencode-ai/schema/form" import type { Effect, Scope } from "effect" import type { Transform } from "./registration.js" -type IntegrationInputs = Record type IntegrationRef = { id: string; name: string } +export interface IntegrationOAuthMethod { + readonly id: string + readonly type: "oauth" + readonly label: string + readonly form?: Form.Fields +} + +export interface IntegrationCommandMethod { + readonly id: string + readonly type: "command" + readonly label: string + readonly command: ReadonlyArray +} + +export interface IntegrationKeyMethod { + readonly type: "key" + readonly label?: string + readonly form?: Form.Fields +} + +export interface IntegrationEnvMethod { + readonly type: "env" + readonly names: ReadonlyArray +} + +export type IntegrationMethod = + | IntegrationOAuthMethod + | IntegrationCommandMethod + | IntegrationKeyMethod + | IntegrationEnvMethod + export type IntegrationOAuthAuthorization = { readonly url: string readonly instructions: string @@ -31,7 +55,7 @@ export type IntegrationOAuthAuthorization = { export type IntegrationOAuthMethodRegistration = { readonly integrationID: string readonly method: IntegrationOAuthMethod - readonly authorize: (inputs: IntegrationInputs) => Effect.Effect + readonly authorize: (answer: Form.Answer) => Effect.Effect readonly refresh?: (credential: Credential.OAuth) => Effect.Effect readonly label?: (credential: Credential.OAuth) => string | undefined } diff --git a/packages/plugin/src/promise/integration.ts b/packages/plugin/src/promise/integration.ts index 8e598fc8dc..733b9fe2c8 100644 --- a/packages/plugin/src/promise/integration.ts +++ b/packages/plugin/src/promise/integration.ts @@ -1,18 +1,42 @@ -import type { - ConnectionInfo, - IntegrationCommandMethod, - IntegrationEnvMethod, - IntegrationKeyMethod, - IntegrationMethod, - IntegrationOAuthMethod, -} from "@opencode-ai/client" +import type { ConnectionInfo } from "@opencode-ai/client" import type { IntegrationApi } from "@opencode-ai/client/promise/api" import { Credential } from "@opencode-ai/schema/credential" +import { Form } from "@opencode-ai/schema/form" import type { Transform } from "./registration.js" -type IntegrationInputs = Record type IntegrationRef = { id: string; name: string } +export interface IntegrationOAuthMethod { + readonly id: string + readonly type: "oauth" + readonly label: string + readonly form?: Form.Fields +} + +export interface IntegrationCommandMethod { + readonly id: string + readonly type: "command" + readonly label: string + readonly command: ReadonlyArray +} + +export interface IntegrationKeyMethod { + readonly type: "key" + readonly label?: string + readonly form?: Form.Fields +} + +export interface IntegrationEnvMethod { + readonly type: "env" + readonly names: ReadonlyArray +} + +export type IntegrationMethod = + | IntegrationOAuthMethod + | IntegrationCommandMethod + | IntegrationKeyMethod + | IntegrationEnvMethod + export type IntegrationOAuthAuthorization = { readonly url: string readonly instructions: string @@ -31,7 +55,7 @@ export type IntegrationOAuthAuthorization = { export type IntegrationOAuthMethodRegistration = { readonly integrationID: string readonly method: IntegrationOAuthMethod - readonly authorize: (inputs: IntegrationInputs) => Promise + readonly authorize: (answer: Form.Answer) => Promise readonly refresh?: (credential: Credential.OAuth) => Promise readonly label?: (credential: Credential.OAuth) => string | undefined } @@ -39,7 +63,10 @@ export type IntegrationOAuthMethodRegistration = { export type IntegrationMethodRegistration = | IntegrationOAuthMethodRegistration | { readonly integrationID: string; readonly method: IntegrationCommandMethod } - | { readonly integrationID: string; readonly method: IntegrationKeyMethod } + | { + readonly integrationID: string + readonly method: IntegrationKeyMethod + } | { readonly integrationID: string; readonly method: IntegrationEnvMethod } export interface IntegrationDraft { diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index 9d88e002da..2a40425dc8 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -1,12 +1,11 @@ import { Integration } from "@opencode-ai/schema/integration" import { Location } from "@opencode-ai/schema/location" +import { Form } from "@opencode-ai/schema/form" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { InvalidRequestError } from "../errors.js" import { LocationQuery, locationQueryOpenApi } from "./location.js" -const Inputs = Schema.Record(Schema.String, Schema.String) - export const IntegrationGroup = HttpApiGroup.make("server.integration") .add( HttpApiEndpoint.get("integration.list", "/api/integration", { @@ -59,6 +58,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") query: LocationQuery, payload: Schema.Struct({ key: Schema.String, + answer: Schema.optional(Form.Answer), label: Schema.optional(Schema.String), }), success: HttpApiSchema.NoContent, @@ -79,7 +79,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") query: LocationQuery, payload: Schema.Struct({ methodID: Integration.MethodID, - inputs: Inputs, + answer: Schema.optional(Form.Answer), label: Schema.optional(Schema.String), }), success: Location.response(Integration.Attempt), diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index 0d2892020e..0c4fb8d55f 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -5,6 +5,7 @@ import { optional } from "./schema.js" import { IntegrationMethodID } from "./integration-id.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" +import { Form } from "./form.js" export const ID = Schema.String.pipe( Schema.brand("Credential.ID"), @@ -27,6 +28,7 @@ export const Key = Schema.Struct({ type: Schema.Literal("key"), key: Schema.String, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + configuration: optional(Form.Answer), }).annotate({ identifier: "Credential.Key" }) export const Value = Schema.Union([OAuth, Key]) diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 0e573c4313..c64d6d37c0 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -7,6 +7,7 @@ import { Connection } from "./connection.js" import { ascending } from "./identifier.js" import { statics } from "./schema.js" import { IntegrationID, IntegrationMethodID } from "./integration-id.js" +import { Form } from "./form.js" export const ID = IntegrationID export type ID = typeof ID.Type @@ -14,46 +15,12 @@ export type ID = typeof ID.Type export const MethodID = IntegrationMethodID export type MethodID = typeof MethodID.Type -export interface When extends Schema.Schema.Type {} -export const When = Schema.Struct({ - key: Schema.String, - op: Schema.Literals(["eq", "neq"]), - value: Schema.String, -}).annotate({ identifier: "Integration.When" }) - -export interface TextPrompt extends Schema.Schema.Type {} -export const TextPrompt = Schema.Struct({ - type: Schema.Literal("text"), - key: Schema.String, - message: Schema.String, - placeholder: optional(Schema.String), - when: optional(When), -}).annotate({ identifier: "Integration.TextPrompt" }) - -export interface SelectPrompt extends Schema.Schema.Type {} -export const SelectPrompt = Schema.Struct({ - type: Schema.Literal("select"), - key: Schema.String, - message: Schema.String, - options: Schema.Array( - Schema.Struct({ - label: Schema.String, - value: Schema.String, - hint: optional(Schema.String), - }), - ), - when: optional(When), -}).annotate({ identifier: "Integration.SelectPrompt" }) - -export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) -export type Prompt = typeof Prompt.Type - export interface OAuthMethod extends Schema.Schema.Type {} export const OAuthMethod = Schema.Struct({ id: MethodID, type: Schema.Literal("oauth"), label: Schema.String, - prompts: optional(Schema.Array(Prompt)), + form: optional(Form.Fields), }).annotate({ identifier: "Integration.OAuthMethod" }) export interface CommandMethod extends Schema.Schema.Type {} @@ -68,6 +35,7 @@ export interface KeyMethod extends Schema.Schema.Type {} export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), label: optional(Schema.String), + form: optional(Form.Fields), }).annotate({ identifier: "Integration.KeyMethod" }) export interface EnvMethod extends Schema.Schema.Type {} @@ -81,9 +49,6 @@ export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMe .annotate({ identifier: "Integration.Method" }) export type Method = typeof Method.Type -export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" }) -export type Inputs = typeof Inputs.Type - const Updated = ephemeral({ type: "integration.updated", schema: {}, diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index a9cd73fa83..0fa99955de 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -58,6 +58,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" service.connection.key({ integrationID: ctx.params.integrationID, key: ctx.payload.key, + answer: ctx.payload.answer, label: ctx.payload.label, }), ) @@ -73,7 +74,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" service.oauth.connect({ integrationID: ctx.params.integrationID, methodID: ctx.payload.methodID, - inputs: ctx.payload.inputs, + answer: ctx.payload.answer, label: ctx.payload.label, }), ), diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index b4be709f24..30c81e50ce 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -5,6 +5,10 @@ import type { IntegrationInfo, IntegrationOauthConnectOutput, IntegrationOAuthMethod, + FormAnswer, + FormField, + FormFields, + FormValue, } from "@opencode-ai/client" import open from "open" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" @@ -18,6 +22,7 @@ import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" import { Link } from "../ui/link" import { useToast } from "../ui/toast" +import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form" const INTEGRATION_PRIORITY: Record = { opencode: 0, @@ -32,6 +37,10 @@ type ConnectMethod = Exclude void +const CANCELLED = Symbol("cancelled") +const CUSTOM = Symbol("custom") +const OPEN = Symbol("open") +const SUBMIT = Symbol("submit") export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -181,7 +190,7 @@ function openMethod( onConnected?: OnIntegrationConnected, ) { if (method.type === "key") { - dialog.replace(() => ) + void beginKey(integration, method, dialog, onConnected) return } if (method.type === "command") { @@ -191,6 +200,21 @@ function openMethod( void beginOAuth(integration, method, dialog, onConnected) } +async function beginKey( + integration: IntegrationInfo, + method: Extract, + dialog: ReturnType, + onConnected?: OnIntegrationConnected, +) { + const answer = method.form + ? await formAnswer(dialog, method.label ?? `Connect ${integration.name}`, method.form) + : undefined + if (answer === null) return + dialog.replace(() => ( + + )) +} + function CommandStarting(props: { integration: IntegrationInfo method: Extract @@ -336,6 +360,7 @@ function CommandView(props: { title: string; output: string; message: string }) function KeyMethod(props: { integration: IntegrationInfo method: Extract + answer?: FormAnswer onConnected?: OnIntegrationConnected }) { const data = useData() @@ -356,6 +381,7 @@ function KeyMethod(props: { integrationID: props.integration.id, location: location(data), key, + ...(props.answer ? { answer: props.answer } : {}), }) .then(() => connected(props.integration, data, dialog, toast, props.onConnected)) .catch((cause) => setError(message(cause))) @@ -373,17 +399,17 @@ async function beginOAuth( dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { - const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} - if (inputs === null) return + const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined + if (answer === null) return dialog.replace(() => ( - + )) } function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod - inputs: Record + answer?: FormAnswer onConnected?: OnIntegrationConnected }) { const data = useData() @@ -397,7 +423,7 @@ function OAuthStarting(props: { integrationID: props.integration.id, location: location(data), methodID: props.method.id, - inputs: props.inputs, + ...(props.answer ? { answer: props.answer } : {}), }) .then((result) => { if (result.data.mode === "code") { @@ -621,49 +647,234 @@ function OAuthView(props: { ) } -async function promptInputs( +async function formAnswer(dialog: ReturnType, title: string, fields: FormFields) { + const answer: FormAnswer = {} + for (const field of fields) { + if (!active(field, answer)) continue + const value = await fieldAnswer(dialog, title, field) + if (value === CANCELLED) return null + if (value !== undefined) answer[field.key] = value + } + return answer +} + +function active(field: FormField, answer: FormAnswer) { + if (field.type === "external" || !field.when) return true + return field.when.every((when) => { + const value = answer[when.key] + if (value === undefined) return false + const hit = Array.isArray(value) ? value.includes(String(when.value)) : value === when.value + return when.op === "eq" ? hit : !hit + }) +} + +function fieldAnswer( dialog: ReturnType, - prompts: NonNullable, -) { - const inputs: Record = {} - for (const prompt of prompts) { - if (prompt.when) { - const value = inputs[prompt.when.key] - if (value === undefined) continue - const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value - if (!matches) continue - } - if (prompt.type === "select") { - const value = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: option.label, - value: option.value, - description: option.hint, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), + title: string, + field: FormField, +): Promise { + if (field.type === "external") return externalAnswer(dialog, title, field) + if (field.type === "multiselect") return multiselectAnswer(dialog, title, field) + if (field.type === "boolean" || (field.type === "string" && field.options)) { + return selectAnswer(dialog, title, field) + } + return textAnswer(dialog, title, field) +} + +async function selectAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + const options = + field.type === "boolean" + ? field.default === false + ? [ + { title: "No", value: false as FormValue }, + { title: "Yes", value: true as FormValue }, + ] + : [ + { title: "Yes", value: true as FormValue }, + { title: "No", value: false as FormValue }, + ] + : (field.options ?? []).map((option) => ({ + title: option.label, + value: option.value as FormValue, + description: option.description, + })) + const choice = await new Promise((resolve) => { + dialog.replace( + () => ( + + title={formLabel(field) || title} + options={[ + ...options, + ...(field.type === "string" && field.custom + ? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }] + : []), + ...(!field.required ? [{ title: "Skip", value: undefined }] : []), + ]} + current={field.type === "string" ? field.default : undefined} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(CANCELLED), + ) + }) + if (choice === CUSTOM) { + if (field.type !== "string") return CANCELLED + return textAnswer(dialog, title, field, "") + } + return choice +} + +function textAnswer( + dialog: ReturnType, + title: string, + field: Extract, + initial = field.default === undefined ? undefined : String(field.default), +): Promise { + return new Promise((resolve) => { + dialog.replace( + () => { + const theme = useTheme("elevated") + const [error, setError] = createSignal() + return ( + { + const text = input.trim() + const value = text === "" && !field.required ? undefined : field.type === "string" ? text : Number(text) + const invalid = formValidateValue(field, value) + if (invalid) { + setError(invalid) + return + } + resolve(value) + }} + description={() => ( + + + {(description) => {description()}} + + {(value) => {value()}} + + )} + /> ) - }) - if (value === null) return null - inputs[prompt.key] = value - continue - } - const value = await new Promise((resolve) => { + }, + () => resolve(CANCELLED), + ) + }) +} + +async function multiselectAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + const selected = field.default ? [...field.default] : [] + while (true) { + const invalid = formValidateValue(field, selected) + const choice = await new Promise((resolve) => { dialog.replace( - () => , - () => resolve(null), + () => ( + + title={formLabel(field) || title} + options={[ + ...field.options.map((option) => ({ + title: `[${selected.includes(option.value) ? "x" : " "}] ${option.label}`, + value: option.value, + description: option.description, + disabled: + !selected.includes(option.value) && field.maxItems !== undefined && selected.length >= field.maxItems, + })), + ...(field.custom ? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }] : []), + { + title: "Continue", + value: SUBMIT as typeof SUBMIT, + description: invalid, + disabled: invalid !== undefined, + }, + ]} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(CANCELLED), ) }) - if (value === null) return null - inputs[prompt.key] = value + if (choice === CANCELLED) return CANCELLED + if (choice === SUBMIT) return selected + if (choice === CUSTOM) { + const value = await customAnswer(dialog, title, field) + if (value === CANCELLED) return CANCELLED + if (value && !selected.includes(value)) selected.push(value) + continue + } + selected.splice(0, selected.length, ...formToggleMultiselect(selected, choice)) + } +} + +function customAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + return new Promise((resolve) => { + dialog.replace( + () => ( + { + if (value) resolve(value) + }} + /> + ), + () => resolve(CANCELLED), + ) + }) +} + +async function externalAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + let opened = false + while (true) { + const choice = await new Promise((resolve) => { + dialog.replace( + () => ( + + title={formLabel(field) || title} + options={[ + { title: opened ? "Open link again" : "Open link", value: OPEN as typeof OPEN, description: field.url }, + { title: "I finished", value: true as const, description: field.description, disabled: !opened }, + ]} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(CANCELLED), + ) + }) + if (choice === CANCELLED) return CANCELLED + if (choice === true) return true + const result = await new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(CANCELLED), + ) + void open(field.url).then( + () => resolve(true), + () => resolve(false), + ) + }) + if (result === CANCELLED) return CANCELLED + opened ||= result } - return inputs } async function connected(