fix(core): cross-instance plugin tool schemas, null tool inputs, and TUI default-model display - #43535
Draft
kitlangton wants to merge 3 commits into
Draft
fix(core): cross-instance plugin tool schemas, null tool inputs, and TUI default-model display#43535kitlangton wants to merge 3 commits into
kitlangton wants to merge 3 commits into
Conversation
…ance Plugins load their own copy of effect, so their live Effect schemas cannot be interpreted by the host instance: parser sentinels and AST class identity are per-instance, making checks false-fail on valid input (bogus minLength errors) and branded IDs die as defects surfaced as bare 'Tool execution failed'. Plugin.define now converts tool input/output schemas to detached Standard Schema wrappers at the draft.add boundary, so validation and JSON Schema generation run as closures bound to the instance that created the schema. The core tool runtime detects still-foreign live schemas from older plugin packages and skips validation with a warning instead of misvalidating, and standard-schema validation errors now include the issue path.
The JSON Schema advertised for tools renders optional fields as `X | null` because JSON cannot express undefined, so callers (models, Code Mode agents) legitimately pass null meaning "omit" and were rejected with 'Expected string | undefined'. Decode now retries with null-valued object properties removed when the first attempt fails: schemas that genuinely accept null succeed on the first attempt, array elements stay positional, and the original error is reported when the retry cannot help.
Sessions created through the raw API with no model (agent: null, model: null) run fine on the server's default model, but the prompt status line rendered only the stored session model and claimed 'No provider selected - Connect a provider' even with providers connected. The session selection now falls back to GET /api/model/default, gated on the model still being available, so 'No provider selected' appears only when there genuinely is no usable provider.
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Fixes three bugs hit while driving opencode from another agent session:
session.createrejected valid input with a bogusExpected a value with a length of at least 1 at ["title"]).session.notify) hard-failed with a bareTool execution failed.Bugs 1 and 2 turned out to be the same root cause: cross-instance Effect schemas. Plugins load
effectfrom their own node_modules while the server bundles its own copy. A live Effect schema cannot be interpreted across instances, even at the identical version: the parser compiles leaf parsers through the foreign AST's methods, then compares results against its own private sentinels (sameExit, theargssymbol), so the value handed to filter checks is an internal sentinel instead of the input. Checks false-fail on valid values, and branded types throwSync adapter can only throw schema errors, which surfaces as the genericTool execution failed.Before / After
Bug 1+2, before: every
session.createcall failed withInvalid tool input: Expected a value with a length of at least 1 at ["title"]even for{ title: "probe" };session.notifywith a real session ID died as a defect and the caller saw onlyTool execution failed.After: with an updated
@opencode-ai/plugin, schemas are converted at the plugin boundary and validate correctly (including proper error paths). With an older plugin package, the server detects the foreign schema, logs a warning, and skips validation instead of misvalidating, so all eight session tools work again with a server update alone.Null smell, before: the advertised Code Mode signature says
agent?: string | null(Effect renders optional asX | nullbecause JSON cannot express undefined), but passingagent: nullfailed withExpected string | undefined at ["agent"].After: decode retries with null-valued properties removed when the first attempt fails, so
nullmeans "omitted" exactly as advertised. Schemas that genuinely accept null (Schema.NullOr) succeed on the first attempt and are unaffected; if the retry cannot help, the original error is reported.Bug 3, before: opening a session created via
POST /api/sessionwith only{title, directory}(storedmodel: null) showed "Build · No provider selected Connect a provider" and submitting opened the Connect-an-integration modal, while the run itself happily used the default model.After: the status line falls back to
GET /api/model/default(gated on the model still being in the catalog), so it shows the effective model. "No provider selected" appears only when there genuinely is no usable default.How
packages/plugin/src/effect/tool-schema.ts(new): converts tool input/output Effect schemas into detached Standard Schema wrappers (toStandardSchemaV1+toStandardJSONSchemaV1; outputs viaSchema.flipsovalidateruns in the encode direction). The wrapper's closures are bound to the instance that created the schema, so the host runs them as-is. Detachment matters: the augmented original still satisfiesSchema.isSchemaand would route back into cross-instance interpretation.packages/plugin/src/effect/plugin.ts:Plugin.definewraps the context sodraft.addconverts schemas while authoring-instance code is still on the stack. Promise plugins flow through the same path viafromPromise.packages/core/src/tool/runtime.ts:Schema.isSchemapasses butschema.ast instanceof SchemaAST.Basefails, since AST classes are plain per-instance classes) and skips validation with a warning instead of misvalidating. This keeps older plugin packages working with a server update alone.... at ["title"]).decodeInputretries with null properties stripped (object properties only; array elements are positional and untouched).packages/tui/src/context/local.tsx: session model selection falls back to amodel.defaultresource;withDefaultModelFallbackis exported for tests.sequenceDiagram participant P as Plugin (own effect copy) participant D as Plugin.define (authoring instance) participant R as Core tool runtime (host instance) P->>D: draft.add({ input: Effect schema }) D->>D: convert to detached Standard Schema wrapper D->>R: register tool Note over R: validate via wrapper closures<br/>(run in authoring instance) R->>P: execute(decoded input)Scope
effectinstance with plugins via module aliasing, is a larger architectural change and deliberately not attempted.session.notify's post-decode behavior (event subscription across instances) was not re-verified live; the reported failure was at input decode, which is fixed and tested.Testing
packages/core: full suite, 1892 pass / 0 fail. Newtest/tool-runtime-foreign-schema.test.tscopies the realeffectpackage to a temp dir and imports it as a genuinely foreign instance, reproducing both failure modes; newtest/tool-input-null.test.tscovers the null-as-omitted retry.packages/plugin: full suite. Newtest/instance-safe-tool.test.tscovers thedefineconversion (wrapper detachment, decode/encode direction, non-Effect schemas untouched).packages/tui: full suite, 736 pass / 0 fail (fixture now serves/api/model/default). New unit tests forwithDefaultModelFallback.model: nullon the live server and opened it in the dev TUI before and after the fix (screenshots below).bun typecheckinpackages/core,packages/plugin,packages/tui.Demo
Session stored with
model: null, before (claims no provider; the plugin-failed toast is unrelated local config):After (shows the server default, Gemini 3.7 Flash):