diff --git a/.agents/plans/07-reviewer-picker/README.md b/.agents/plans/07-reviewer-picker/README.md new file mode 100644 index 00000000..1b05e3b2 --- /dev/null +++ b/.agents/plans/07-reviewer-picker/README.md @@ -0,0 +1,78 @@ +# Choose the reviewer inside OpenCode + +The 8.4.0 candidate adds an optional TUI module and native /flow-reviewer command. +This is a feature PR, not a published release. Installed 8.3.1 remains unchanged. +No paid evals or canaries are authorized. + +## User flow + +Open /flow-reviewer or choose “Flow: Choose reviewer model” from the command +palette. Search connected providers' text/tool-capable models. Deprecated models +are omitted. Availability here means configured catalog availability; no paid +probe establishes actual provider execution. + +Choose a model, then confirm the global save and server reload. Current-project +busy sessions block saving. The confirmation tells the user to finish other +projects first because OpenCode's global config API disposes server instances. +The picker does not claim it can atomically rule out work starting elsewhere. +Cancellation makes no write. Choosing the already-selected value closes the picker. +A changed project, disconnected model, changed preference, or disposed plugin +prevents the pending save. API errors surface without claiming success. + +## Stored preference and precedence + +The picker patches only agent.flow-reviewer.options.flowReviewerModel through +OpenCode's global configuration API. It never writes the returned plugin list, +providers, credentials, or the manager's model. A project value for this same +preference overrides the global value through OpenCode's normal config merge. +There is no project-write UI in this first version. + +A nonempty picker preference takes priority over reviewer.model tuple options +and OPENCODE_FLOW_REVIEWER_MODEL. It uses default model reasoning and retains +configured reviewer steps. An empty preference (“Use default”) restores the +existing tuple/environment model and variant. It does not delete or materialize +those settings. Project tuple settings alone do not override a nonempty picker +preference; use an empty project preference to restore tuple/environment behavior. + +Flow consumes the preference during config application and reports its effective +requested source as picker in /flow-status. It is removed from the generated +agent options, so it is not forwarded as a provider option. OpenCode still owns +persistence, configuration layering, and reload behavior. No Session v5 fields or +new lifecycle tools are added. + +## Packaging and host boundary + +The package retains its server entry point and adds ./tui with its own compiled +module and declaration. OpenCode's installer detects both targets; reinstalling +with --force registers the TUI entry. Manual users must list the package in their +tui.json as well as their server config on OpenCode 1.18.6. + +Only the existing SDK seam imports OpenCode types. The optional TUI implementation +has an 8 KiB source allowance; server-side preference resolution adds 1 KiB to the +existing config/status allowance. Runtime workflow code is otherwise unchanged. + +Sources inspected for the pinned host: +- https://github.com/anomalyco/opencode/blob/v1.18.6/packages/opencode/specs/tui-plugins.md +- https://github.com/anomalyco/opencode/blob/v1.18.6/packages/plugin/src/tui.ts +- https://github.com/anomalyco/opencode/blob/v1.18.6/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts + +## Verification + +Unit tests cover connected-model filtering, minimal preference patches, precedence +and reset, cancellation, confirmed saves, busy sessions, stale preferences, removed +models, and server config propagation. Existing surface and package checks pass. + +Pinned OpenCode 1.18.6 rendered the real native picker and confirmation in an +isolated PTY using a synthetic catalog and a disabled write transport. This proves +UI registration and rendering; it is not a real provider or billing test. Early +probe attempts hit an old PATH executable and then a syntax error in the temporary +wrapper; the corrected pinned-host probe rendered both dialogs. Terminal capture +is retained outside Git in the temporary flow-picker-ui workspace. + +The provider-free host smoke uses the real global PATCH API against an isolated +candidate installation. It verifies selected model/default reasoning, retained +steps, restoration of the original model/variant, unchanged unrelated environment +references in the config file, and no .flow state creation. No prompt is submitted. + +The 8.3.1 baseline patch record cannot qualify this new public UI and runtime +configuration behavior. No 8.4.0 tag or publication is part of this implementation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a116a6..a0180d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ One short entry per release, written for users deciding whether to upgrade. +## [8.4.0] - 2026-09-14 + +Choose Flow's global reviewer model from OpenCode's terminal UI. + +- `/flow-reviewer` opens a searchable picker of connected text/tool-capable models. + “Use default” restores plugin/environment settings. Selection makes no model call. +- Confirm before saving global settings and reloading OpenCode. Active work in the + current project blocks saving; finish work in other projects first. Project-level + preferences override the global choice. Picker selections use default reasoning; + resetting restores saved plugin/environment options without rewriting them. +- Add the optional `./tui` package entry point. Reinstall with + `opencode plugin opencode-plugin-flow@8.4.0 --global --force` to register it. +- Session v5 schema and Flow lifecycle tools are unchanged. This feature requires + its own release qualification; the previous patch declaration does not apply. + ## [8.3.1] - 2026-09-14 Clarify review handoffs and make release operations recoverable. diff --git a/README.md b/README.md index 9424d99a..51f28e93 100644 --- a/README.md +++ b/README.md @@ -40,46 +40,43 @@ expensive, and it is overhead when it is not. Install the exact npm release through OpenCode: ```bash -opencode plugin opencode-plugin-flow@8.3.1 --global --force +opencode plugin opencode-plugin-flow@8.4.0 --global --force ``` Omit `--global` for project scope. Version pins are exact and never update on their own; to update, rerun the command with the new version. -The equivalent manual project configuration is: +Manual setup needs this entry in both `opencode.json` and `tui.json`: ```json { - "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-plugin-flow@8.3.1"] + "plugin": ["opencode-plugin-flow@8.4.0"] } ``` -For an explicit reviewer model, use OpenCode's plugin tuple options: +Run `/flow-reviewer` for the global reviewer picker. Saving reloads the server; +finish other projects' work first. Project picker preferences take precedence. +“Use default” restores plugin/environment settings. No model call. + +`opencode.json` reviewer options: ```json { - "$schema": "https://opencode.ai/config.json", - "plugin": [ - [ - "opencode-plugin-flow@8.3.1", - { "reviewer": { "model": "provider/model", "steps": 80 } } - ] - ] + "plugin": [["opencode-plugin-flow@8.4.0", { + "reviewer": { "model": "provider/model", "steps": 80 } + }]] } ``` -The tuple values take precedence over `OPENCODE_FLOW_REVIEWER_MODEL` and -`OPENCODE_FLOW_REVIEWER_STEPS`. Optional `reviewer.variant` selects a native -model variant and overrides `OPENCODE_FLOW_REVIEWER_VARIANT`. It requires an -explicit reviewer model. `/flow-status` reports the requested settings and -unsupported combinations. Only a successful reviewer run confirms availability. - -Restart OpenCode after changing configuration. OpenCode owns installation and -configuration; see its -[plugin documentation](https://opencode.ai/docs/plugins/). Flow has no installer -or activation CLI, and removing the plugin entry disables it. If two Flow copies -load for one project, both fail closed until the duplicate is removed. +Picker preferences take priority. Otherwise tuple settings override +`OPENCODE_FLOW_REVIEWER_MODEL`, +`OPENCODE_FLOW_REVIEWER_STEPS`, and `OPENCODE_FLOW_REVIEWER_VARIANT`. +`reviewer.variant` requires an explicit model. `/flow-status` shows requested +settings; only successful reviews confirm availability. + +[OpenCode](https://opencode.ai/docs/plugins/) owns installation and configuration. +Restart after manual changes. Remove the plugin entry to disable Flow; duplicate +copies fail closed until one remains. **Changing versions.** Finish or explicitly close any active session first, in either direction. Flow opens only Session v5 active state, and an older build diff --git a/docs/maintainer-contract.md b/docs/maintainer-contract.md index c6850b6a..afa9cd6e 100644 --- a/docs/maintainer-contract.md +++ b/docs/maintainer-contract.md @@ -349,3 +349,7 @@ host-visible configuration without adding a scheduler or tests-of-tests. See [Model-driven wave evidence](development.md#model-driven-wave-evidence) for the manual canary policy. + + +The optional `./tui` export adds native `/flow-reviewer` selection of the global +reviewer. Saving requires confirmation. No server command or lifecycle tool is added. diff --git a/package.json b/package.json index 3e6a7918..e9abd332 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-flow", - "version": "8.3.1", + "version": "8.4.0", "description": "Small durable planning, validation, and review workflow for OpenCode", "type": "module", "repository": { @@ -10,6 +10,10 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { + "./tui": { + "types": "./dist/tui.d.ts", + "import": "./dist/tui.js" + }, ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", @@ -17,6 +21,9 @@ } }, "files": [ + "dist/tui.js", + "dist/tui.js.map", + "dist/tui.d.ts", "dist/index.js", "dist/index.js.map", "dist/index.d.ts", @@ -27,8 +34,9 @@ "CHANGELOG.md" ], "scripts": { - "build": "bun run clean && bun run build:plugin && bun run build:types", + "build": "bun run clean && bun run build:plugin && bun run build:tui && bun run build:types", "build:plugin": "bun build --target=node --outdir=./dist --entry-naming=index.js --external=@opencode-ai/plugin --external=zod --sourcemap=external ./src/index.ts", + "build:tui": "bun build --target=bun --outdir=./dist --entry-naming=tui.js --sourcemap=external ./src/tui.ts", "build:types": "tsc -p tsconfig.types.json && bun run scripts/prune-dist-declarations.ts", "clean": "bun run scripts/clean-dist.ts", "eval": "bun run evals/run.ts", diff --git a/scripts/lib/package-surface.ts b/scripts/lib/package-surface.ts index 6bc4d962..cba77357 100644 --- a/scripts/lib/package-surface.ts +++ b/scripts/lib/package-surface.ts @@ -1,10 +1,14 @@ export const PUBLIC_DECLARATION_PATHS = [ + "dist/tui.d.ts", "dist/index.d.ts", "dist/platform/opencode/plugin.d.ts", "dist/platform/opencode/sdk.d.ts", ] as const; export const PACKED_PACKAGE_PATHS = [ + "dist/tui.js", + "dist/tui.js.map", + "dist/tui.d.ts", "CHANGELOG.md", "LICENSE", "README.md", diff --git a/src/config-shared.ts b/src/config-shared.ts index 0ad4f216..88f85bae 100644 --- a/src/config-shared.ts +++ b/src/config-shared.ts @@ -30,7 +30,7 @@ export type MutableFlowConfig = { }; type FlowEnvironment = Readonly>; -type ReviewerSettingSource = "plugin-option" | "environment"; +type ReviewerSettingSource = "plugin-option" | "environment" | "picker"; type FlowPluginOptions = Readonly<{ reviewer?: unknown; diff --git a/src/platform/opencode/config.ts b/src/platform/opencode/config.ts index 0fca53e8..e4a5bf09 100644 --- a/src/platform/opencode/config.ts +++ b/src/platform/opencode/config.ts @@ -4,15 +4,23 @@ import { type MutableFlowConfig, } from "../../config-shared.js"; import { createFlowLog } from "./logging.js"; +import { + applyReviewerPreference, + reviewerPreference, +} from "./reviewer-picker.js"; export function createConfigHook( ctx: unknown, options?: { assertOperational?: (action: string) => void; reviewerConfiguration?: FlowReviewerConfiguration | undefined; + onReviewerConfiguration?: ( + configuration: FlowReviewerConfiguration, + ) => void; }, ) { const log = createFlowLog(ctx); + let preference: string | undefined; return async (config: MutableFlowConfig) => { try { options?.assertOperational?.("apply its OpenCode configuration"); @@ -20,10 +28,14 @@ export function createConfigHook( log("error", error instanceof Error ? error.message : String(error)); return; } + const saved = reviewerPreference(config); + if (saved !== undefined) preference = saved; + const reviewer = options?.reviewerConfiguration + ? applyReviewerPreference(options.reviewerConfiguration, preference) + : undefined; + if (reviewer) options?.onReviewerConfiguration?.(reviewer); applyFlowConfig(config, { - ...(options?.reviewerConfiguration - ? { reviewerConfiguration: options.reviewerConfiguration } - : {}), + ...(reviewer ? { reviewerConfiguration: reviewer } : {}), onWarning: (warning) => log("warn", warning), onNotice: (notice) => log("info", notice), onCollision: (kind, name) => diff --git a/src/platform/opencode/plugin.ts b/src/platform/opencode/plugin.ts index 08ec6a7d..8908ae3f 100644 --- a/src/platform/opencode/plugin.ts +++ b/src/platform/opencode/plugin.ts @@ -34,10 +34,7 @@ type CommandHook = NonNullable; type CommandOutput = Parameters[1]; type Part = CommandOutput["parts"][number]; type TextPart = Extract; -/** - * A text part as the plugin writes one: the host assigns id, sessionID, and - * messageID only after the command hook returns. - */ +// Host assigns id, sessionID and messageID after the command hook returns. type DraftTextPart = Omit; const MUTATION = /^flow_(?:plan_save|plan_approve|run_start|review_start|feature_complete|feature_reset|session_close)$/; @@ -260,7 +257,7 @@ function guardTools( const FlowPlugin: Plugin = async (ctx, pluginOptions) => { const log = createFlowLog(ctx); - const reviewerConfiguration = resolveFlowReviewerConfiguration({ + let reviewerConfiguration = resolveFlowReviewerConfiguration({ pluginOptions, onWarning: (warning) => log("warn", warning), }); @@ -323,13 +320,16 @@ const FlowPlugin: Plugin = async (ctx, pluginOptions) => { prepareValidation: prepareWorkspaceValidation, autoTimingSnapshot: () => autoDrive.timingSnapshot(), autoContinuationSupport: () => autoDrive.continuationSupport(), - reviewerConfiguration, + readReviewerConfiguration: () => reviewerConfiguration, runtimeIdentity: { packageVersion: version, pluginEntrySha256 }, }); return { config: createConfigHook(ctx, { assertOperational: (action) => runtimeGuard.assertOperational(action), reviewerConfiguration, + onReviewerConfiguration: (configuration) => { + reviewerConfiguration = configuration; + }, }), tool: guardTools(tools, runtimeGuard, autoDrive), "command.execute.before": createCommandHook( diff --git a/src/platform/opencode/reviewer-picker.ts b/src/platform/opencode/reviewer-picker.ts new file mode 100644 index 00000000..48d0d554 --- /dev/null +++ b/src/platform/opencode/reviewer-picker.ts @@ -0,0 +1,69 @@ +import type { FlowReviewerConfiguration } from "../../config-shared.js"; +import type { Provider } from "./sdk.js"; + +export function reviewerChoices( + providers: readonly Provider[], + connected: readonly string[], +) { + const enabled = new Set(connected); + return providers + .filter((provider) => enabled.has(provider.id)) + .flatMap((provider) => + Object.values(provider.models) + .filter( + (model) => + model.capabilities.toolcall && + model.capabilities.input.text && + model.capabilities.output.text && + model.status !== "deprecated", + ) + .map((model) => ({ + title: model.name, + value: `${provider.id}/${model.id}`, + category: provider.name, + description: `${provider.id}/${model.id}`, + })), + ) + .sort( + (a, b) => + a.category.localeCompare(b.category) || + a.title.localeCompare(b.title) || + a.value.localeCompare(b.value), + ); +} + +export function reviewerPreference(config: { + agent?: Record; +}): string | undefined { + const agent = config.agent?.["flow-reviewer"]; + if (!agent || typeof agent !== "object" || !("options" in agent)) + return undefined; + const options = agent.options; + if ( + !options || + typeof options !== "object" || + !("flowReviewerModel" in options) + ) + return undefined; + if (typeof options.flowReviewerModel !== "string") + throw new Error("Flow reviewer preference must be a string."); + return options.flowReviewerModel.trim(); +} + +export function reviewerPreferencePatch(model: string) { + return { + agent: { "flow-reviewer": { options: { flowReviewerModel: model } } }, + }; +} + +export function applyReviewerPreference( + base: FlowReviewerConfiguration, + model: string | undefined, +): FlowReviewerConfiguration { + return model + ? { + model: { kind: "explicit", source: "picker", value: model }, + steps: base.steps, + } + : base; +} diff --git a/src/platform/opencode/sdk.ts b/src/platform/opencode/sdk.ts index edb54a31..b6f1a0bb 100644 --- a/src/platform/opencode/sdk.ts +++ b/src/platform/opencode/sdk.ts @@ -6,3 +6,6 @@ export type { ToolResult, } from "@opencode-ai/plugin"; export { tool } from "@opencode-ai/plugin"; + +export type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"; +export type { Config as HostConfig, Provider } from "@opencode-ai/sdk/v2"; diff --git a/src/platform/opencode/tools.ts b/src/platform/opencode/tools.ts index 4e82ae0c..ca208af9 100644 --- a/src/platform/opencode/tools.ts +++ b/src/platform/opencode/tools.ts @@ -53,6 +53,7 @@ type ToolOptions = Readonly<{ | (() => ProcessLocalAutoContinuationSupport) | undefined; reviewerConfiguration?: FlowReviewerConfiguration | undefined; + readReviewerConfiguration?: () => FlowReviewerConfiguration; runtimeIdentity?: | Readonly<{ packageVersion: string; pluginEntrySha256: string }> | undefined; @@ -90,7 +91,8 @@ function withAutoContext( view?: string, ): FlowToolResponse { let workflowData = response.workflowData; - const reviewer = options.reviewerConfiguration; + const reviewer = + options.readReviewerConfiguration?.() ?? options.reviewerConfiguration; if (reviewer) { workflowData = { ...workflowData, diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 00000000..47bead50 --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,168 @@ +import { + reviewerChoices, + reviewerPreference, + reviewerPreferencePatch, +} from "./platform/opencode/reviewer-picker.js"; +import type { TuiPlugin, TuiPluginModule } from "./platform/opencode/sdk.js"; + +const tui: TuiPlugin = async (api) => { + let saving = false; + const error = (cause: unknown) => + api.ui.toast({ + variant: "error", + title: "Flow reviewer", + message: + cause instanceof Error + ? cause.message + : "Could not update reviewer settings.", + }); + const open = async () => { + if (saving || api.lifecycle.signal.aborted) return; + try { + if (!api.state.ready) + throw new Error("OpenCode is still syncing. Try again shortly."); + const directory = api.state.path.directory; + const [global, providers] = await Promise.all([ + api.client.global.config.get({ throwOnError: true }), + api.client.provider.list({ directory }, { throwOnError: true }), + ]); + if (!global.data || !providers.data) + throw new Error( + "OpenCode configuration or model catalog is unavailable.", + ); + const config = global.data; + reviewerPreference(config); + const choices = [ + { + title: "Use default", + value: "", + category: "Default", + description: + "Existing plugin/environment settings, otherwise the coding model", + }, + ...reviewerChoices(providers.data.all, providers.data.connected), + ]; + const current = reviewerPreference(config) ?? ""; + if (current && !choices.some((choice) => choice.value === current)) { + const unavailable = { + title: current, + value: current, + category: "Current preference", + description: "Unavailable here; reconnect or choose another model", + disabled: true, + }; + choices.unshift(unavailable); + } + api.ui.dialog.replace(() => + api.ui.DialogSelect({ + title: "Flow reviewer · global default", + placeholder: "Search connected models", + current, + options: choices, + onSelect: ({ value }) => { + if (saving || api.lifecycle.signal.aborted) return; + if (value === (reviewerPreference(config) ?? "")) { + api.ui.dialog.clear(); + return; + } + api.ui.dialog.replace(() => + api.ui.DialogConfirm({ + title: "Save Flow reviewer?", + message: `${value || "Use configured default"}\n\nChanges the global Flow reviewer and reloads OpenCode server instances. Finish work in other projects first. Project picker preferences take precedence. Picker selections use the model’s default reasoning. Use default to restore plugin/environment settings.`, + onCancel: () => api.ui.dialog.clear(), + onConfirm: () => { + if (saving || api.lifecycle.signal.aborted) return; + saving = true; + void (async () => { + try { + const [latest, status, catalog] = await Promise.all([ + api.client.global.config.get({ throwOnError: true }), + api.client.session.status( + { directory }, + { throwOnError: true }, + ), + api.client.provider.list( + { directory }, + { throwOnError: true }, + ), + ]); + if (!latest.data || !status.data || !catalog.data) + throw new Error( + "Could not verify current settings and session activity.", + ); + if ( + Object.values(status.data).some( + (session) => session.type !== "idle", + ) + ) + throw new Error( + "Wait for this project's active work to finish before changing the reviewer.", + ); + if ( + reviewerPreference(latest.data) !== + reviewerPreference(config) + ) + throw new Error( + "Reviewer preference changed while the picker was open. Open it again.", + ); + if ( + value && + !reviewerChoices( + catalog.data.all, + catalog.data.connected, + ).some((choice) => choice.value === value) + ) + throw new Error( + "That model is no longer available from a connected provider.", + ); + if (api.lifecycle.signal.aborted) return; + if (api.state.path.directory !== directory) + throw new Error( + "Project changed while the picker was open. Open it again.", + ); + if (value !== (reviewerPreference(latest.data) ?? "")) + await api.client.global.config.update( + { config: reviewerPreferencePatch(value) }, + { throwOnError: true }, + ); + api.ui.dialog.clear(); + api.ui.toast({ + variant: "success", + title: "Flow reviewer saved", + message: + "Global preference saved. Use /flow-status after reload to check the effective reviewer.", + }); + } catch (cause) { + error(cause); + } finally { + saving = false; + } + })(); + }, + }), + ); + }, + }), + ); + } catch (cause) { + error(cause); + } + }; + api.keymap.registerLayer({ + commands: [ + { + name: "flow.reviewer.select", + title: "Flow: Choose reviewer model", + category: "Flow", + namespace: "palette", + slashName: "flow-reviewer", + run: open, + }, + ], + }); +}; + +export default { + id: "opencode-plugin-flow.reviewer-picker", + tui, +} satisfies TuiPluginModule; diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index dbf4dc42..4116b528 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -63,7 +63,7 @@ const sourceRoot = join(repositoryRoot, "src"); const FROZEN_TYPESCRIPT_SOURCE_BYTES = 249 * 1024; const PROCESS_LOCAL_CONFIG_AND_STATUS_BYTES = 15 * 1024; const MAX_TYPESCRIPT_SOURCE_BYTES = - FROZEN_TYPESCRIPT_SOURCE_BYTES + PROCESS_LOCAL_CONFIG_AND_STATUS_BYTES; + FROZEN_TYPESCRIPT_SOURCE_BYTES + PROCESS_LOCAL_CONFIG_AND_STATUS_BYTES + 1024; // Reviewer preference resolution; no Session fields. const MAX_TYPESCRIPT_FILE_LINES = 1_000; const inwardLayers = new Set(["domain", "application", "infrastructure"]); const allowedTargets = { @@ -174,12 +174,24 @@ describe("v6 architecture boundaries", () => { // Reported because a budget that only speaks up once it is exceeded blocks // the change that discovered the problem rather than the one that caused it. - const headroom = MAX_TYPESCRIPT_SOURCE_BYTES - totalBytes; + const headroom = MAX_TYPESCRIPT_SOURCE_BYTES + 8 * 1024 - totalBytes; console.info( - `src TypeScript: ${totalBytes} bytes, ${headroom} of ${MAX_TYPESCRIPT_SOURCE_BYTES} remaining.`, + `src TypeScript: ${totalBytes} bytes, ${headroom} of ${MAX_TYPESCRIPT_SOURCE_BYTES + 8 * 1024} remaining (including optional TUI).`, ); - expect(totalBytes).toBeLessThanOrEqual(MAX_TYPESCRIPT_SOURCE_BYTES); + // The optional TUI picker ships separately; retain the server source ceiling. + const pickerPaths = new Set([ + "src/tui.ts", + "src/platform/opencode/reviewer-picker.ts", + ]); + let pickerBytes = 0; + for (const file of await sourceFiles()) + if (pickerPaths.has(repositoryPath(file))) + pickerBytes += (await readFile(file)).byteLength; + expect(pickerBytes).toBeLessThanOrEqual(8 * 1024); + expect(totalBytes - pickerBytes).toBeLessThanOrEqual( + MAX_TYPESCRIPT_SOURCE_BYTES, + ); expect(oversized).toEqual([]); }); diff --git a/tests/documentation-contract.test.ts b/tests/documentation-contract.test.ts index d5156bfd..dbf4466f 100644 --- a/tests/documentation-contract.test.ts +++ b/tests/documentation-contract.test.ts @@ -236,7 +236,6 @@ describe("Flow documentation contract", () => { const exactPackage = `opencode-plugin-flow@${packageVersion}`; expect(JSON.parse(block ?? "null")).toEqual({ - $schema: "https://opencode.ai/config.json", plugin: [exactPackage], }); expect(install).toContain( diff --git a/tests/live-opencode-smoke.test.ts b/tests/live-opencode-smoke.test.ts index cf638a60..a2a0a017 100644 --- a/tests/live-opencode-smoke.test.ts +++ b/tests/live-opencode-smoke.test.ts @@ -1,6 +1,13 @@ import { describe, expect, spyOn, test } from "bun:test"; import { spawn } from "node:child_process"; -import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -642,6 +649,23 @@ describe.skipIf(!LIVE)(`live OpenCode ${OPENCODE_VERSION} smoke`, () => { "utf8", ); + const globalConfig = join( + childHome, + ".config", + "opencode", + "opencode.json", + ); + await mkdir(join(childHome, ".config", "opencode"), { recursive: true }); + await writeFile( + globalConfig, + JSON.stringify({ + agent: { + "fixture-other": { + options: { reference: "{env:FLOW_PICKER_FIXTURE}" }, + }, + }, + }), + ); const port = await availablePort(); const baseUrl = `http://127.0.0.1:${port}`; const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS; @@ -663,6 +687,7 @@ describe.skipIf(!LIVE)(`live OpenCode ${OPENCODE_VERSION} smoke`, () => { ...toolchain.environment, HOME: childHome, OPENCODE_TEST_HOME: childHome, + FLOW_PICKER_FIXTURE: "fixture-resolved-value", XDG_CACHE_HOME: childCache, XDG_CONFIG_HOME: join(childHome, ".config"), XDG_DATA_HOME: join(childHome, ".local", "share"), @@ -758,6 +783,40 @@ describe.skipIf(!LIVE)(`live OpenCode ${OPENCODE_VERSION} smoke`, () => { "deny", ); } + // Exercise the real global preference API and config reload without + // creating a session, submitting a prompt, or calling a provider. + for (const selected of ["flow-probe/selected", ""]) { + const response = await fetch(`${baseUrl}/global/config`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + agent: { + "flow-reviewer": { options: { flowReviewerModel: selected } }, + }, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + expect(response.ok).toBe(true); + const expected = selected ? "selected" : "model"; + let refreshed: ResolvedAgent | undefined; + const deadline = Date.now() + 5000; + do { + refreshed = ( + (await fetchJson(`${baseUrl}/agent`)) as ResolvedAgent[] + ).find((agent) => agent.name === "flow-reviewer"); + if (refreshed?.model?.modelID === expected) break; + await Bun.sleep(50); + } while (Date.now() < deadline); + expect(refreshed?.model).toEqual({ + providerID: "flow-probe", + modelID: expected, + }); + expect(refreshed?.steps).toBe(80); + expect(refreshed?.variant ?? null).toBe(selected ? null : "high"); + expect(await readFile(globalConfig, "utf8")).toContain( + "{env:FLOW_PICKER_FIXTURE}", + ); + } await expect(lstat(join(project, ".flow"))).rejects.toMatchObject({ code: "ENOENT", }); diff --git a/tests/reviewer-picker.test.ts b/tests/reviewer-picker.test.ts new file mode 100644 index 00000000..ded2a8b7 --- /dev/null +++ b/tests/reviewer-picker.test.ts @@ -0,0 +1,247 @@ +import { expect, test } from "bun:test"; +import { resolveFlowReviewerConfiguration } from "../src/config-shared.js"; +import { createConfigHook } from "../src/platform/opencode/config.js"; +import { + applyReviewerPreference, + reviewerChoices, + reviewerPreference, + reviewerPreferencePatch, +} from "../src/platform/opencode/reviewer-picker.js"; +import type { HostConfig, Provider } from "../src/platform/opencode/sdk.js"; +import picker from "../src/tui.js"; + +const config: HostConfig = { + plugin: [ + "other-plugin", + [ + "opencode-plugin-flow@8.4.0", + { + extra: true, + reviewer: { model: "test/old", variant: "high", steps: 80 }, + }, + ], + ], +}; +const provider = { + id: "test", + name: "Test provider", + models: { + luna: { + id: "luna", + name: "Luna", + status: "active", + capabilities: { + toolcall: true, + input: { text: true }, + output: { text: true }, + }, + }, + image: { + id: "image", + name: "Images", + status: "active", + capabilities: { + toolcall: false, + input: { text: true }, + output: { text: false }, + }, + }, + }, +} as unknown as Provider; + +test("lists connected coding models without requiring a paid probe", () => { + expect( + reviewerChoices([provider], ["test"]).map((item) => item.value), + ).toEqual(["test/luna"]); + expect(reviewerChoices([provider], [])).toEqual([]); +}); +test("writes one preference without copying plugins or credentials", () => { + expect(reviewerPreferencePatch("test/luna")).toEqual({ + agent: { "flow-reviewer": { options: { flowReviewerModel: "test/luna" } } }, + }); + expect(reviewerPreferencePatch("")).toEqual({ + agent: { "flow-reviewer": { options: { flowReviewerModel: "" } } }, + }); + const base = { + model: { + kind: "explicit" as const, + source: "plugin-option" as const, + value: "test/old", + }, + variant: { + kind: "explicit" as const, + source: "environment" as const, + value: "high", + }, + steps: { kind: "host-default" as const }, + }; + expect(applyReviewerPreference(base, "test/luna")).toEqual({ + model: { kind: "explicit", source: "picker", value: "test/luna" }, + steps: base.steps, + }); + expect(applyReviewerPreference(base, "")).toBe(base); + expect( + reviewerPreference({ + agent: { + "flow-reviewer": { options: { flowReviewerModel: "test/luna" } }, + }, + }), + ).toBe("test/luna"); + expect(() => + reviewerPreference({ + agent: { "flow-reviewer": { options: { flowReviewerModel: 42 } } }, + }), + ).toThrow(); +}); + +async function host(preference?: string) { + let current = structuredClone(config); + if (preference) current.agent = reviewerPreferencePatch(preference).agent; + let connected = ["test"]; + let busy = false; + let writes = 0; + let command: { slashName: string; run(): Promise } | undefined; + let select: + | { + options: Array<{ value: string }>; + onSelect(item: { value: string }): void; + } + | undefined; + let confirm: { onConfirm(): void; onCancel(): void } | undefined; + const toasts: Array<{ variant: string; message: string }> = []; + const api = { + lifecycle: { signal: new AbortController().signal }, + state: { ready: true, path: { directory: "/fixture" } }, + keymap: { + registerLayer: ({ commands }: { commands: [typeof command] }) => { + command = commands[0]; + }, + }, + ui: { + toast: (toast: (typeof toasts)[number]) => toasts.push(toast), + dialog: { replace: (render: () => unknown) => render(), clear: () => {} }, + DialogSelect: (props: typeof select) => { + select = props; + return null; + }, + DialogConfirm: (props: typeof confirm) => { + confirm = props; + return null; + }, + }, + client: { + global: { + config: { + get: async () => ({ data: structuredClone(current) }), + update: async ({ config: next }: { config: HostConfig }) => { + writes++; + current = { ...current, ...next }; + return { data: current }; + }, + }, + }, + provider: { + list: async () => ({ data: { all: [provider], connected } }), + }, + session: { + status: async () => ({ + data: { session: { type: busy ? "busy" : "idle" } }, + }), + }, + }, + }; + await picker.tui( + api as unknown as Parameters[0], + undefined, + {} as Parameters[2], + ); + return { + open: async () => { + expect(command?.slashName).toBe("flow-reviewer"); + await command?.run(); + }, + choose: () => select?.onSelect({ value: "test/luna" }), + cancel: () => confirm?.onCancel(), + confirm: async () => { + confirm?.onConfirm(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }, + busy: () => { + busy = true; + }, + disconnect: () => { + connected = []; + }, + change: () => { + current.agent = { + "flow-reviewer": { options: { flowReviewerModel: "test/other" } }, + }; + }, + choices: () => select?.options, + writes: () => writes, + toasts, + }; +} +test("native command requires a confirmed selection before saving", async () => { + const h = await host(); + await h.open(); + h.choose(); + h.cancel(); + expect(h.writes()).toBe(0); + await h.open(); + h.choose(); + await h.confirm(); + expect(h.writes()).toBe(1); + expect(h.toasts.at(-1)?.variant).toBe("success"); +}); +test("rechecks activity, concurrent edits and provider availability before saving", async () => { + for (const condition of ["busy", "change", "disconnect"] as const) { + const h = await host(); + await h.open(); + h.choose(); + h[condition](); + await h.confirm(); + expect(h.writes()).toBe(0); + expect(h.toasts.at(-1)?.variant).toBe("error"); + } +}); + +test("server config applies the picker preference and restores original defaults", async () => { + const base = resolveFlowReviewerConfiguration({ + env: { + OPENCODE_FLOW_REVIEWER_MODEL: "test/default", + OPENCODE_FLOW_REVIEWER_VARIANT: "high", + }, + }); + let current = base; + const hook = createConfigHook( + {}, + { + reviewerConfiguration: base, + onReviewerConfiguration: (value) => { + current = value; + }, + }, + ); + const config = reviewerPreferencePatch("test/luna") as { + agent: Record; + }; + await hook(config); + expect(current.model).toMatchObject({ value: "test/luna", source: "picker" }); + expect(current.variant).toBeUndefined(); + expect(config.agent["flow-reviewer"]).toMatchObject({ model: "test/luna" }); + expect(config.agent["flow-reviewer"]).not.toHaveProperty("options"); + await hook(config); + expect(current.model).toMatchObject({ value: "test/luna" }); + await hook(reviewerPreferencePatch("")); + expect(current).toBe(base); +}); + +test("shows an unavailable saved preference explicitly", async () => { + const h = await host("disconnected/model"); + await h.open(); + expect(h.choices()).toContainEqual( + expect.objectContaining({ value: "disconnected/model", disabled: true }), + ); + expect(h.writes()).toBe(0); +}); diff --git a/tsconfig.types.json b/tsconfig.types.json index 10969888..c9534a5e 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -8,5 +8,5 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/index.ts", "src/markdown-modules.d.ts"] + "include": ["src/index.ts", "src/tui.ts", "src/markdown-modules.d.ts"] }