فهرست منبع

fix(tui): stabilize reload connection state

Dax Raad 1 ماه پیش
والد
کامیت
8dd993d25a

+ 16 - 0
.opencode/plugins/sample-agent.ts

@@ -0,0 +1,16 @@
+export default {
+  id: "sample-agent-plugin",
+  setup: async (ctx) => {
+    await ctx.agent.transform((agents) => {
+      agents.update("sample-plugin-agent", (agent) => {
+        agent.description = "Example subagent registered by .opencode/plugins/sample-agent.ts"
+        agent.mode = "subagent"
+        agent.prompt = [
+          "You are the sample plugin agent for this repository.",
+          "Use this agent to verify that local plugin auto-discovery can add agents.",
+          "Keep responses concise and explain which plugin registered you when asked.",
+        ].join("\n")
+      })
+    })
+  },
+}

+ 1 - 1
packages/cli/src/commands/handlers/serve.ts

@@ -38,7 +38,7 @@ export default Runtime.handler(
           createOpencodeClient({
             baseUrl: HttpServer.formatAddress(address),
             headers: ServerAuth.headers({ password }),
-          }).v2.location.get(undefined, { throwOnError: true }),
+          }).v2.health.get({}),
         )
         if (input.service) yield* daemon.register(address)
         const url = HttpServer.formatAddress(address)

+ 43 - 0
packages/core/src/config/plugin/external.ts

@@ -29,6 +29,12 @@ const PluginModule = Schema.Struct({
   ]),
 })
 
+const PluginPackage = Schema.Struct({
+  exports: Schema.optional(Schema.Unknown),
+  main: Schema.optional(Schema.String),
+  module: Schema.optional(Schema.String),
+})
+
 export const Plugin = define({
   id: "config-plugin",
   effect: Effect.fn(function* (ctx) {
@@ -65,8 +71,30 @@ export const Plugin = define({
               symlink: true,
             })
             .pipe(Effect.orElseSucceed(() => []))
+          const directories = yield* fs
+            .glob("{plugin,plugins}/*", {
+              cwd: entry.path,
+              absolute: true,
+              include: "all",
+              dot: true,
+              symlink: true,
+            })
+            .pipe(
+              Effect.flatMap((items) =>
+                Effect.filter(items, (item) => fs.isDir(item), {
+                  concurrency: "unbounded",
+                }),
+              ),
+              Effect.orElseSucceed(() => []),
+            )
+          const packages = yield* Effect.forEach(
+            directories.sort(),
+            (directory) => resolvePackageEntrypoint(fs, directory),
+            { concurrency: "unbounded" },
+          ).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
           files.sort()
           for (const file of files) configured.push({ package: file })
+          for (const file of packages) configured.push({ package: file })
         }
       }
 
@@ -89,3 +117,18 @@ export const Plugin = define({
     })
   }),
 })
+
+const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
+  const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
+    Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
+    Effect.catch(() => Effect.succeed(undefined)),
+  )
+  const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
+  const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
+
+  return yield* Effect.forEach(entries, (entry) => {
+    if (!entry) return Effect.succeed(undefined)
+    const file = path.resolve(directory, entry)
+    return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
+  }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
+})

+ 8 - 2
packages/core/src/observability/logging.ts

@@ -1,6 +1,7 @@
 import { Formatter, Logger, type LogLevel } from "effect"
 import path from "path"
 import { Global } from "../global"
+import { InstallationChannel, InstallationLocal } from "../installation/version"
 import { runID } from "./shared"
 
 function formatter(id: string = runID) {
@@ -46,9 +47,14 @@ function format(input: unknown) {
   return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
 }
 
-export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
+export function file(local = InstallationLocal, channel = InstallationChannel) {
+  if (!local) return path.join(Global.Path.log, "opencode.log")
+  return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
+}
+
+export function fileLogger(target = file(), id: string = runID) {
   // Do not set batchWindow to 0; it causes high idle CPU usage.
-  return Logger.toFile(formatter(id), file, { flag: "a" })
+  return Logger.toFile(formatter(id), target, { flag: "a" })
 }
 
 const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))

+ 11 - 1
packages/core/src/plugin/models-dev.ts

@@ -1,4 +1,5 @@
 import { define } from "./internal"
+import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
 import { Effect, Stream } from "effect"
 import { EventV2 } from "../event"
 import { ModelV2 } from "../model"
@@ -45,6 +46,15 @@ function variants(model: ModelsDev.Model) {
   }))
 }
 
+function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) {
+  const existing = new Map(model.variants.map((variant) => [variant.id, variant]))
+  const nextIDs = new Set(next.map((variant) => variant.id))
+  model.variants = [
+    ...next.map((variant) => existing.get(variant.id) ?? variant),
+    ...model.variants.filter((variant) => !nextIDs.has(variant.id)),
+  ]
+}
+
 export const ModelsDevPlugin = define({
   id: "models-dev",
   effect: Effect.fn(function* (ctx) {
@@ -111,7 +121,7 @@ export const ModelsDevPlugin = define({
                 input: [...(model.modalities?.input ?? [])],
                 output: [...(model.modalities?.output ?? [])],
               }
-              draft.variants = variants(model)
+              mergeVariants(draft, variants(model))
               draft.time.released = released(model.release_date)
               draft.cost = cost(model.cost)
               draft.status = model.status ?? "active"

+ 10 - 5
packages/core/src/plugin/provider/opencode.ts

@@ -142,11 +142,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
             Object.assign(model.request.headers, config.headers)
             Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options)))
             if (config.variants !== undefined) {
-              model.variants = Object.entries(config.variants).map(([id, options]) => ({
-                id: ModelV2.VariantID.make(id),
-                headers: { ...(options.headers ?? {}) },
-                body: lowerer.request(withoutCredentials(options)),
-              }))
+              for (const [id, options] of Object.entries(config.variants)) {
+                const variantID = ModelV2.VariantID.make(id)
+                let existing = model.variants.find((item) => item.id === variantID)
+                if (!existing) {
+                  existing = { id: variantID, headers: {}, body: {} }
+                  model.variants.push(existing)
+                }
+                Object.assign(existing.headers, options.headers)
+                Object.assign(existing.body, lowerer.request(withoutCredentials(options)))
+              }
             }
             if (config.release_date !== undefined) {
               const released = Date.parse(config.release_date)

+ 13 - 0
packages/core/test/config/fixtures/plugins/folder-plugin/index.ts

@@ -0,0 +1,13 @@
+import { define } from "@opencode-ai/plugin/v2/promise"
+
+export default define({
+  id: "folder-plugin",
+  setup: async (ctx) => {
+    await ctx.agent.transform((agents) => {
+      agents.update("folder", (agent) => {
+        agent.description = "Loaded from plugin folder"
+        agent.mode = "subagent"
+      })
+    })
+  },
+})

+ 4 - 0
packages/core/test/config/plugin.test.ts

@@ -234,6 +234,10 @@ describe("ConfigExternalPlugin", () => {
         description: "Loaded from plugin directory",
         mode: "subagent",
       })
+      expect(yield* waitForAgent(agents, "folder")).toMatchObject({
+        description: "Loaded from plugin folder",
+        mode: "subagent",
+      })
     }),
   )
 })

+ 14 - 0
packages/core/test/logging.test.ts

@@ -0,0 +1,14 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Global } from "@opencode-ai/core/global"
+import { Logging } from "@opencode-ai/core/observability/logging"
+
+describe("Logging", () => {
+  test("uses a local-specific log file for local installs", () => {
+    expect(Logging.file(true, "local")).toBe(path.join(Global.Path.log, "opencode-local.log"))
+  })
+
+  test("keeps non-local installs on the default log file", () => {
+    expect(Logging.file(false, "next")).toBe(path.join(Global.Path.log, "opencode.log"))
+  })
+})

+ 22 - 1
packages/core/test/plugin/fixtures/models-dev.json

@@ -9,6 +9,27 @@
     "id": "local",
     "name": "Local",
     "env": [],
-    "models": {}
+    "models": {
+      "model": {
+        "id": "model",
+        "name": "Local Model",
+        "release_date": "2026-01-01",
+        "attachment": false,
+        "reasoning": true,
+        "temperature": true,
+        "tool_call": true,
+        "limit": { "context": 1000, "output": 100 },
+        "experimental": {
+          "modes": {
+            "high": {
+              "provider": { "body": { "reasoning_effort": "high" } }
+            },
+            "max": {
+              "provider": { "headers": { "x-variant": "max" }, "body": { "reasoning_effort": "max" } }
+            }
+          }
+        }
+      }
+    }
   }
 }

+ 46 - 0
packages/core/test/plugin/models-dev.test.ts

@@ -7,9 +7,11 @@ import { Credential } from "@opencode-ai/core/credential"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Location } from "@opencode-ai/core/location"
+import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
 import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
 import { Policy } from "@opencode-ai/core/policy"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
@@ -73,4 +75,48 @@ describe("ModelsDevPlugin", () => {
         }),
     ),
   )
+
+  it.effect("loads models.dev variants without replacing existing variants", () =>
+    Effect.acquireUseRelease(
+      Effect.sync(() => {
+        const previous = {
+          path: Flag.OPENCODE_MODELS_PATH,
+          disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
+        }
+        Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
+        Flag.OPENCODE_DISABLE_MODELS_FETCH = true
+        return previous
+      }),
+      () =>
+        Effect.gen(function* () {
+          const service = yield* Catalog.Service
+          const integrations = yield* Integration.Service
+          yield* service.transform((catalog) => {
+            catalog.model.update(ProviderV2.ID.make("local"), ModelV2.ID.make("model"), (model) => {
+              model.variants = [
+                { id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} },
+                { id: ModelV2.VariantID.make("custom"), headers: {}, body: { custom: true } },
+              ]
+            })
+          })
+          yield* ModelsDevPlugin.effect(
+            host({
+              catalog: catalogHost(service),
+              integration: integrationHost(integrations),
+            }),
+          )
+
+          expect((yield* service.model.get(ProviderV2.ID.make("local"), ModelV2.ID.make("model")))?.variants).toEqual([
+            expect.objectContaining({ id: "high", headers: { custom: "true" } }),
+            expect.objectContaining({ id: "max", headers: { "x-variant": "max" }, body: { reasoning_effort: "max" } }),
+            expect.objectContaining({ id: "custom", body: { custom: true } }),
+          ])
+        }).pipe(Effect.provide(ModelsDev.defaultLayer)),
+      (previous) =>
+        Effect.sync(() => {
+          Flag.OPENCODE_MODELS_PATH = previous.path
+          Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
+        }),
+    ),
+  )
 })

+ 14 - 0
packages/core/test/plugin/provider-opencode.test.ts

@@ -133,6 +133,15 @@ describe("OpencodePlugin", () => {
           const catalog = yield* Catalog.Service
           yield* catalog.transform((draft) => {
             draft.provider.update(ProviderV2.ID.make("remote"), () => {})
+            draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"), (model) => {
+              model.variants = [
+                {
+                  id: ModelV2.VariantID.make("custom"),
+                  headers: { "x-custom": "true" },
+                  body: { custom: true },
+                },
+              ]
+            })
             draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
           })
           yield* credentials.create({
@@ -176,6 +185,11 @@ describe("OpencodePlugin", () => {
           })
           expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 })
           expect(model.variants).toEqual([
+            {
+              id: ModelV2.VariantID.make("custom"),
+              headers: { "x-custom": "true" },
+              body: { custom: true },
+            },
             {
               id: ModelV2.VariantID.make("high"),
               headers: {},

+ 1 - 1
packages/tui/src/app.tsx

@@ -1148,7 +1148,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
       <Show when={!startup.skipInitialLoading}>
         <StartupLoading ready={ready} />
       </Show>
-      <Show when={sdk.connection.status() === "reconnecting"}>
+      <Show when={sdk.connection.status() === "connecting"}>
         <Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
       </Show>
     </box>

+ 16 - 4
packages/tui/src/context/data.tsx

@@ -93,6 +93,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       directory: process.cwd(),
     })
     const messageIndex = new Map<string, Map<string, number>>()
+    let bootstrapping: Promise<void> | undefined
 
     const message = {
       update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
@@ -543,6 +544,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
         // so the mcp list refreshes here rather than off integration.updated.
         case "mcp.status.changed":
+          if (bootstrapping) break
           void result.location.mcp.refresh(event.location)
           break
       }
@@ -750,7 +752,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
     }
 
     async function bootstrap() {
-      const settled = await Promise.allSettled([
+      if (bootstrapping) return bootstrapping
+      bootstrapping = Promise.allSettled([
         sdk.api.session
           .list({
             limit: 50,
@@ -787,14 +790,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         result.location.skill.refresh(),
         result.shell.refresh(),
       ])
-      for (const failure of settled.filter((item) => item.status === "rejected"))
-        console.error("Failed to refresh default location data", failure.reason)
+        .then((settled) => {
+          for (const failure of settled.filter((item) => item.status === "rejected"))
+            console.error("Failed to refresh default location data", failure.reason)
+        })
+        .finally(() => {
+          bootstrapping = undefined
+        })
+      return bootstrapping
     }
 
     onCleanup(
       sdk.event.listen(({ details }) => {
+        if (details.type === "server.connected") {
+          void bootstrap()
+          return
+        }
         handleEvent(details)
-        if (details.type === "server.connected") void bootstrap()
       }),
     )
 

+ 4 - 3
packages/tui/src/context/sdk.tsx

@@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 
-export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting"
+export type SDKConnectionStatus = "connected" | "connecting"
 
 type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
 const connectTimeout = 2_000
@@ -64,10 +64,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
               return connection.signal.reason instanceof Error
                 ? connection.signal.reason
                 : new Error("Event stream disconnected")
+            if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected")
             clearTimeout(timeout)
             attempt = 0
-            setConnection({ status: "connected", attempt: 0, error: undefined })
             events.emit(first.value.type, first.value)
+            setConnection({ status: "connected", attempt: 0, error: undefined })
             connected()
             while (!abort.signal.aborted && !controller.signal.aborted) {
               const event = await iterator.next()
@@ -84,7 +85,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
           if (abort.signal.aborted || controller.signal.aborted) return
           attempt += 1
           setConnection({
-            status: "reconnecting",
+            status: "connecting",
             attempt,
             error: error instanceof Error ? error.message : String(error),
           })

+ 1 - 1
packages/tui/test/cli/tui/data.test.tsx

@@ -155,7 +155,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
     expect(data.connection.attempt()).toBe(0)
 
     events.disconnect()
-    await wait(() => data.connection.status() === "reconnecting")
+    await wait(() => data.connection.status() === "connecting")
     expect(data.connection.attempt()).toBe(1)
     expect(data.connection.error()).toBe("Event stream disconnected")