Forráskód Böngészése

fix: scope tui-state.json per project to stop model override leaks

Runtime /model overrides were written to a single global
tui-state.json, leaking across projects and causing
"Model not found" when providers differ (issue #741).

Hash the resolved project directory into the state path so each
project gets its own tui-state.json.
Michael Henke 3 hete
szülő
commit
7c83f4e433
6 módosított fájl, 139 hozzáadás és 87 törlés
  1. 15 9
      src/index.ts
  2. 16 10
      src/tools/preset-manager.test.ts
  3. 2 2
      src/tools/preset-manager.ts
  4. 52 42
      src/tui-state.test.ts
  5. 52 22
      src/tui-state.ts
  6. 2 2
      src/tui.ts

+ 15 - 9
src/index.ts

@@ -772,10 +772,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           tuiAgentVariants[agentDef.name] = resolvedVariant;
         }
       }
-      recordTuiAgentModels({
-        agentModels: tuiAgentModels,
-        agentVariants: tuiAgentVariants,
-      });
+      recordTuiAgentModels(
+        {
+          agentModels: tuiAgentModels,
+          agentVariants: tuiAgentVariants,
+        },
+        ctx.directory,
+      );
 
       applyOrchestratorModelConfig({
         agents: configAgent,
@@ -888,11 +891,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           const agentName = resolveRuntimeAgentName(config, info.agent);
           const model = `${providerID}/${modelID}`;
           const variant = resolveTuiVariantForModel(agentName, model);
-          recordTuiAgentModel({
-            agentName,
-            model,
-            variant: variant ?? null,
-          });
+          recordTuiAgentModel(
+            {
+              agentName,
+              model,
+              variant: variant ?? null,
+            },
+            ctx.directory,
+          );
         }
       }
 

+ 16 - 10
src/tools/preset-manager.test.ts

@@ -185,12 +185,15 @@ describe('createPresetManager', () => {
     });
 
     test('updates the TUI snapshot after a successful preset switch', async () => {
-      recordTuiAgentModels({
-        agentModels: {
-          explorer: 'openai/gpt-5.6-luna',
-          fixer: 'openai/gpt-5.6-luna',
+      recordTuiAgentModels(
+        {
+          agentModels: {
+            explorer: 'openai/gpt-5.6-luna',
+            fixer: 'openai/gpt-5.6-luna',
+          },
         },
-      });
+        tempDir,
+      );
 
       const ctx = createMockContext();
       const config: PluginConfig = {
@@ -209,7 +212,7 @@ describe('createPresetManager', () => {
         output,
       );
 
-      expect(readTuiSnapshot().agentModels).toEqual({
+      expect(readTuiSnapshot(tempDir).agentModels).toEqual({
         explorer: 'openai/gpt-5.6',
         fixer: 'openai/gpt-5.6-luna',
         orchestrator: 'anthropic/claude-3.5-haiku',
@@ -354,11 +357,14 @@ describe('createPresetManager', () => {
 
     test('unknown preset does not change active state or dispose instance', async () => {
       setActiveRuntimePreset('cheap');
-      recordTuiAgentModels({
-        agentModels: {
-          explorer: 'openai/gpt-5.6-luna',
+      recordTuiAgentModels(
+        {
+          agentModels: {
+            explorer: 'openai/gpt-5.6-luna',
+          },
         },
-      });
+        tempDir,
+      );
 
       const ctx = createMockContext();
       const config: PluginConfig = {

+ 2 - 2
src/tools/preset-manager.ts

@@ -177,7 +177,7 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
       // Non-critical: runtime state is set regardless
     }
 
-    const snapshot = readTuiSnapshot();
+    const snapshot = readTuiSnapshot(ctx.directory);
     const agentModels = { ...snapshot.agentModels };
     const agentVariants = { ...snapshot.agentVariants };
     for (const [agentName, agentConfig] of Object.entries(agentUpdates)) {
@@ -191,7 +191,7 @@ export function createPresetManager(ctx: PluginInput, config: PluginConfig) {
       }
     }
 
-    recordTuiAgentModels({ agentModels, agentVariants });
+    recordTuiAgentModels({ agentModels, agentVariants }, ctx.directory);
 
     activePreset = presetName;
 

+ 52 - 42
src/tui-state.test.ts

@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import {
+  getTuiStatePath,
   readTuiSnapshot,
   recordTuiAgentModel,
   recordTuiAgentModels,
@@ -29,18 +30,21 @@ afterEach(() => {
 
 describe('tui-state persistence', () => {
   test('persists enabled agent models', () => {
-    recordTuiAgentModels({
-      agentModels: {
-        explorer: 'openai/gpt-5.6-luna',
-        fixer: 'openai/gpt-5.6-luna',
+    recordTuiAgentModels(
+      {
+        agentModels: {
+          explorer: 'openai/gpt-5.6-luna',
+          fixer: 'openai/gpt-5.6-luna',
+        },
+        agentVariants: {
+          explorer: 'low',
+          fixer: 'high',
+        },
       },
-      agentVariants: {
-        explorer: 'low',
-        fixer: 'high',
-      },
-    });
+      tempDir,
+    );
 
-    const snapshot = readTuiSnapshot();
+    const snapshot = readTuiSnapshot(tempDir);
 
     expect(snapshot.agentModels).toEqual({
       explorer: 'openai/gpt-5.6-luna',
@@ -53,55 +57,61 @@ describe('tui-state persistence', () => {
   });
 
   test('updates a single live agent model without dropping others', () => {
-    recordTuiAgentModels({
-      agentModels: {
-        orchestrator: 'default',
-        explorer: 'openai/gpt-5.6-luna',
+    recordTuiAgentModels(
+      {
+        agentModels: {
+          orchestrator: 'default',
+          explorer: 'openai/gpt-5.6-luna',
+        },
       },
-    });
+      tempDir,
+    );
 
-    recordTuiAgentModel({
-      agentName: 'orchestrator',
-      model: 'openai/gpt-5.6',
-    });
+    recordTuiAgentModel(
+      {
+        agentName: 'orchestrator',
+        model: 'openai/gpt-5.6',
+      },
+      tempDir,
+    );
 
-    expect(readTuiSnapshot().agentModels).toEqual({
+    expect(readTuiSnapshot(tempDir).agentModels).toEqual({
       orchestrator: 'openai/gpt-5.6',
       explorer: 'openai/gpt-5.6-luna',
     });
   });
 
   test('updates a single live agent variant without dropping others', () => {
-    recordTuiAgentModels({
-      agentModels: {
-        orchestrator: 'default',
-        explorer: 'openai/gpt-5.6-luna',
+    recordTuiAgentModels(
+      {
+        agentModels: {
+          orchestrator: 'default',
+          explorer: 'openai/gpt-5.6-luna',
+        },
+        agentVariants: {
+          explorer: 'low',
+        },
       },
-      agentVariants: {
-        explorer: 'low',
-      },
-    });
+      tempDir,
+    );
 
-    recordTuiAgentModel({
-      agentName: 'orchestrator',
-      model: 'openai/gpt-5.6',
-      variant: 'high',
-    });
+    recordTuiAgentModel(
+      {
+        agentName: 'orchestrator',
+        model: 'openai/gpt-5.6',
+        variant: 'high',
+      },
+      tempDir,
+    );
 
-    expect(readTuiSnapshot().agentVariants).toEqual({
+    expect(readTuiSnapshot(tempDir).agentVariants).toEqual({
       orchestrator: 'high',
       explorer: 'low',
     });
   });
 
   test('ignores legacy config status fields in old snapshots', () => {
-    const filePath = path.join(
-      tempDir,
-      'opencode',
-      'storage',
-      'oh-my-opencode-slim',
-      'tui-state.json',
-    );
+    const filePath = getTuiStatePath(tempDir);
     fs.mkdirSync(path.dirname(filePath), { recursive: true });
     fs.writeFileSync(
       filePath,
@@ -114,7 +124,7 @@ describe('tui-state persistence', () => {
       }),
     );
 
-    const snapshot = readTuiSnapshot();
+    const snapshot = readTuiSnapshot(tempDir);
     expect(snapshot.agentModels).toEqual({
       explorer: 'openai/gpt-5.6-luna',
     });

+ 52 - 22
src/tui-state.ts

@@ -1,3 +1,4 @@
+import * as crypto from 'node:crypto';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
@@ -18,8 +19,24 @@ function dataDir(): string {
   );
 }
 
-export function getTuiStatePath(): string {
-  return path.join(dataDir(), 'opencode', 'storage', STATE_DIR, STATE_FILE);
+// ponytail: per-project scope prevents /model overrides from leaking across projects
+function projectScope(projectDir: string): string {
+  return crypto
+    .createHash('sha256')
+    .update(path.resolve(projectDir))
+    .digest('hex')
+    .slice(0, 12);
+}
+
+export function getTuiStatePath(projectDir: string): string {
+  return path.join(
+    dataDir(),
+    'opencode',
+    'storage',
+    STATE_DIR,
+    projectScope(projectDir),
+    STATE_FILE,
+  );
 }
 
 function emptySnapshot(): TuiSnapshot {
@@ -44,25 +61,29 @@ function parseSnapshot(value: string): TuiSnapshot {
   };
 }
 
-export function readTuiSnapshot(): TuiSnapshot {
+export function readTuiSnapshot(projectDir: string): TuiSnapshot {
   try {
-    return parseSnapshot(fs.readFileSync(getTuiStatePath(), 'utf8'));
+    return parseSnapshot(fs.readFileSync(getTuiStatePath(projectDir), 'utf8'));
   } catch {
     return emptySnapshot();
   }
 }
 
-export async function readTuiSnapshotAsync(): Promise<TuiSnapshot> {
+export async function readTuiSnapshotAsync(
+  projectDir: string,
+): Promise<TuiSnapshot> {
   try {
-    return parseSnapshot(await fs.promises.readFile(getTuiStatePath(), 'utf8'));
+    return parseSnapshot(
+      await fs.promises.readFile(getTuiStatePath(projectDir), 'utf8'),
+    );
   } catch {
     return emptySnapshot();
   }
 }
 
-function writeTuiSnapshot(snapshot: TuiSnapshot): void {
+function writeTuiSnapshot(snapshot: TuiSnapshot, projectDir: string): void {
   try {
-    const filePath = getTuiStatePath();
+    const filePath = getTuiStatePath(projectDir);
     fs.mkdirSync(path.dirname(filePath), { recursive: true });
     fs.writeFileSync(filePath, `${JSON.stringify(snapshot)}\n`);
   } catch {
@@ -70,29 +91,38 @@ function writeTuiSnapshot(snapshot: TuiSnapshot): void {
   }
 }
 
-function updateSnapshot(mutator: (snapshot: TuiSnapshot) => void): void {
-  const snapshot = readTuiSnapshot();
+function updateSnapshot(
+  projectDir: string,
+  mutator: (snapshot: TuiSnapshot) => void,
+): void {
+  const snapshot = readTuiSnapshot(projectDir);
   mutator(snapshot);
   snapshot.updatedAt = Date.now();
-  writeTuiSnapshot(snapshot);
+  writeTuiSnapshot(snapshot, projectDir);
 }
 
-export function recordTuiAgentModels(input: {
-  agentModels: Record<string, string>;
-  agentVariants?: Record<string, string>;
-}): void {
-  updateSnapshot((snapshot) => {
+export function recordTuiAgentModels(
+  input: {
+    agentModels: Record<string, string>;
+    agentVariants?: Record<string, string>;
+  },
+  projectDir: string,
+): void {
+  updateSnapshot(projectDir, (snapshot) => {
     snapshot.agentModels = { ...input.agentModels };
     snapshot.agentVariants = { ...(input.agentVariants ?? {}) };
   });
 }
 
-export function recordTuiAgentModel(input: {
-  agentName: string;
-  model: string;
-  variant?: string | null;
-}): void {
-  updateSnapshot((snapshot) => {
+export function recordTuiAgentModel(
+  input: {
+    agentName: string;
+    model: string;
+    variant?: string | null;
+  },
+  projectDir: string,
+): void {
+  updateSnapshot(projectDir, (snapshot) => {
     snapshot.agentModels[input.agentName] = input.model;
     if (input.variant !== undefined) {
       if (input.variant === null) {

+ 2 - 2
src/tui.ts

@@ -252,11 +252,11 @@ const plugin: TuiPluginModule & { id: string } = {
     const version = meta.version ?? (await readPackageVersion()) ?? 'dev';
     let configDirectory = getTuiDirectory(api);
     let { configInvalid, compactSidebar } = readConfigState(configDirectory);
-    let snapshot = readTuiSnapshot();
+    let snapshot = readTuiSnapshot(configDirectory);
     const renderTimer = setInterval(async () => {
       try {
-        snapshot = await readTuiSnapshotAsync();
         const currentDirectory = getTuiDirectory(api);
+        snapshot = await readTuiSnapshotAsync(currentDirectory);
         if (currentDirectory !== configDirectory) {
           configDirectory = currentDirectory;
           ({ configInvalid, compactSidebar } =