Просмотр исходного кода

fix(tui): resolve sidebar models over the host SDK on remote attach (#1184)

* fix(tui): resolve sidebar models over the host SDK on remote attach

When the TUI attaches to a remote `opencode serve`, tui-state.json lives
on the server filesystem and the client always reads an empty snapshot,
so every agent model renders as "pending". Fall back to the host agent
list over the TUI client: v1 `app.agents({ directory })`, v2
`agent.list({ location: { directory } })`. Local snapshot entries still
win. Cache hits indefinitely; empty misses retry after 5s.

Refs #1133

* fix(tui): serialize remote sidebar refreshes

A slow host agent-list fetch could overlap the 1s poll and commit an
older snapshot after a newer one. Skip in-flight refreshes and drop a
result whose directory changed while waiting.

Refs #1133
Raxxoor 3 дней назад
Родитель
Сommit
c642c19334
2 измененных файлов с 373 добавлено и 31 удалено
  1. 148 0
      src/tui.test.ts
  2. 225 31
      src/tui.ts

+ 148 - 0
src/tui.test.ts

@@ -7,10 +7,14 @@ import { testRender } from '@opentui/solid';
 import { readTmuxPane } from './multiplexer/tmux-pane-registry';
 import {
   type ActiveTmuxPaneRegistration,
+  applyRemoteAgentModels,
+  createSerializedRefresh,
+  fetchRemoteAgentModels,
   getActiveSidebarAgentNames,
   getContrastForeground,
   getSidebarActivityIndicator,
   getSidebarAgentNames,
+  isRefreshCurrent,
   readCompactSidebar,
   readConfigInvalid,
   splitSidebarModelId,
@@ -52,6 +56,150 @@ describe('tui sidebar agents', () => {
     expect(agentNames).not.toContain('librarian');
   });
 
+  test('fills empty snapshot models from the v1 host agent list (#1133)', async () => {
+    const seen: unknown[] = [];
+    const client = {
+      app: {
+        async agents(input?: unknown) {
+          seen.push(input);
+          return {
+            data: [
+              {
+                name: 'explorer',
+                model: { providerID: 'openai', modelID: 'gpt-5.6-luna' },
+              },
+              {
+                name: 'fixer',
+                model: { providerID: 'openai', modelID: 'gpt-5.6' },
+              },
+              { name: 'unrelated', model: { providerID: 'x', modelID: 'y' } },
+              { name: 'oracle' },
+            ],
+          };
+        },
+      },
+    };
+
+    const remote = await fetchRemoteAgentModels(client, '/tmp/project');
+    expect(seen).toEqual([{ directory: '/tmp/project' }]);
+    expect(remote).toEqual({
+      explorer: 'openai/gpt-5.6-luna',
+      fixer: 'openai/gpt-5.6',
+    });
+
+    const merged = applyRemoteAgentModels(
+      createSnapshot({ agentModels: { explorer: 'local/model' } }),
+      remote,
+    );
+    expect(merged.agentModels).toEqual({
+      explorer: 'local/model',
+      fixer: 'openai/gpt-5.6',
+    });
+  });
+
+  test('fills models from the v2 agent.list contract (#1133)', async () => {
+    const seen: unknown[] = [];
+    const client = {
+      agent: {
+        async list(input?: unknown) {
+          seen.push(input);
+          return {
+            data: {
+              data: [
+                {
+                  id: 'explorer',
+                  model: { providerID: 'openai', id: 'gpt-5.6-luna' },
+                },
+                {
+                  id: 'fixer',
+                  model: { providerID: 'openai', id: 'gpt-5.6' },
+                },
+                { id: 'unrelated', model: { providerID: 'x', id: 'y' } },
+                { id: 'oracle' },
+              ],
+            },
+          };
+        },
+      },
+    };
+
+    const remote = await fetchRemoteAgentModels(client, '/srv/project');
+    expect(seen).toEqual([{ location: { directory: '/srv/project' } }]);
+    expect(remote).toEqual({
+      explorer: 'openai/gpt-5.6-luna',
+      fixer: 'openai/gpt-5.6',
+    });
+  });
+
+  test('fills models from nested v2.agent.list (#1133)', async () => {
+    const seen: unknown[] = [];
+    const client = {
+      v2: {
+        agent: {
+          async list(input?: unknown) {
+            seen.push(input);
+            return {
+              data: {
+                location: { directory: '/srv/project' },
+                data: [
+                  {
+                    id: 'explorer',
+                    model: { providerID: 'openai', id: 'gpt-5.6-luna' },
+                  },
+                ],
+              },
+            };
+          },
+        },
+      },
+    };
+
+    const remote = await fetchRemoteAgentModels(client, '/srv/project');
+    expect(seen).toEqual([{ location: { directory: '/srv/project' } }]);
+    expect(remote).toEqual({ explorer: 'openai/gpt-5.6-luna' });
+  });
+
+  test('remote model fetch is a no-op without a host client', async () => {
+    expect(await fetchRemoteAgentModels(undefined, '/tmp/project')).toEqual({});
+    expect(applyRemoteAgentModels(createSnapshot({}), {}).agentModels).toEqual(
+      {},
+    );
+  });
+
+  test('serialized refresh skips overlap and drops a stale directory (#1133)', async () => {
+    expect(isRefreshCurrent('/a', '/a')).toBe(true);
+    expect(isRefreshCurrent('/a', '/b')).toBe(false);
+
+    let running = 0;
+    let started = 0;
+    let finished = 0;
+    const release: Array<() => void> = [];
+    const schedule = createSerializedRefresh(async () => {
+      started += 1;
+      running += 1;
+      await new Promise<void>((resolve) => {
+        release.push(() => {
+          running -= 1;
+          finished += 1;
+          resolve();
+        });
+      });
+    });
+
+    schedule();
+    schedule();
+    expect(started).toBe(1);
+    expect(running).toBe(1);
+    release[0]?.();
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    expect(finished).toBe(1);
+    schedule();
+    expect(started).toBe(2);
+    release[1]?.();
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    expect(finished).toBe(2);
+  });
+
   test('uses default-enabled fallback before models are persisted', () => {
     const agentNames = getSidebarAgentNames(createSnapshot({}));
 

+ 225 - 31
src/tui.ts

@@ -7,7 +7,11 @@ import { type ColorInput, parseColor, RGBA } from '@opentui/core';
 import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
 import { createSignal } from 'solid-js';
-import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
+import {
+  ALL_AGENT_NAMES,
+  DEFAULT_DISABLED_AGENTS,
+  SUBAGENT_NAMES,
+} from './config/constants';
 import { loadPluginConfig } from './config/loader';
 import {
   recordTmuxPane,
@@ -197,6 +201,174 @@ export function getSidebarAgentNames(snapshot: TuiSnapshot): string[] {
     : FALLBACK_SIDEBAR_AGENTS;
 }
 
+type AgentListFn = (input?: unknown) => Promise<unknown>;
+
+function asFunction(value: unknown): AgentListFn | undefined {
+  return typeof value === 'function' ? (value as AgentListFn) : undefined;
+}
+
+function unwrapAgentList(response: unknown): unknown[] {
+  if (Array.isArray(response)) return response;
+  if (!response || typeof response !== 'object') return [];
+  const data = (response as { data?: unknown }).data;
+  if (Array.isArray(data)) return data;
+  if (data && typeof data === 'object') {
+    const nested = (data as { data?: unknown }).data;
+    if (Array.isArray(nested)) return nested;
+  }
+  return [];
+}
+
+function remoteAgentName(entry: unknown): string | undefined {
+  if (!entry || typeof entry !== 'object') return undefined;
+  const rec = entry as { name?: unknown; id?: unknown };
+  if (typeof rec.name === 'string') return rec.name;
+  if (typeof rec.id === 'string') return rec.id;
+  return undefined;
+}
+
+function remoteModelId(model: unknown): string | undefined {
+  if (!model || typeof model !== 'object') return undefined;
+  const rec = model as {
+    providerID?: unknown;
+    modelID?: unknown;
+    id?: unknown;
+  };
+  if (typeof rec.providerID !== 'string') return undefined;
+  const id =
+    typeof rec.modelID === 'string'
+      ? rec.modelID
+      : typeof rec.id === 'string'
+        ? rec.id
+        : undefined;
+  return id ? `${rec.providerID}/${id}` : undefined;
+}
+
+function modelsFromAgentList(response: unknown): Record<string, string> {
+  const models: Record<string, string> = {};
+  for (const entry of unwrapAgentList(response)) {
+    const name = remoteAgentName(entry);
+    const model = remoteModelId(
+      (entry as { model?: unknown } | undefined)?.model,
+    );
+    if (!name || !model) continue;
+    if ((ALL_AGENT_NAMES as readonly string[]).includes(name)) {
+      models[name] = model;
+    }
+  }
+  return models;
+}
+
+/**
+ * Remote-attach fallback (#1133): the server-side plugin writes
+ * tui-state.json on the server's filesystem, which a remote TUI cannot
+ * see, so every model renders as "pending". Resolve agent models through
+ * the host SDK instead. Only fills gaps — local snapshot entries win.
+ *
+ * v1 TUI (`api.client`, `@opencode-ai/sdk/v2`): `app.agents({ directory })`
+ * with `{ name, model: { providerID, modelID } }`.
+ * v2 TUI: `agent.list({ location: { directory } })` or
+ * `v2.agent.list(...)` with `{ id, model: { providerID, id } }`.
+ */
+export async function fetchRemoteAgentModels(
+  client: unknown,
+  directory: string,
+): Promise<Record<string, string>> {
+  const rec = client as
+    | {
+        app?: { agents?: unknown };
+        agent?: { list?: unknown };
+        v2?: { agent?: { list?: unknown } };
+      }
+    | undefined;
+  if (!rec) return {};
+
+  try {
+    const v1Agents = asFunction(rec.app?.agents);
+    if (v1Agents) {
+      return modelsFromAgentList(await v1Agents.call(rec.app, { directory }));
+    }
+    const v2Receiver = rec.agent ?? rec.v2?.agent;
+    const v2List = asFunction(v2Receiver?.list);
+    if (!v2List) return {};
+    return modelsFromAgentList(
+      await v2List.call(v2Receiver, { location: { directory } }),
+    );
+  } catch {
+    return {};
+  }
+}
+
+/** Local snapshot entries win; remote fills empty/missing agent models (#1133). */
+export function applyRemoteAgentModels(
+  snapshot: TuiSnapshot,
+  remote: Record<string, string>,
+): TuiSnapshot {
+  if (Object.keys(remote).length === 0) return snapshot;
+  return {
+    ...snapshot,
+    agentModels: { ...remote, ...snapshot.agentModels },
+  };
+}
+
+const REMOTE_RETRY_MS = 5_000;
+
+interface RemoteModelCache {
+  directory?: string;
+  models?: Record<string, string>;
+  at?: number;
+}
+
+async function hydrateRemoteModels(
+  snapshot: TuiSnapshot,
+  client: unknown,
+  directory: string,
+  cache: RemoteModelCache,
+): Promise<TuiSnapshot> {
+  if (Object.keys(snapshot.agentModels).length > 0) return snapshot;
+  const now = Date.now();
+  const cached =
+    cache.directory === directory && cache.models !== undefined
+      ? cache.models
+      : undefined;
+  const cacheFresh =
+    cached !== undefined &&
+    (Object.keys(cached).length > 0 ||
+      (cache.at !== undefined && now - cache.at < REMOTE_RETRY_MS));
+  if (cached !== undefined && cacheFresh) {
+    return applyRemoteAgentModels(snapshot, cached);
+  }
+  const models = await fetchRemoteAgentModels(client, directory);
+  cache.directory = directory;
+  cache.models = models;
+  cache.at = now;
+  return applyRemoteAgentModels(snapshot, models);
+}
+
+/** Skip overlapping sidebar refreshes so a slow host fetch cannot pile up. */
+export function createSerializedRefresh(run: () => Promise<void>): () => void {
+  let inFlight = false;
+  return () => {
+    if (inFlight) return;
+    inFlight = true;
+    void run()
+      .catch(() => {
+        // Ignore render errors; this is best-effort live status.
+      })
+      .finally(() => {
+        inFlight = false;
+      });
+  };
+}
+
+/** Drop a refresh whose directory changed while the host fetch was in flight. */
+export function isRefreshCurrent(
+  startedDirectory: string,
+  currentDirectory: string,
+): boolean {
+  return startedDirectory === currentDirectory;
+}
+
 export function getActiveSidebarAgentNames(
   snapshot: TuiSnapshot,
 ): ReadonlySet<string> {
@@ -499,6 +671,7 @@ interface V2TuiSlotClaim {
 
 interface V2TuiContext {
   location?: { directory: string };
+  client?: unknown;
   renderer: { requestRender: () => void };
   theme: V2TuiThemeTokens;
   ui: {
@@ -544,23 +717,38 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
   };
   syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
   let disposed = false;
-  const renderTimer = setInterval(async () => {
+  const remoteCache: RemoteModelCache = {};
+  const refreshSidebar = async () => {
     if (disposed) return;
-    try {
-      const currentDirectory = ctx.location?.directory ?? process.cwd();
-      syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
-      const nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
-      if (disposed) return;
-      if (currentDirectory !== configDirectory) {
-        configDirectory = currentDirectory;
-        ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
-      }
-      setSnapshot(nextSnapshot);
-      ctx.renderer.requestRender();
-    } catch {
-      // Ignore render errors; this is best-effort live status.
+    const currentDirectory = ctx.location?.directory ?? process.cwd();
+    syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
+    let nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
+    if (disposed) return;
+    if (currentDirectory !== configDirectory) {
+      configDirectory = currentDirectory;
+      ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
     }
-  }, 1000);
+    nextSnapshot = await hydrateRemoteModels(
+      nextSnapshot,
+      ctx.client,
+      currentDirectory,
+      remoteCache,
+    );
+    if (disposed) return;
+    if (
+      !isRefreshCurrent(
+        currentDirectory,
+        ctx.location?.directory ?? process.cwd(),
+      )
+    ) {
+      return;
+    }
+    setSnapshot(nextSnapshot);
+    ctx.renderer.requestRender();
+  };
+  const scheduleRefresh = createSerializedRefresh(refreshSidebar);
+  scheduleRefresh();
+  const renderTimer = setInterval(scheduleRefresh, 1000);
   const animationTimer = setInterval(() => {
     if (!disposed && Object.keys(snapshot().activeSessions).length > 0) {
       setAnimationNow(Date.now());
@@ -644,22 +832,28 @@ const plugin: TuiDualContractModule = {
       lastRecordedAt: 0,
     };
     syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
-    const renderTimer = setInterval(async () => {
-      try {
-        const currentDirectory = getTuiDirectory(api);
-        syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
-        const nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
-        if (currentDirectory !== configDirectory) {
-          configDirectory = currentDirectory;
-          ({ configInvalid, compactSidebar } =
-            readConfigState(configDirectory));
-        }
-        setSnapshot(nextSnapshot);
-        api.renderer.requestRender();
-      } catch {
-        // Ignore render errors; this is best-effort live status.
+    const remoteCache: RemoteModelCache = {};
+    const refreshSidebar = async () => {
+      const currentDirectory = getTuiDirectory(api);
+      syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
+      let nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
+      if (currentDirectory !== configDirectory) {
+        configDirectory = currentDirectory;
+        ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
       }
-    }, 1000);
+      nextSnapshot = await hydrateRemoteModels(
+        nextSnapshot,
+        (api as { client?: unknown }).client,
+        currentDirectory,
+        remoteCache,
+      );
+      if (!isRefreshCurrent(currentDirectory, getTuiDirectory(api))) return;
+      setSnapshot(nextSnapshot);
+      api.renderer.requestRender();
+    };
+    const scheduleRefresh = createSerializedRefresh(refreshSidebar);
+    scheduleRefresh();
+    const renderTimer = setInterval(scheduleRefresh, 1000);
     const animationTimer = setInterval(() => {
       if (Object.keys(snapshot().activeSessions).length > 0) {
         setAnimationNow(Date.now());