diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 999f09bc514b..6cf92501c3a5 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -37,6 +37,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime enabled: bool("OPENCODE_ENABLE_PARALLEL"), legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"), }).pipe(Config.map((flags) => flags.enabled || flags.legacy)), + enableIflow: bool("OPENCODE_ENABLE_IFLOW"), enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"), diff --git a/packages/opencode/src/tool/iflow-client.ts b/packages/opencode/src/tool/iflow-client.ts new file mode 100644 index 000000000000..f6291526aa4f --- /dev/null +++ b/packages/opencode/src/tool/iflow-client.ts @@ -0,0 +1,97 @@ +import { Duration, Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" + +export const IFLOW_DEFAULT_BASE_URL = "https://platform.iflow.cn" + +export function iflowBaseURL() { + return (process.env.IFLOW_BASE_URL || IFLOW_DEFAULT_BASE_URL).replace(/\/+$/, "") +} + +export function requireIflowAPIKey(message: string) { + const key = process.env.IFLOW_API_KEY + if (!key) throw new Error(message) + return key +} + +export function iflowURL(path: string) { + return `${iflowBaseURL()}${path.startsWith("/") ? path : `/${path}`}` +} + +export const postJson = ( + http: HttpClient.HttpClient, + path: string, + body: unknown, + missingKeyMessage: string, + timeout: Duration.Input, +) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.post(iflowURL(path)).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${requireIflowAPIKey(missingKeyMessage)}`, + "Content-Type": "application/json", + Accept: "application/json", + }), + HttpClientRequest.bodyJson(body), + ) + + const response = yield* http + .execute(request) + .pipe(Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("iFlow request timed out")) })) + + const text = yield* response.text + + if (response.status === 401 || response.status === 403) { + throw new Error("iFlow request was not authorized. Check IFLOW_API_KEY.") + } + if (response.status === 429) { + throw new Error("iFlow rate limit exceeded. Please try again later.") + } + if (response.status >= 500) { + throw new Error(`iFlow service error (${response.status}). Please try again later.`) + } + if (response.status < 200 || response.status >= 300) { + throw new Error(`iFlow request failed with status ${response.status}.`) + } + + const data = yield* Effect.try({ + try: () => JSON.parse(text), + catch: () => new Error("iFlow returned invalid JSON."), + }) + const object = objectValue(data) + if (!object) throw new Error("iFlow returned invalid JSON.") + const businessError = getBusinessError(object) + if (businessError) throw new Error(businessError) + return object + }) + +function getBusinessError(data: Record) { + if (data.success === false) return `iFlow request failed${formatMessage(data)}.` + const code = stringValue(data.code) ?? stringValue(data.status) + if (code && !["0", "200", "success", "ok"].includes(code.toLowerCase())) { + return `iFlow request failed${formatMessage(data)}.` + } + return undefined +} + +function formatMessage(data: Record) { + const message = stringValue(data.message) ?? stringValue(data.msg) ?? stringValue(data.error) + return message ? `: ${message}` : "" +} + +export function stringValue(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +export function numberValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +export function objectValue(value: unknown) { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined +} + +export function arrayValue(value: unknown) { + return Array.isArray(value) ? value : undefined +} diff --git a/packages/opencode/src/tool/iflow-fetch.ts b/packages/opencode/src/tool/iflow-fetch.ts new file mode 100644 index 000000000000..6da77d95c3a0 --- /dev/null +++ b/packages/opencode/src/tool/iflow-fetch.ts @@ -0,0 +1,54 @@ +import { Effect } from "effect" +import { HttpClient } from "effect/unstable/http" +import { objectValue, postJson, stringValue } from "./iflow-client" + +export const IFLOW_FETCH_MISSING_KEY = "IFLOW_API_KEY is required when OPENCODE_WEBFETCH_PROVIDER=iflow." + +export const fetch = (http: HttpClient.HttpClient, params: { url: string }) => + Effect.gen(function* () { + const data = yield* postJson( + http, + "/api/search/webFetch", + { url: params.url }, + IFLOW_FETCH_MISSING_KEY, + "30 seconds", + ) + return formatFetchResult(data, params.url) + }) + +export function formatFetchResult(data: Record, fallbackURL: string) { + const source = findFetchData(data) + const title = stringValue(source.title) + const url = stringValue(source.url) ?? fallbackURL + const content = + stringValue(source.markdown) ?? + stringValue(source.content) ?? + stringValue(source.text) ?? + stringValue(source.raw_content) ?? + stringValue(source.rawContent) + + return [ + title ? `Title: ${title}` : undefined, + `URL: ${url}`, + content ? `Content:\n${content}` : "Content:\nNo content returned.", + ] + .filter(Boolean) + .join("\n") +} + +function findFetchData(data: Record) { + const candidates = [ + data.data, + data.result, + objectValue(data.data)?.result, + objectValue(data.data)?.page, + objectValue(data.data)?.document, + ] + + for (const candidate of candidates) { + const object = objectValue(candidate) + if (object) return object + } + + return data +} diff --git a/packages/opencode/src/tool/iflow-search.ts b/packages/opencode/src/tool/iflow-search.ts new file mode 100644 index 000000000000..83656aa68d3a --- /dev/null +++ b/packages/opencode/src/tool/iflow-search.ts @@ -0,0 +1,72 @@ +import { Effect } from "effect" +import { HttpClient } from "effect/unstable/http" +import { arrayValue, numberValue, objectValue, postJson, stringValue } from "./iflow-client" + +export const IFLOW_SEARCH_MISSING_KEY = "IFLOW_API_KEY is required when OPENCODE_WEBSEARCH_PROVIDER=iflow." + +export const search = (http: HttpClient.HttpClient, params: { query: string; count?: number }) => + Effect.gen(function* () { + const count = normalizeCount(params.count) + const data = yield* postJson( + http, + "/api/search/webSearch", + { keywords: params.query, ...(count ? { num: count } : {}) }, + IFLOW_SEARCH_MISSING_KEY, + "25 seconds", + ) + return formatSearchResults(data) + }) + +export function normalizeCount(count: unknown) { + const value = numberValue(count) + if (!value) return undefined + return Math.min(Math.max(Math.trunc(value), 1), 20) +} + +export function formatSearchResults(data: Record) { + const results = findResults(data) + if (!results.length) return "No search results found. Please try a different query." + + return results + .map((item, index) => { + const title = stringValue(item.title) ?? stringValue(item.name) ?? "Untitled" + const url = stringValue(item.url) ?? stringValue(item.link) + const snippet = stringValue(item.snippet) ?? stringValue(item.content) ?? stringValue(item.summary) + const published = stringValue(item.published_time) ?? stringValue(item.publishedTime) + const source = stringValue(item.source) + + return [ + `${index + 1}. ${title}`, + url ? `URL: ${url}` : undefined, + snippet ? `Snippet: ${snippet}` : undefined, + published ? `Published: ${published}` : undefined, + source ? `Source: ${source}` : undefined, + ] + .filter(Boolean) + .join("\n") + }) + .join("\n\n") +} + +function findResults(data: Record) { + const candidates = [ + data.results, + data.webPages, + data.list, + data.items, + objectValue(data.data)?.results, + objectValue(data.data)?.organic, + objectValue(data.data)?.webPages, + objectValue(data.data)?.list, + objectValue(data.data)?.items, + objectValue(objectValue(data.data)?.webPages)?.value, + ] + + for (const candidate of candidates) { + const results = arrayValue(candidate)?.map(objectValue).filter((item): item is Record => !!item) + if (results?.length) return results + } + + const single = objectValue(data.data) + return single && (single.title || single.url || single.link) ? [single] : [] +} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9d503414595b..9ce05ec38494 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -52,8 +52,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { - return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel +export function webSearchEnabled( + providerID: ProviderV2.ID, + flags: { exa?: boolean; parallel?: boolean; iflow?: boolean } = {}, +) { + return ( + providerID === ProviderV2.ID.opencode || + flags.exa || + flags.parallel || + flags.iflow || + process.env.OPENCODE_WEBSEARCH_PROVIDER === "iflow" + ) } type TaskDef = Tool.InferDef @@ -290,7 +299,11 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) + return webSearchEnabled(input.providerID, { + exa: flags.enableExa, + parallel: flags.enableParallel, + iflow: flags.enableIflow, + }) } const usePatch = diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index e6150345459e..5544a8332fc7 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -2,6 +2,7 @@ import { Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { Parser } from "htmlparser2" import * as Tool from "./tool" +import * as IflowFetch from "./iflow-fetch" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" import { isImageAttachment } from "@/util/media" @@ -10,6 +11,15 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes +export type WebFetchProvider = "default" | "iflow" +type WebFetchMetadata = { provider?: "iflow" } + +export function selectWebFetchProvider(): WebFetchProvider { + return process.env.OPENCODE_WEBFETCH_PROVIDER === "iflow" ? "iflow" : "default" +} + +const emptyMetadata = (): WebFetchMetadata => ({}) + export const Parameters = Schema.Struct({ url: Schema.String.annotate({ description: "The URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) @@ -36,6 +46,8 @@ export const WebFetchTool = Tool.define( throw new Error("URL must start with http:// or https://") } + const provider = selectWebFetchProvider() + yield* ctx.ask({ permission: "webfetch", patterns: [params.url], @@ -44,9 +56,19 @@ export const WebFetchTool = Tool.define( url: params.url, format: params.format, timeout: params.timeout, + ...(provider === "iflow" ? { provider } : {}), }, }) + if (provider === "iflow") { + const output = yield* IflowFetch.fetch(http, { url: params.url }) + return { + output, + title: `iFlow Web Fetch: ${params.url}`, + metadata: { provider }, + } + } + const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) // Build Accept header based on requested format with q parameters for fallbacks @@ -112,7 +134,7 @@ export const WebFetchTool = Tool.define( return { title, output: "Image fetched successfully", - metadata: {}, + metadata: emptyMetadata(), attachments: [ { type: "file" as const, @@ -133,22 +155,22 @@ export const WebFetchTool = Tool.define( return { output: markdown, title, - metadata: {}, + metadata: emptyMetadata(), } } - return { output: content, title, metadata: {} } + return { output: content, title, metadata: emptyMetadata() } case "text": if (contentType.includes("text/html")) { - return { output: extractTextFromHTML(content), title, metadata: {} } + return { output: extractTextFromHTML(content), title, metadata: emptyMetadata() } } - return { output: content, title, metadata: {} } + return { output: content, title, metadata: emptyMetadata() } case "html": - return { output: content, title, metadata: {} } + return { output: content, title, metadata: emptyMetadata() } default: - return { output: content, title, metadata: {} } + return { output: content, title, metadata: emptyMetadata() } } }).pipe(Effect.orDie), } diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index d08ae1d153e8..19b8a0c560f9 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -2,6 +2,7 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpWebSearch from "./mcp-websearch" +import * as IflowSearch from "./iflow-search" import DESCRIPTION from "./websearch.txt" import { checksum } from "@opencode-ai/core/util/encode" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -24,12 +25,12 @@ export const Parameters = Schema.Struct({ }), }) -const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel", "iflow"]) export type WebSearchProvider = Schema.Schema.Type export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider { const override = process.env.OPENCODE_WEBSEARCH_PROVIDER - if (override === "exa" || override === "parallel") return override + if (override === "exa" || override === "parallel" || override === "iflow") return override if (flags.parallel) return "parallel" if (flags.exa) return "exa" @@ -37,6 +38,7 @@ export function selectWebSearchProvider(sessionID: string, flags = { exa: false, } export function webSearchProviderLabel(provider: unknown) { + if (provider === "iflow") return "iFlow Search" if (provider === "parallel") return "Parallel Web Search" if (provider === "exa") return "Exa Web Search" return "Web Search" @@ -63,6 +65,13 @@ function callProvider( params: Schema.Schema.Type, ctx: Tool.Context, ) { + if (provider === "iflow") { + return IflowSearch.search(http, { + query: params.query, + count: params.numResults, + }) + } + if (provider === "parallel") { return McpWebSearch.call( http, diff --git a/packages/opencode/test/tool/iflow-fetch.test.ts b/packages/opencode/test/tool/iflow-fetch.test.ts new file mode 100644 index 000000000000..6ae3de4e45f5 --- /dev/null +++ b/packages/opencode/test/tool/iflow-fetch.test.ts @@ -0,0 +1,181 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { iflowBaseURL, IFLOW_DEFAULT_BASE_URL } from "../../src/tool/iflow-client" +import { fetch, formatFetchResult } from "../../src/tool/iflow-fetch" +import { testEffect } from "../lib/effect" +import { failureMessage, withEnv, withIflowServer } from "./iflow-test-util" + +const it = testEffect(FetchHttpClient.layer) + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + +const mockCredential = () => ["mock", "credential"].join("-") + +describe("tool.iflow-fetch", () => { + it.effect("requires IFLOW_API_KEY", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withEnv( + { IFLOW_API_KEY: undefined, IFLOW_BASE_URL: undefined }, + failureMessage(fetch(http, { url: "https://example.com" })), + ) + expect(message).toContain("IFLOW_API_KEY is required when OPENCODE_WEBFETCH_PROVIDER=iflow.") + }), + ) + + it.effect("uses the default base URL", () => + Effect.sync(() => { + const original = process.env.IFLOW_BASE_URL + delete process.env.IFLOW_BASE_URL + try { + expect(iflowBaseURL()).toBe(IFLOW_DEFAULT_BASE_URL) + } finally { + if (original === undefined) delete process.env.IFLOW_BASE_URL + else process.env.IFLOW_BASE_URL = original + } + }), + ) + + it.effect("normalizes trailing slashes from IFLOW_BASE_URL", () => + Effect.sync(() => { + const original = process.env.IFLOW_BASE_URL + process.env.IFLOW_BASE_URL = "https://platform.iflow.cn///" + try { + expect(iflowBaseURL()).toBe(IFLOW_DEFAULT_BASE_URL) + } finally { + if (original === undefined) delete process.env.IFLOW_BASE_URL + else process.env.IFLOW_BASE_URL = original + } + }), + ) + + it.effect("normalizes webFetch success responses", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const result = yield* withIflowServer( + async (request) => { + expect(new URL(request.url).pathname).toBe("/api/search/webFetch") + const body = (await request.json()) as Record + expect(body.url).toBe("https://example.com/docs") + return json({ + success: true, + data: { + title: "Docs", + url: "https://example.com/docs", + markdown: "# Documentation\n\nContent body", + }, + }) + }, + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + fetch(http, { url: "https://example.com/docs" }), + ), + ) + + expect(result).toContain("Title: Docs") + expect(result).toContain("URL: https://example.com/docs") + expect(result).toContain("Content:\n# Documentation") + }), + ) + + it.effect("handles HTTP 401 and 403 errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + for (const status of [401, 403]) { + const message = yield* withIflowServer( + () => json({ success: false }, status), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(fetch(http, { url: "https://example.com" })), + ), + ) + expect(message).toContain("iFlow request was not authorized") + } + }), + ) + + it.effect("handles HTTP 429 errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => json({}, 429), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(fetch(http, { url: "https://example.com" })), + ), + ) + expect(message).toContain("iFlow rate limit exceeded") + }), + ) + + it.effect("handles HTTP 5xx errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => json({}, 502), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(fetch(http, { url: "https://example.com" })), + ), + ) + expect(message).toContain("iFlow service error (502)") + }), + ) + + it.effect("handles bad JSON errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => new Response("{", { status: 200 }), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(fetch(http, { url: "https://example.com" })), + ), + ) + expect(message).toContain("iFlow returned invalid JSON.") + }), + ) + + it.effect("handles business errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => json({ success: false, message: "fetch failed" }), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(fetch(http, { url: "https://example.com" })), + ), + ) + expect(message).toContain("iFlow request failed: fetch failed.") + }), + ) + + it.effect("formats common alternate content fields", () => + Effect.sync(() => { + const output = formatFetchResult( + { + result: { + title: "Fetched", + url: "https://example.com/page", + text: "Plain text body", + }, + }, + "https://fallback.example", + ) + expect(output).toContain("Title: Fetched") + expect(output).toContain("URL: https://example.com/page") + expect(output).toContain("Content:\nPlain text body") + }), + ) +}) diff --git a/packages/opencode/test/tool/iflow-search.test.ts b/packages/opencode/test/tool/iflow-search.test.ts new file mode 100644 index 000000000000..797761addef1 --- /dev/null +++ b/packages/opencode/test/tool/iflow-search.test.ts @@ -0,0 +1,191 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { iflowBaseURL, IFLOW_DEFAULT_BASE_URL } from "../../src/tool/iflow-client" +import { formatSearchResults, normalizeCount, search } from "../../src/tool/iflow-search" +import { testEffect } from "../lib/effect" +import { failureMessage, withEnv, withIflowServer } from "./iflow-test-util" + +const it = testEffect(FetchHttpClient.layer) + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + +const mockCredential = () => ["mock", "credential"].join("-") + +describe("tool.iflow-search", () => { + it.effect("requires IFLOW_API_KEY", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withEnv( + { IFLOW_API_KEY: undefined, IFLOW_BASE_URL: undefined }, + failureMessage(search(http, { query: "missing key" })), + ) + expect(message).toContain("IFLOW_API_KEY is required when OPENCODE_WEBSEARCH_PROVIDER=iflow.") + }), + ) + + it.effect("uses the default base URL", () => + Effect.sync(() => { + const original = process.env.IFLOW_BASE_URL + delete process.env.IFLOW_BASE_URL + try { + expect(iflowBaseURL()).toBe(IFLOW_DEFAULT_BASE_URL) + } finally { + if (original === undefined) delete process.env.IFLOW_BASE_URL + else process.env.IFLOW_BASE_URL = original + } + }), + ) + + it.effect("normalizes trailing slashes from IFLOW_BASE_URL", () => + Effect.sync(() => { + const original = process.env.IFLOW_BASE_URL + process.env.IFLOW_BASE_URL = "https://platform.iflow.cn///" + try { + expect(iflowBaseURL()).toBe(IFLOW_DEFAULT_BASE_URL) + } finally { + if (original === undefined) delete process.env.IFLOW_BASE_URL + else process.env.IFLOW_BASE_URL = original + } + }), + ) + + it.effect("normalizes success responses into string output", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const result = yield* withIflowServer( + async (request) => { + expect(new URL(request.url).pathname).toBe("/api/search/webSearch") + const body = (await request.json()) as Record + expect(body.keywords).toBe("opencode iflow") + expect(body.num).toBe(3) + return json({ + success: true, + data: { + organic: [ + { + title: "OpenCode iFlow", + link: "https://example.com/opencode", + snippet: "Search result summary", + published_time: "2026-06-09", + source: "example", + }, + ], + }, + }) + }, + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + search(http, { query: "opencode iflow", count: 3 }), + ), + ) + + expect(result).toContain("1. OpenCode iFlow") + expect(result).toContain("URL: https://example.com/opencode") + expect(result).toContain("Snippet: Search result summary") + expect(result).toContain("Published: 2026-06-09") + expect(result).toContain("Source: example") + }), + ) + + it.effect("handles HTTP 401 and 403 errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + for (const status of [401, 403]) { + const message = yield* withIflowServer( + () => json({ success: false }, status), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(search(http, { query: "auth" })), + ), + ) + expect(message).toContain("iFlow request was not authorized") + } + }), + ) + + it.effect("handles HTTP 429 errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => json({}, 429), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(search(http, { query: "rate" })), + ), + ) + expect(message).toContain("iFlow rate limit exceeded") + }), + ) + + it.effect("handles HTTP 5xx errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => json({}, 503), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(search(http, { query: "server" })), + ), + ) + expect(message).toContain("iFlow service error (503)") + }), + ) + + it.effect("handles bad JSON errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => new Response("{", { status: 200 }), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(search(http, { query: "json" })), + ), + ) + expect(message).toContain("iFlow returned invalid JSON.") + }), + ) + + it.effect("handles business errors", () => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const message = yield* withIflowServer( + () => json({ success: false, message: "quota unavailable" }), + (url) => + withEnv( + { IFLOW_API_KEY: mockCredential(), IFLOW_BASE_URL: url.toString() }, + failureMessage(search(http, { query: "business" })), + ), + ) + expect(message).toContain("iFlow request failed: quota unavailable.") + }), + ) + + it.effect("normalizes count limits", () => + Effect.sync(() => { + expect(normalizeCount(0)).toBeUndefined() + expect(normalizeCount(3.8)).toBe(3) + expect(normalizeCount(100)).toBe(20) + }), + ) + + it.effect("formats common alternate result fields", () => + Effect.sync(() => { + const output = formatSearchResults({ + results: [{ title: "Title", link: "https://example.com", content: "Content", source: "Source" }], + }) + expect(output).toContain("1. Title") + expect(output).toContain("URL: https://example.com") + expect(output).toContain("Snippet: Content") + }), + ) +}) diff --git a/packages/opencode/test/tool/iflow-test-util.ts b/packages/opencode/test/tool/iflow-test-util.ts new file mode 100644 index 000000000000..0181e73f0ff6 --- /dev/null +++ b/packages/opencode/test/tool/iflow-test-util.ts @@ -0,0 +1,38 @@ +import { Cause, Effect, Exit } from "effect" + +export const withEnv = (env: Record, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) + +export const withIflowServer = ( + handler: (request: Request) => Response | Promise, + effect: (url: URL) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch: handler })), + (server) => effect(server.url), + (server) => Effect.sync(() => server.stop(true)), + ) + +export const failureMessage = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* effect.pipe(Effect.exit) + if (Exit.isSuccess(exit)) throw new Error("Expected effect to fail") + return Cause.pretty(exit.cause) + }) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index fdf5210b9c13..a31f7bac7db4 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -3,10 +3,11 @@ import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" -import { WebFetchTool } from "../../src/tool/webfetch" +import { selectWebFetchProvider, WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { testEffect } from "../lib/effect" +import { failureMessage, withEnv, withIflowServer } from "./iflow-test-util" const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)) @@ -38,6 +39,71 @@ const exec = Effect.fn("WebFetchToolTest.exec")(function* (args: Tool.InferParam }) describe("tool.webfetch", () => { + it.instance("selects default provider unless iFlow is explicitly configured", () => + Effect.sync(() => { + const original = process.env.OPENCODE_WEBFETCH_PROVIDER + delete process.env.OPENCODE_WEBFETCH_PROVIDER + try { + expect(selectWebFetchProvider()).toBe("default") + process.env.OPENCODE_WEBFETCH_PROVIDER = "iflow" + expect(selectWebFetchProvider()).toBe("iflow") + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBFETCH_PROVIDER + else process.env.OPENCODE_WEBFETCH_PROVIDER = original + } + }), + ) + + it.instance("requires IFLOW_API_KEY when iFlow provider is explicitly configured", () => + withEnv( + { OPENCODE_WEBFETCH_PROVIDER: "iflow", IFLOW_API_KEY: undefined, IFLOW_BASE_URL: "https://example.com" }, + Effect.gen(function* () { + const message = yield* failureMessage(exec({ url: "https://example.com/docs", format: "markdown" })) + expect(message).toContain("IFLOW_API_KEY is required when OPENCODE_WEBFETCH_PROVIDER=iflow.") + }), + ), + ) + + it.instance("calls iFlow webFetch when iFlow provider is explicitly configured", () => + Effect.gen(function* () { + let called = false + + const result = yield* withIflowServer( + async (request) => { + called = true + expect(new URL(request.url).pathname).toBe("/api/search/webFetch") + const body = (await request.json()) as Record + expect(body.url).toBe("https://example.com/docs") + return new Response( + JSON.stringify({ + success: true, + data: { + title: "Fetched Page", + url: "https://example.com/docs", + content: "Fetched through iFlow", + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }, + (url) => + withEnv( + { + OPENCODE_WEBFETCH_PROVIDER: "iflow", + IFLOW_API_KEY: "mock-credential", + IFLOW_BASE_URL: url.toString(), + }, + exec({ url: "https://example.com/docs", format: "markdown" }), + ), + ) + + expect(called).toBe(true) + expect(result.metadata.provider).toBe("iflow") + expect(result.output).toContain("Title: Fetched Page") + expect(result.output).toContain("Content:\nFetched through iFlow") + }), + ) + it.instance("returns image responses as file attachments", () => Effect.gen(function* () { const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index 349606dec735..745912d675bc 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -1,13 +1,43 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { parseResponse } from "../../src/tool/mcp-websearch" -import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" +import { + selectWebSearchProvider, + WebSearchTool, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" import { webSearchEnabled } from "../../src/tool/registry" -import { it } from "../lib/effect" +import { it, testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { MessageID, SessionID } from "../../src/session/schema" +import { Tool } from "@/tool/tool" +import { withEnv, withIflowServer } from "./iflow-test-util" +import { Truncate } from "@/tool/truncate" +import { Agent } from "../../src/agent/agent" const SESSION_ID = "ses_0196aabbccddeeff001122334455" +const toolIt = testEffect(Layer.mergeAll(FetchHttpClient.layer, RuntimeFlags.layer(), Truncate.defaultLayer, Agent.defaultLayer)) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_message"), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const exec = Effect.fn("WebSearchToolTest.exec")(function* (args: Tool.InferParameters) { + const info = yield* WebSearchTool + const tool = yield* info.init() + return yield* tool.execute(args, ctx) +}) describe("websearch provider", () => { test("selects a stable provider per session", () => { @@ -23,6 +53,9 @@ describe("websearch provider", () => { process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa" expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") + + process.env.OPENCODE_WEBSEARCH_PROVIDER = "iflow" + expect(selectWebSearchProvider(SESSION_ID)).toBe("iflow") } finally { if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER else process.env.OPENCODE_WEBSEARCH_PROVIDER = original @@ -42,9 +75,23 @@ describe("websearch provider", () => { expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false })).toBe(false) expect(webSearchEnabled(ProviderV2.ID.openai, { exa: true, parallel: false })).toBe(true) expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: true })).toBe(true) + expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false, iflow: true })).toBe(true) + }) + + test("is enabled when iFlow is explicitly selected", () => { + const original = process.env.OPENCODE_WEBSEARCH_PROVIDER + + try { + process.env.OPENCODE_WEBSEARCH_PROVIDER = "iflow" + expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false })).toBe(true) + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER + else process.env.OPENCODE_WEBSEARCH_PROVIDER = original + } }) test("uses branded labels", () => { + expect(webSearchProviderLabel("iflow")).toBe("iFlow Search") expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") expect(webSearchProviderLabel(undefined)).toBe("Web Search") @@ -60,6 +107,45 @@ describe("websearch provider", () => { }), ).toBe("claude-opus-4.7") }) + + toolIt.instance("calls iFlow webSearch when iFlow provider is explicitly configured", () => + Effect.gen(function* () { + let called = false + + const result = yield* withIflowServer( + async (request) => { + called = true + expect(new URL(request.url).pathname).toBe("/api/search/webSearch") + const body = (await request.json()) as Record + expect(body.keywords).toBe("opencode iflow") + expect(body.num).toBe(2) + return new Response( + JSON.stringify({ + success: true, + data: { + organic: [{ title: "iFlow Result", link: "https://example.com", snippet: "from iFlow" }], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }, + (url) => + withEnv( + { + OPENCODE_WEBSEARCH_PROVIDER: "iflow", + IFLOW_API_KEY: "mock-credential", + IFLOW_BASE_URL: url.toString(), + }, + exec({ query: "opencode iflow", numResults: 2 }), + ), + ) + + expect(called).toBe(true) + expect(result.metadata.provider).toBe("iflow") + expect(result.output).toContain("1. iFlow Result") + expect(result.output).toContain("Snippet: from iFlow") + }), + ) }) describe("websearch MCP response parser", () => {