From a0c42a2aa2aa99828b5fee2f710d3aa013b63651 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:58:51 -0400 Subject: [PATCH 1/6] fix(sync): keep both workspaces when replay meets a name collision Replaying a workspace_created whose name another id already held went through INSERT OR REPLACE, which resolved UNIQUE(name) by deleting the other row. Streams from 5.17-5.20.x (and two devices creating the same name before syncing) therefore lost a workspace, its edits, and the parent row of its sessions, states, projects and tasks on cache rebuild. Both workspaces now keep their rows and ids, so every reference still resolves. The earliest-created (ties: smaller id) keeps the name; the other is shown as " ()", independent of shard order. A replayed rename onto a taken name applies the rest of the event with the same disambiguated name instead of failing it, and the live update refuses such a rename before appending its event. Co-Authored-By: Claude Opus 5.5 --- .../repositories/WorkspaceRepository.ts | 17 ++- src/database/sync/WorkspaceEventApplier.ts | 105 +++++++++++++++-- .../WorkspaceCreateEventIntegrity.test.ts | 109 ++++++++++++++++++ 3 files changed, 220 insertions(+), 11 deletions(-) diff --git a/src/database/repositories/WorkspaceRepository.ts b/src/database/repositories/WorkspaceRepository.ts index 7c8c7c23..a5de2ddd 100644 --- a/src/database/repositories/WorkspaceRepository.ts +++ b/src/database/repositories/WorkspaceRepository.ts @@ -178,6 +178,19 @@ export class WorkspaceRepository async update(id: string, data: UpdateWorkspaceData): Promise { try { await this.transaction(async () => { + // 0. A rename onto another workspace's name would fail UNIQUE(name) + // after the event below was already appended — the same orphan + // event `create` guards against. Refuse it first. + if (data.name !== undefined) { + const holder = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE name = ? AND id != ? LIMIT 1', + [data.name, id] + ); + if (holder) { + throw new WorkspaceConflictError('name', holder.id, data.name); + } + } + // 1. Write event to JSONL const eventData: Partial<{ name: string; description: string; rootFolder: string; lastAccessed: number; isActive: boolean; isArchived: boolean; dedicatedAgentId: string; contextJson: string }> = { lastAccessed: data.lastAccessed ?? Date.now() @@ -248,7 +261,9 @@ export class WorkspaceRepository this.invalidateCache(id); this.log('update', { id }); } catch (error) { - this.logError('update', error); + if (!(error instanceof WorkspaceConflictError)) { + this.logError('update', error); + } throw error; } } diff --git a/src/database/sync/WorkspaceEventApplier.ts b/src/database/sync/WorkspaceEventApplier.ts index 76c77643..1c919eaf 100644 --- a/src/database/sync/WorkspaceEventApplier.ts +++ b/src/database/sync/WorkspaceEventApplier.ts @@ -106,29 +106,102 @@ export class WorkspaceEventApplier { // events. Replaying a duplicate as INSERT OR REPLACE reverted every edit // made before it. First create wins; later ones are no-ops. A create after // `workspace_deleted` still lands, because the delete purged the row. - // OR REPLACE is kept for the name constraint only, as before. + const existing = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE id = ?', + [event.data.id] + ); + if (existing) { + return; + } + + const created = event.data.created ?? event.timestamp ?? Date.now(); + const name = await this.claimNameForCreate(event.data.id, event.data.name, created); + await this.sqliteCache.run( - `INSERT OR REPLACE INTO workspaces + `INSERT INTO workspaces (id, name, description, rootFolder, created, lastAccessed, isActive, isArchived, contextJson, dedicatedAgentId) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE NOT EXISTS (SELECT 1 FROM workspaces WHERE id = ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ event.data.id, - event.data.name, + name, event.data.description ?? null, event.data.rootFolder ?? '', - event.data.created ?? Date.now(), - event.data.created ?? Date.now(), + created, + created, // Default to 1 (active) if not specified event.data.isActive !== undefined ? (event.data.isActive ? 1 : 0) : 1, event.data.isArchived !== undefined ? (event.data.isArchived ? 1 : 0) : 0, event.data.contextJson ?? null, - event.data.dedicatedAgentId ?? null, - event.data.id + event.data.dedicatedAgentId ?? null ] ); } + /** + * Decide which name a replayed `workspace_created` gets when another + * workspace (a different id) already holds that name. + * + * `UNIQUE(name)` used to be resolved by INSERT OR REPLACE, which DELETES the + * row holding the name. Two workspaces with one name in the event store is + * real history — 5.17-5.20.x appended a `workspace_created` even when the + * live INSERT then failed on the name, and two devices can each create + * "Project X" before they sync — so a rebuild silently dropped whichever + * workspace was replayed first, its edits, and the parent row of its + * sessions, states, traces, projects, tasks and conversations. + * + * Rule: both workspaces keep their rows and ids, so every reference to either + * id still resolves. The earliest-created one (ties: smaller id) keeps the + * plain name; the other is shown as " ()". Deciding by `created` + * rather than by arrival makes the result independent of the order the + * shard files are listed in. Merging the two under one id (aliasing) was + * rejected: every applier and every live lookup by id would need to learn + * the alias, and two genuinely different workspaces would be fused. + * + * Returns the name the incoming row should be inserted with. When the + * incoming workspace is the earlier one, the current holder is renamed out + * of the way first. + */ + private async claimNameForCreate(id: string, name: string, created: number): Promise { + const holder = await this.sqliteCache.queryOne<{ id: string; created: number | null }>( + 'SELECT id, created FROM workspaces WHERE name = ? AND id != ?', + [name, id] + ); + if (!holder) { + return name; + } + + const holderCreated = typeof holder.created === 'number' ? holder.created : Number.POSITIVE_INFINITY; + const incomingWins = created < holderCreated || (created === holderCreated && id < holder.id); + if (!incomingWins) { + return this.disambiguatedName(name, id); + } + + await this.sqliteCache.run( + 'UPDATE workspaces SET name = ? WHERE id = ?', + [await this.disambiguatedName(name, holder.id), holder.id] + ); + return name; + } + + /** + * " ()" — derived from the id so the same history always yields + * the same name, with a counter only if a user already chose that exact text. + */ + private async disambiguatedName(name: string, id: string): Promise { + const base = `${name} (${id})`; + let candidate = base; + for (let n = 2; ; n++) { + const taken = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE name = ? AND id != ?', + [candidate, id] + ); + if (!taken) { + return candidate; + } + candidate = `${base} ${n}`; + } + } + private async applyWorkspaceUpdated(event: WorkspaceUpdatedEvent): Promise { if (!this.isValidWorkspaceId(event.workspaceId)) { return; @@ -137,7 +210,19 @@ export class WorkspaceEventApplier { const updates: string[] = []; const values: unknown[] = []; - if (event.data.name !== undefined) { updates.push('name = ?'); values.push(event.data.name); } + if (event.data.name !== undefined) { + // A rename onto a name another workspace holds would fail UNIQUE(name) + // and take the rest of this event (description, context, archive flag) + // down with it. Such events exist: the live update appended its event + // before SQLite rejected the name. Keep the rest, and give the name the + // same " ()" form a colliding create gets. + const holder = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE name = ? AND id != ?', + [event.data.name, event.workspaceId] + ); + const name = holder ? await this.disambiguatedName(event.data.name, event.workspaceId) : event.data.name; + updates.push('name = ?'); values.push(name); + } if (event.data.description !== undefined) { updates.push('description = ?'); values.push(event.data.description); } if (event.data.rootFolder !== undefined) { updates.push('rootFolder = ?'); values.push(event.data.rootFolder); } if (event.data.lastAccessed !== undefined) { updates.push('lastAccessed = ?'); values.push(event.data.lastAccessed); } diff --git a/tests/unit/WorkspaceCreateEventIntegrity.test.ts b/tests/unit/WorkspaceCreateEventIntegrity.test.ts index f18e2167..142de5ae 100644 --- a/tests/unit/WorkspaceCreateEventIntegrity.test.ts +++ b/tests/unit/WorkspaceCreateEventIntegrity.test.ts @@ -209,3 +209,112 @@ describeWithSqlite('WorkspaceEventApplier: replaying a duplicate workspace_creat expect(rows).toEqual([expect.objectContaining({ id: 'default', description: 'second life', created: 3 })]); }); }); + +/** + * Two `workspace_created` events with one name and different ids. + * + * 5.17-5.20.x appended a `workspace_created` even when the live INSERT then + * failed UNIQUE(name), and two devices can each create the same name before + * syncing. Each id has its own shard (ws_.jsonl) and a rebuild replays the + * shards one after another in listing order. Replay used INSERT OR REPLACE, so + * the second shard's create DELETED the first workspace's row — its edits gone, + * its sessions/states/projects/tasks pointing at a workspace that no longer + * existed. The rule now: both rows survive under their own ids, the + * earliest-created keeps the name, the other becomes " ()", and the + * result does not depend on shard order. + */ +describeWithSqlite('WorkspaceEventApplier: two workspaces created with one name', () => { + const create = (id: string, created: number, description: string): WorkspaceEvent => ({ + id: `c-${id}-${created}`, type: 'workspace_created', deviceId: 'dev', timestamp: created, + data: { id, name: 'Default Workspace', description, rootFolder: '/', created, isActive: true } + } as WorkspaceEvent); + const update = (workspaceId: string, timestamp: number, data: Record): WorkspaceEvent => ({ + id: `u-${workspaceId}-${timestamp}`, type: 'workspace_updated', deviceId: 'dev', timestamp, + workspaceId, data: { lastAccessed: timestamp, ...data } + } as WorkspaceEvent); + const session = (workspaceId: string, timestamp: number): WorkspaceEvent => ({ + id: `s-${workspaceId}`, type: 'session_created', deviceId: 'dev', timestamp, + workspaceId, data: { id: `session-${workspaceId}`, name: 'S', startTime: timestamp } + } as WorkspaceEvent); + + // Each array is one shard; shards replay whole, one after another. + const realShard = [create('default', 10, 'real'), update('default', 20, { description: 'EDITED' }), session('default', 21)]; + const phantomShard = [create('ws_phantom', 30, 'phantom'), session('ws_phantom', 31)]; + + async function replayShards(shards: WorkspaceEvent[][]) { + const { cache, db } = openCache(); + const applier = new WorkspaceEventApplier(cache as unknown as ISQLiteCacheManager); + for (const shard of shards) { + for (const event of shard) { + await applier.apply(event); + } + } + const workspaces = db.prepare('SELECT id, name, description, created FROM workspaces ORDER BY id').all(); + const orphanSessions = db.prepare( + 'SELECT s.id FROM sessions s LEFT JOIN workspaces w ON w.id = s.workspaceId WHERE w.id IS NULL' + ).all(); + return { workspaces, orphanSessions }; + } + + const expected = [ + { id: 'default', name: 'Default Workspace', description: 'EDITED', created: 10 }, + { id: 'ws_phantom', name: 'Default Workspace (ws_phantom)', description: 'phantom', created: 30 } + ]; + + it('keeps the earlier workspace, its edits and its sessions when the later shard replays second', async () => { + const result = await replayShards([realShard, phantomShard]); + // Pre-fix: only ws_phantom survived; session-default was orphaned. + expect(result.workspaces).toEqual(expected); + expect(result.orphanSessions).toEqual([]); + }); + + it('gives the same result when the later shard replays first', async () => { + const result = await replayShards([phantomShard, realShard]); + expect(result.workspaces).toEqual(expected); + expect(result.orphanSessions).toEqual([]); + }); + + it('breaks a created-time tie by id, independent of shard order', async () => { + const a = [create('ws_a', 5, 'a')]; + const b = [create('ws_b', 5, 'b')]; + const forward = await replayShards([a, b]); + const reversed = await replayShards([b, a]); + expect(forward.workspaces).toEqual(reversed.workspaces); + expect(forward.workspaces).toEqual([ + expect.objectContaining({ id: 'ws_a', name: 'Default Workspace' }), + expect.objectContaining({ id: 'ws_b', name: 'Default Workspace (ws_b)' }) + ]); + }); + + it('applies the rest of an update whose rename collides, instead of failing the event', async () => { + const result = await replayShards([ + realShard, + [ + { ...create('ws_other', 40, 'other'), data: { id: 'ws_other', name: 'Other', created: 40, rootFolder: '/' } } as WorkspaceEvent, + update('ws_other', 50, { name: 'Default Workspace', description: 'kept' }) + ] + ]); + // Pre-fix: UNIQUE constraint failed: workspaces.name, and 'kept' never landed. + expect(result.workspaces).toEqual([ + expect.objectContaining({ id: 'default', name: 'Default Workspace', description: 'EDITED' }), + expect.objectContaining({ id: 'ws_other', name: 'Default Workspace (ws_other)', description: 'kept' }) + ]); + }); +}); + +describeWithSqlite('WorkspaceRepository.update: a rejected rename writes no event', () => { + it('refuses a name another workspace holds before appending', async () => { + const { cache, db } = openCache(); + const log = createEventLog(); + const repo = createRepository(cache, log.writer); + await repo.create(DEFAULT); + await repo.create({ ...DEFAULT, id: 'ws_other', name: 'Other' }); + + await expect(repo.update('ws_other', { name: 'Default Workspace', description: 'x' })) + .rejects.toThrow('UNIQUE constraint failed: workspaces.name'); + + // Pre-fix: a workspace_updated was appended, then the UPDATE threw. + expect(log.events.filter(e => e.type === 'workspace_updated')).toHaveLength(0); + expect(db.prepare("SELECT name FROM workspaces WHERE id = 'ws_other'").get()?.name).toBe('Other'); + }); +}); From 62739ab8096db8e6b849d0faf4f5c585eb892b13 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:01:35 -0400 Subject: [PATCH 2/6] fix(storage): clear the query cache when the cache is rebuilt or resynced "Nexus: Rebuild cache" wiped and replayed SQLite but left the shared repository QueryCache alone, so getWorkspace (and every other repository read) answered with pre-rebuild rows until the 60 s TTL or a reload. rebuildCache now clears it once the replay settles, on failure too. Startup clears it after its sync/rebuild, since the adapter serves reads while replay is still filling the cache. And a sync() that arrives while another is in flight now waits for the follow-up run instead of returning a fabricated 0-event success, so "Refresh synced data" clears the query cache after its changes have landed, not before. Co-Authored-By: Claude Opus 5.5 --- src/database/adapters/HybridStorageAdapter.ts | 3 + .../lifecycle/StorageMaintenanceService.ts | 5 + src/database/sync/SyncCoordinator.ts | 35 ++-- .../cache-backend-rebuild-cache.test.ts | 3 +- .../instruction-skill-cache-rebuild.test.ts | 3 +- .../unit/HybridStorageAdapter.phase0.test.ts | 1 + tests/unit/NotesIndexCacheRebuild.test.ts | 1 + tests/unit/RebuildCacheQueryCache.test.ts | 157 ++++++++++++++++++ 8 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 tests/unit/RebuildCacheQueryCache.test.ts diff --git a/src/database/adapters/HybridStorageAdapter.ts b/src/database/adapters/HybridStorageAdapter.ts index fc9bcb37..32d5bfd9 100644 --- a/src/database/adapters/HybridStorageAdapter.ts +++ b/src/database/adapters/HybridStorageAdapter.ts @@ -316,6 +316,9 @@ export class HybridStorageAdapter implements IStorageAdapter { await this.runReconcile('conversation', () => this.reconcileMissingConversations()); await this.runReconcile('task', () => this.reconcileMissingTasks()); } + // The adapter is usable before replay finishes, so reads made during it + // cached a partial picture. Drop them now that the cache is complete. + this.queryCache.clear(); // Fill in the state columns denormalized in schema v16 (issue #219) for // rows that came over from a pre-v16 cache. Costs one indexed SELECT once diff --git a/src/database/adapters/lifecycle/StorageMaintenanceService.ts b/src/database/adapters/lifecycle/StorageMaintenanceService.ts index 58683e2b..f140a1a6 100644 --- a/src/database/adapters/lifecycle/StorageMaintenanceService.ts +++ b/src/database/adapters/lifecycle/StorageMaintenanceService.ts @@ -160,6 +160,11 @@ export class StorageMaintenanceService { // succeeds; a failed rebuild throws above and never reaches here. this.externalEvents.trigger('cache-rebuilt'); } finally { + // The repositories' query cache still holds rows read from the + // database this rebuild threw away (`getWorkspace` answered with the + // pre-rebuild workspace until its TTL ran out or the plugin reloaded). + // Cleared on failure too: the database was wiped either way. + this.deps.getQueryCache().clear(); // Clear on both success and failure so a follow-up rebuild can // re-run; the original error (if any) still rejects this promise. try { await this.afterCacheRebuild?.(); } diff --git a/src/database/sync/SyncCoordinator.ts b/src/database/sync/SyncCoordinator.ts index c504964b..0e8f8075 100644 --- a/src/database/sync/SyncCoordinator.ts +++ b/src/database/sync/SyncCoordinator.ts @@ -110,8 +110,13 @@ export class SyncCoordinator { /** Guards against overlapping sync() calls. */ private syncing = false; - /** Set when a sync() call arrives while another is in-flight. */ - private syncQueued = false; + /** + * The follow-up run owed to callers that arrived while a sync was in flight. + * They all wait on it, so a caller's `await sync()` means its changes are in + * the cache — the "Refresh synced data" command clears the query cache right + * after that await, and an early return used to let it clear too soon. + */ + private queuedSync: { promise: Promise; resolve: (result: SyncResult) => void } | null = null; constructor(jsonlWriter: IJSONLWriter, sqliteCache: ISQLiteCacheManager) { this.jsonlWriter = jsonlWriter; @@ -166,8 +171,12 @@ export class SyncCoordinator { */ async sync(options: SyncOptions = {}): Promise { if (this.syncing) { - this.syncQueued = true; - return this.createResult(true, 0, 0, [], Date.now(), []); + if (!this.queuedSync) { + let resolve!: (result: SyncResult) => void; + const promise = new Promise(r => { resolve = r; }); + this.queuedSync = { promise, resolve }; + } + return this.queuedSync.promise; } this.syncing = true; @@ -175,12 +184,18 @@ export class SyncCoordinator { return await this.syncInner(options); } finally { this.syncing = false; - if (this.syncQueued) { - this.syncQueued = false; - // Re-run to pick up changes that landed during the previous sync. - // Don't await — callers of the queued sync already got their - // early-return result above. - void this.sync(options); + const queued = this.queuedSync; + if (queued) { + this.queuedSync = null; + // Re-run to pick up changes that landed during the previous sync, and + // settle every caller that queued behind it with that run's result. + // Not awaited here: this run's own caller should not wait for it. + void this.sync(options).then( + queued.resolve, + (error: unknown) => queued.resolve( + this.createResult(false, 0, 0, [`Sync failed: ${String(error)}`], Date.now(), []) + ) + ); } } } diff --git a/tests/integration/cache-backend-rebuild-cache.test.ts b/tests/integration/cache-backend-rebuild-cache.test.ts index a199657e..b9a2d2aa 100644 --- a/tests/integration/cache-backend-rebuild-cache.test.ts +++ b/tests/integration/cache-backend-rebuild-cache.test.ts @@ -86,7 +86,8 @@ async function buildRebuildHarness(): Promise { initLifecycle, sqliteCache: cacheLifecycle as unknown, syncCoordinator: syncCoordinator as unknown, - cacheBlobStore: blobStore + cacheBlobStore: blobStore, + queryCache: { clear: jest.fn() } }); return { adapter, blobStore, cacheLifecycle, syncCoordinator, callOrder }; diff --git a/tests/integration/instruction-skill-cache-rebuild.test.ts b/tests/integration/instruction-skill-cache-rebuild.test.ts index b63eaa90..60592dc1 100644 --- a/tests/integration/instruction-skill-cache-rebuild.test.ts +++ b/tests/integration/instruction-skill-cache-rebuild.test.ts @@ -62,7 +62,8 @@ catch { /* Node >=22 lane; the plugin itself supports Node >=18 and mobile. */ } const maintenance = new StorageMaintenanceService({ getSqliteCache: () => sqlite, getCacheBlobStore: () => ({ remove: () => Promise.resolve() }), getInitLifecycle: () => ({ isInitialized: () => true }), - getSyncCoordinator: () => ({ fullRebuild: () => Promise.resolve({ success: true, errors: [] }) }) + getSyncCoordinator: () => ({ fullRebuild: () => Promise.resolve({ success: true, errors: [] }) }), + getQueryCache: () => ({ clear: () => undefined }) } as unknown as StorageMaintenanceDeps); maintenance.setBeforeCacheRebuild(async () => { const result = await skills.pauseForRebuild(); if (!result.ok) throw new Error(result.error.message); }); maintenance.setAfterCacheRebuild(async () => { const result = await skills.resumeAfterRebuild(); if (!result.ok) throw new Error(result.error.message); }); diff --git a/tests/unit/HybridStorageAdapter.phase0.test.ts b/tests/unit/HybridStorageAdapter.phase0.test.ts index 352fa74f..040d28c2 100644 --- a/tests/unit/HybridStorageAdapter.phase0.test.ts +++ b/tests/unit/HybridStorageAdapter.phase0.test.ts @@ -98,6 +98,7 @@ describe('HybridStorageAdapter (Phase 0 characterization)', () => { adapter.syncCoordinator = { fullRebuild: jest.fn().mockResolvedValue(successfulSyncResult) }; + (adapter as unknown as { queryCache: unknown }).queryCache = { clear: jest.fn() }; return adapter; } diff --git a/tests/unit/NotesIndexCacheRebuild.test.ts b/tests/unit/NotesIndexCacheRebuild.test.ts index f47a9566..9ed8cc7d 100644 --- a/tests/unit/NotesIndexCacheRebuild.test.ts +++ b/tests/unit/NotesIndexCacheRebuild.test.ts @@ -58,6 +58,7 @@ function makeMaintenance(rebuildSucceeds: boolean) { getSqliteCache: () => sqliteCache, getCacheBlobStore: () => cacheBlobStore, getSyncCoordinator: () => syncCoordinator, + getQueryCache: () => ({ clear: () => undefined }), getInitLifecycle: () => ({ isInitialized: () => true }) } as unknown as StorageMaintenanceDeps; diff --git a/tests/unit/RebuildCacheQueryCache.test.ts b/tests/unit/RebuildCacheQueryCache.test.ts new file mode 100644 index 00000000..ff5bec80 --- /dev/null +++ b/tests/unit/RebuildCacheQueryCache.test.ts @@ -0,0 +1,157 @@ +/** + * "Nexus: Rebuild cache" must not leave the repositories answering from the + * database it threw away. + * + * Observed live: after a rebuild, `workspaceService.getWorkspace` kept returning + * the pre-rebuild workspace until the plugin was reloaded. Every repository read + * goes through one shared `QueryCache` (60 s TTL), and `rebuildCache()` closed, + * wiped and replayed SQLite without clearing it. + * + * Real pieces: node:sqlite with the production `workspaces` DDL, the production + * `WorkspaceRepository`, `QueryCache`, `SyncCoordinator` and + * `WorkspaceEventApplier`. Only the event store (an in-memory event list) and + * the cache-file lifecycle (close / blob remove / reopen) are stand-ins. + */ + +import { WorkspaceRepository } from '../../src/database/repositories/WorkspaceRepository'; +import { QueryCache } from '../../src/database/optimizations/QueryCache'; +import { SyncCoordinator } from '../../src/database/sync/SyncCoordinator'; +import type { IJSONLWriter, ISQLiteCacheManager, SyncResult } from '../../src/database/sync/SyncCoordinator'; +import type { ReconcilePipeline } from '../../src/database/sync/ReconcilePipeline'; +import { StorageMaintenanceService, type StorageMaintenanceDeps } from '../../src/database/adapters/lifecycle/StorageMaintenanceService'; +import { SCHEMA_SQL } from '../../src/database/schema/schema'; +import type { RepositoryDependencies } from '../../src/database/repositories/base/BaseRepository'; +import type { StorageEvent, WorkspaceEvent } from '../../src/database/interfaces/StorageEvents'; + +interface NodeSqliteDb { + exec(sql: string): void; + prepare(sql: string): { + run(...params: unknown[]): unknown; + all(...params: unknown[]): Record[]; + get(...params: unknown[]): Record | undefined; + }; +} + +function loadNodeSqlite(): (new (path: string) => NodeSqliteDb) | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return (require('node:sqlite') as { DatabaseSync: new (path: string) => NodeSqliteDb }).DatabaseSync; + } catch { + return null; + } +} + +const DatabaseSync = loadNodeSqlite(); +const describeWithSqlite = DatabaseSync ? describe : describe.skip; + +function openCache() { + const db = new DatabaseSync!(':memory:'); + db.exec(SCHEMA_SQL.replace( + /CREATE VIRTUAL TABLE IF NOT EXISTS (\w+) USING vec0\([\s\S]*?\);/g, + 'CREATE TABLE IF NOT EXISTS $1 (rowid INTEGER PRIMARY KEY);' + )); + const params = (p: unknown[] = []) => p.map(v => (v === undefined ? null : v)); + const cache = { + run: async (sql: string, p?: unknown[]) => db.prepare(sql).run(...params(p)), + query: async (sql: string, p?: unknown[]) => db.prepare(sql).all(...params(p)) as T[], + queryOne: async (sql: string, p?: unknown[]) => (db.prepare(sql).get(...params(p)) ?? null) as T | null, + transaction: async (fn: () => Promise) => fn(), + getSyncState: async () => null, + updateSyncState: async () => undefined, + isEventApplied: async () => false, + markEventApplied: async () => undefined, + clearAllData: async () => { db.exec('DELETE FROM sessions; DELETE FROM workspaces;'); }, + rebuildFTSIndexes: async () => undefined, + save: async () => undefined, + stopAutoSave: () => undefined, + close: async () => undefined, + initialize: async () => undefined + }; + return { db, cache }; +} + +const STREAM = 'workspaces/ws_w1.jsonl'; +const events: WorkspaceEvent[] = [ + { + id: 'e1', type: 'workspace_created', deviceId: 'dev', timestamp: 1, + data: { id: 'w1', name: 'Research', description: 'from creation', rootFolder: '/', created: 1 } + } as WorkspaceEvent, + { + id: 'e2', type: 'workspace_updated', deviceId: 'dev', timestamp: 2, + workspaceId: 'w1', data: { description: 'from the event store' } + } as WorkspaceEvent +]; + +const eventStore: IJSONLWriter = { + getDeviceId: () => 'dev', + listFiles: async (category) => (category === 'workspaces' ? [STREAM] : []), + getFileModTime: async () => null, + readEvents: async () => events as unknown as T[], + getEventsNotFromDevice: async () => [] as T[] +}; + +describeWithSqlite('rebuildCache clears the repository query cache', () => { + it('getById answers with the replayed row, not the one read before the rebuild', async () => { + const { db, cache } = openCache(); + const queryCache = new QueryCache(); + const repo = new WorkspaceRepository({ sqliteCache: cache, jsonlWriter: {}, queryCache } as unknown as RepositoryDependencies); + const coordinator = new SyncCoordinator(eventStore, cache as unknown as ISQLiteCacheManager); + + // A cache that drifted from the event store — the reason to rebuild. + db.prepare("INSERT INTO workspaces (id, name, description, rootFolder, created, lastAccessed) VALUES ('w1', 'Research', 'drifted', '/', 1, 1)").run(); + expect((await repo.getById('w1'))?.description).toBe('drifted'); + + const maintenance = new StorageMaintenanceService({ + getInitLifecycle: () => ({ isInitialized: () => true }), + getSqliteCache: () => cache, + getCacheBlobStore: () => ({ remove: async () => undefined }), + getSyncCoordinator: () => coordinator, + getQueryCache: () => queryCache + } as unknown as StorageMaintenanceDeps); + + await maintenance.rebuildCache(); + + expect(db.prepare("SELECT description FROM workspaces WHERE id = 'w1'").get()?.description).toBe('from the event store'); + // Pre-fix: 'drifted' — served from the QueryCache entry made before the rebuild. + expect((await repo.getById('w1'))?.description).toBe('from the event store'); + }); +}); + +describe('SyncCoordinator.sync: a call that arrives mid-sync waits for the follow-up run', () => { + it('does not resolve until the queued run has applied its changes', async () => { + const { cache } = openCache(); + const releases: Array<() => void> = []; + const finished: number[] = []; + let runs = 0; + const pipeline = { + reconcileAll: jest.fn(async () => { + const run = ++runs; + await new Promise(resolve => releases.push(resolve)); + finished.push(run); + return { success: true, eventsApplied: run, eventsSkipped: 0, errors: [] }; + }) + }; + const coordinator = new SyncCoordinator(eventStore, cache as unknown as ISQLiteCacheManager); + coordinator.setReconcilePipeline(pipeline as unknown as ReconcilePipeline); + + const first = coordinator.sync(); + let refreshResult: SyncResult | null = null; + // What "Refresh synced data" does while startup's sync is still running. + const refresh = coordinator.sync().then(r => { refreshResult = r; }); + + await Promise.resolve(); + // Pre-fix: already resolved with a fabricated 0-event success. + expect(refreshResult).toBeNull(); + + releases[0](); + await first; + await new Promise(resolve => setTimeout(resolve, 0)); + expect(refreshResult).toBeNull(); + expect(pipeline.reconcileAll).toHaveBeenCalledTimes(2); + + releases[1](); + await refresh; + expect(finished).toEqual([1, 2]); + expect(refreshResult).toMatchObject({ success: true, eventsApplied: 2 }); + }); +}); From 80e37a7da89c3450fdc5f97e5e0d10c2785d09be Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:04:38 -0400 Subject: [PATCH 3/6] fix(search): base semantic search state on the embedding runtime search content decided between "off" and "initializing" from the saved setting alone. A manager turned off at runtime (setEnabled(false), or a toggle not yet saved) and a manager whose engine failed to start or whose model failed to load all answered semantic=true with "still initializing", which agents retry indefinitely. resolveSemanticSearchState now also reads the manager: turned off reads as off (keyword fallback, "turned off" note and description), and a failed start or a model-load error reads as a new 'error' state that falls back to keyword search with a note naming the cause and pointing at "Try again". "Initializing" is left for a manager that is on and starting. EmbeddingManager keeps the last start error in getStatus(). Co-Authored-By: Claude Opus 5.5 --- .../searchManager/tools/searchContent.ts | 48 ++++++-- src/services/embeddings/EmbeddingManager.ts | 12 +- src/services/embeddings/embeddingAccess.ts | 83 +++++++++++-- tests/unit/EmbeddingManagerHotToggle.test.ts | 5 +- tests/unit/SearchContentSemanticOff.test.ts | 115 +++++++++++++++++- 5 files changed, 238 insertions(+), 25 deletions(-) diff --git a/src/agents/searchManager/tools/searchContent.ts b/src/agents/searchManager/tools/searchContent.ts index 6390af1e..58b07e08 100644 --- a/src/agents/searchManager/tools/searchContent.ts +++ b/src/agents/searchManager/tools/searchContent.ts @@ -7,6 +7,7 @@ import type { EmbeddingService } from '../../../services/embeddings/EmbeddingSer import { resolveEmbeddingService, resolveSemanticSearchState, + resolveSemanticSearchFailure, type SemanticSearchState } from '../../../services/embeddings/embeddingAccess'; import { CommonParameters } from '../../../types'; @@ -68,20 +69,23 @@ const DESCRIPTION_AVAILABLE = 'Search vault files. Set semantic=true for AI-powe /** Tool descriptions while semantic search cannot be used: no pitch for it. */ const DESCRIPTION_KEYWORD_ONLY: Record, string> = { off: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is turned off in Nexus settings, so semantic=true also runs keyword search.', - unsupported: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is not available on this device, so semantic=true also runs keyword search.' + unsupported: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is not available on this device, so semantic=true also runs keyword search.', + error: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search failed to start on this device, so semantic=true also runs keyword search.' }; /** The `semantic` property description for each state. The property itself always stays, so `--semantic` never becomes an unknown flag. */ const SEMANTIC_PARAM_DESCRIPTION: Record = { available: 'true = AI-powered conceptual search (desktop only, best for concepts/related ideas). false = keyword/fuzzy search (default, best for exact matches).', off: 'Unavailable: semantic search is turned off in Nexus settings. true runs keyword search instead.', - unsupported: 'Unavailable on this device. true runs keyword search instead.' + unsupported: 'Unavailable on this device. true runs keyword search instead.', + error: 'Unavailable: semantic search failed to start. true runs keyword search instead.' }; /** The `note` returned when semantic=true fell back to keyword search. */ export const SEMANTIC_FALLBACK_NOTES: Record, string> = { off: 'Semantic search is turned off in Nexus settings, so this used keyword search.', - unsupported: 'Semantic search is not available on this device, so this used keyword search.' + unsupported: 'Semantic search is not available on this device, so this used keyword search.', + error: 'Semantic search failed to start, so this used keyword search. Use "Try again" under Semantic search in Nexus settings to restart it.' }; /** Score for a verbatim occurrence of the whole query. */ @@ -255,7 +259,11 @@ export class SearchContentTool extends BaseTool SemanticSearchState) { + constructor( + plugin: Plugin, + getSemanticState?: () => SemanticSearchState, + private readonly getSemanticFailure: () => string | null = () => resolveSemanticSearchFailure(plugin) + ) { super('content', 'Content Search', DESCRIPTION_AVAILABLE, '2.0.0'); this.plugin = plugin; this.getSemanticState = getSemanticState ?? (() => resolveSemanticSearchState(plugin)); @@ -322,11 +330,13 @@ export class SearchContentTool extends BaseTool): string { + const note = SEMANTIC_FALLBACK_NOTES[state]; + if (state !== 'error') { + return note; + } + let reason: string | null = null; + try { + reason = this.getSemanticFailure(); + } catch { + reason = null; + } + return reason ? `${note} Cause: ${reason.replace(/[.\s]+$/, '')}.` : note; + } + /** Attach a note next to the results of a successful search. */ private withNote(result: ContentSearchResult, note: string): ContentSearchResult { const data = (result as unknown as { data?: Record }).data ?? {}; @@ -358,13 +383,16 @@ export class SearchContentTool extends BaseTool { expect(created.engine[0].dispose).toHaveBeenCalled(); expect(h.manager.getService()).toBeNull(); expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: false }); + // Why it failed stays readable, so search can say so instead of + // "still initializing" (resolveSemanticSearchState reads this). + expect(h.manager.getStatus().startError).toBe('status bar failed'); await h.manager.setEnabled(true); expect(created.engine).toHaveLength(2); - expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: true }); + expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: true, startError: null }); } finally { errorSpy.mockRestore(); } diff --git a/tests/unit/SearchContentSemanticOff.test.ts b/tests/unit/SearchContentSemanticOff.test.ts index 7a1a658f..b026a2e3 100644 --- a/tests/unit/SearchContentSemanticOff.test.ts +++ b/tests/unit/SearchContentSemanticOff.test.ts @@ -36,7 +36,13 @@ interface SearchData { note?: string; } -function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boolean } = {}) { +interface RuntimeView { + turnedOn: boolean; + startError?: string | null; + progress?: { phase: string; error?: string } | null; +} + +function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boolean; runtime?: RuntimeView } = {}) { const files = [ new TFile('auth.md', 'Ideas/auth.md'), new TFile('cooking.md', 'Life/cooking.md') @@ -50,10 +56,24 @@ function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boo getStats: jest.fn(async () => ({ noteCount: 2 })), semanticSearch: jest.fn(async () => [{ notePath: 'Ideas/auth.md', distance: 0.1, score: 0.1 }]) }; - const manager = { + const manager: Record = { isEmbeddingEnabled: () => options.managerReady ?? false, getService: () => (options.managerReady ? service : null) }; + // The real EmbeddingManager's runtime view. Left off by default so the + // settings-only path (and stand-ins without it) stays covered. + const runtime = options.runtime; + if (runtime) { + manager.isTurnedOn = () => runtime.turnedOn; + manager.getStatus = () => ({ + enabled: runtime.turnedOn, + initialized: options.managerReady ?? false, + indexing: false, + paused: false, + progress: runtime.progress ?? null, + startError: runtime.startError ?? null + }); + } const settings: Record = { memory: {} }; if (options.enableEmbeddings !== undefined) settings.enableEmbeddings = options.enableEmbeddings; @@ -87,7 +107,7 @@ function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boo context: CONTEXT }) as Promise<{ success: boolean; error?: string; data?: SearchData }>; - return { agent, normalizer, tool, run, service, settings }; + return { agent, normalizer, tool, run, service, settings, runtime }; } describe('search content with semantic search turned off', () => { @@ -178,3 +198,92 @@ describe('search content with semantic search turned on', () => { expect(h.tool().description).toContain(SEMANTIC_PITCH); }); }); + +/** + * The saved setting is not the whole truth. EmbeddingManager.setEnabled(false) + * turns the runtime off before (or without) the setting being saved, and a + * model that fails to load leaves the manager "on" with nothing that can + * search. Reading only the setting answered both with "still initializing", + * which an agent retries indefinitely. + */ +describe('search content follows the embedding runtime, not just the setting', () => { + it('treats a manager turned off at runtime as off, though the setting is still on', async () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false, runtime: { turnedOn: false } }); + + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + expect(h.tool().description).toMatch(/turned off/); + const result = await h.run(true); + + // Pre-fix: success false, "Semantic search is still initializing". + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.data?.note).toBe(SEMANTIC_FALLBACK_NOTES.off); + expect(result.data?.results.map(r => r.filePath)).toContain('Ideas/auth.md'); + }); + + it('still says "initializing" while the manager is on and starting', async () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false, runtime: { turnedOn: true } }); + + expect(h.tool().description).toContain(SEMANTIC_PITCH); + const result = await h.run(true); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/still initializing/); + }); + + it('reports a model that failed to load, with its cause, instead of "initializing"', async () => { + const h = createHarness({ + enableEmbeddings: true, + managerReady: true, + runtime: { turnedOn: true, progress: { phase: 'error', error: 'The embedding model could not be loaded' } } + }); + h.service.isServiceEnabled.mockReturnValue(false); + + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + expect(h.tool().description).toMatch(/failed to start/); + const result = await h.run(true); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.data?.note).toBe(`${SEMANTIC_FALLBACK_NOTES.error} Cause: The embedding model could not be loaded.`); + expect(result.data?.note).not.toMatch(/initializing/); + expect(h.service.semanticSearch).not.toHaveBeenCalled(); + }); + + it('reports an engine that failed to start (no service at all) instead of "initializing"', async () => { + const h = createHarness({ + enableEmbeddings: true, + managerReady: false, + runtime: { turnedOn: true, startError: 'wasm unavailable' } + }); + + const result = await h.run(true); + + // Pre-fix: "still initializing" — the engine was never going to start. + expect(result.success).toBe(true); + expect(result.data?.note).toBe(`${SEMANTIC_FALLBACK_NOTES.error} Cause: wasm unavailable.`); + }); + + it('keeps searching stored vectors when a pass errored but the service still runs', async () => { + const h = createHarness({ + enableEmbeddings: true, + managerReady: true, + runtime: { turnedOn: true, progress: { phase: 'error', error: 'disk full' } } + }); + + const result = await h.run(true); + + expect(result.success).toBe(true); + expect(result.data?.note).toBeUndefined(); + expect(h.service.semanticSearch).toHaveBeenCalledTimes(1); + }); + + it('follows a runtime toggle live, without a new agent', () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false, runtime: { turnedOn: true } }); + expect(h.tool().description).toContain(SEMANTIC_PITCH); + + h.runtime!.turnedOn = false; + + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + }); +}); From 46c86c9bb9a377776ad42f5fc4bbbd0ea0170ed6 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:07:19 -0400 Subject: [PATCH 4/6] fix(settings): stop naming image/video models no provider can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no image provider set up, the Images fold summary named the default model from the catalog ("Nano Banana 2") while its Model dropdown said "No models available". The Video fold did the same for a saved provider without a key ("Veo 3.1 · 16:9 · 720p" next to "(unavailable)"). The summaries now read "No image provider" / "No video provider" when no provider of that kind is set up, and "Not set" when the selected one is unusable (no key, turned off, or listed no models). The saved model is kept, so it applies again once its provider is set up. Co-Authored-By: Claude Opus 5.5 --- src/components/shared/ChatSettingsRenderer.ts | 23 +++++++- src/components/shared/chatSettings/copy.ts | 3 + .../shared/chatSettings/sectionSummaries.ts | 34 ++++++++++-- src/settings/tabs/defaults/VideoSettings.ts | 14 ++++- tests/helpers/settingsRendererFixtures.ts | 3 + tests/unit/DefaultsWithoutProvider.test.ts | 55 ++++++++++++++++++- tests/unit/SettingsSectionSummaries.test.ts | 7 ++- 7 files changed, 130 insertions(+), 9 deletions(-) diff --git a/src/components/shared/ChatSettingsRenderer.ts b/src/components/shared/ChatSettingsRenderer.ts index 7c6877b0..320222bb 100644 --- a/src/components/shared/ChatSettingsRenderer.ts +++ b/src/components/shared/ChatSettingsRenderer.ts @@ -155,6 +155,8 @@ export class ChatSettingsRenderer { private modelOptionMap: Map = new Map(); private agentModelOptionMap: Map = new Map(); private modelNames: { chat?: string; agent?: string; image?: string } = {}; + /** The image provider whose model list last came back empty, if any. */ + private imageProviderWithoutModels: string | null = null; // Registry capabilities plus context windows discovered at runtime (Ollama, LM Studio). private capabilities: ModelCapabilityResolver; @@ -198,6 +200,7 @@ export class ChatSettingsRenderer { this.config.llmProviderSettings = newSettings; this.providerManager.updateSettings(newSettings); this.imageService.updateSettings(newSettings); + this.imageProviderWithoutModels = null; this.render(); }); } @@ -251,7 +254,7 @@ export class ChatSettingsRenderer { this.createFold(FOLD_IDS.subagents, FOLD_TITLES.subagents, FOLD_ICONS.subagents, el => this.renderSubagents(el), () => this.subagentsSummary()), this.createFold(FOLD_IDS.images, FOLD_TITLES.images, FOLD_ICONS.images, - el => this.renderImages(el), () => imagesSummary(this.imageModelName())), + el => this.renderImages(el), () => this.imagesFoldSummary()), this.createFold(FOLD_IDS.voice, FOLD_TITLES.voice, FOLD_ICONS.voice, el => this.voice.render(el), () => this.voice.getSummary()) ]; @@ -864,6 +867,21 @@ export class ChatSettingsRenderer { // ========== IMAGES ========== + /** + * The Images summary. A provider with no key (or turned off) has no adapter + * and lists no models, so naming the saved model there — the catalog knows + * "Nano Banana 2" without a key — described a setting that cannot run. + */ + private imagesFoldSummary(): string { + const ready = this.imageService.getInitializedProviders(); + const provider = this.settings.imageProvider; + return imagesSummary({ + modelName: this.imageModelName(), + anyProviderUsable: ready.length > 0, + providerUsable: ready.includes(provider) && this.imageProviderWithoutModels !== provider + }); + } + /** The image model's name: as listed once loaded, else from the catalog, else its id. */ private imageModelName(): string | undefined { const { imageProvider, imageModel } = this.settings; @@ -916,8 +934,11 @@ export class ChatSettingsRenderer { const models = await this.imageService.getModelsForProvider(provider); if (provider !== this.settings.imageProvider) return; + this.imageProviderWithoutModels = models.length === 0 ? provider : null; if (models.length === 0) { dropdown.addOption('', 'No models available'); + this.modelNames.image = undefined; + this.updateSummaries(); } else { sortModelsNewestFirst(models, m => ({ provider, id: m.id })).forEach(m => { dropdown.addOption(m.id, m.name); diff --git a/src/components/shared/chatSettings/copy.ts b/src/components/shared/chatSettings/copy.ts index 6a2f6d9a..c1a422c6 100644 --- a/src/components/shared/chatSettings/copy.ts +++ b/src/components/shared/chatSettings/copy.ts @@ -79,6 +79,9 @@ export const COPY = { noModel: 'No model', /** Subagents summary: no subagent model and no cloud chat model to describe. */ notSet: 'Not set', + /** Images/Video summaries: no provider of that kind has a key and is turned on. */ + noImageProvider: 'No image provider', + noVideoProvider: 'No video provider', noProviders: 'No providers enabled', noCloudProviders: 'No cloud providers enabled', none: 'None' diff --git a/src/components/shared/chatSettings/sectionSummaries.ts b/src/components/shared/chatSettings/sectionSummaries.ts index 9dcb3bdb..e80ef6b7 100644 --- a/src/components/shared/chatSettings/sectionSummaries.ts +++ b/src/components/shared/chatSettings/sectionSummaries.ts @@ -88,12 +88,38 @@ export function subagentsSummary(input: { ]); } -export function imagesSummary(modelName?: string | null): string { - return modelName || 'Not set'; +/** + * Whether the media provider behind a summary can run at all. Both default to + * usable, so a caller that does not know yet does not blank the summary. + */ +export interface MediaProviderUsability { + /** False when the selected provider has no API key or is turned off. */ + providerUsable?: boolean; + /** False when no provider of this kind has a key and is turned on. */ + anyProviderUsable?: boolean; +} + +/** + * Naming a model whose provider cannot run claimed a setting that does not + * work: the Images summary said "Nano Banana 2" while its Model dropdown said + * "No models available". The model id stays saved; the summary just stops + * presenting it as the active choice. + */ +function unusableMediaSummary(input: MediaProviderUsability, noneLabel: string): string | null { + if (input.anyProviderUsable === false) return noneLabel; + if (input.providerUsable === false) return COPY.notSet; + return null; +} + +export function imagesSummary(input: { modelName?: string | null } & MediaProviderUsability): string { + return unusableMediaSummary(input, COPY.noImageProvider) ?? (input.modelName || COPY.notSet); } -export function videoSummary(input: { modelName?: string | null; aspectRatio?: string | null; resolution?: string | null }): string { - return join([input.modelName || 'Not set', input.aspectRatio, input.resolution]); +export function videoSummary( + input: { modelName?: string | null; aspectRatio?: string | null; resolution?: string | null } & MediaProviderUsability +): string { + return unusableMediaSummary(input, COPY.noVideoProvider) + ?? join([input.modelName || COPY.notSet, input.aspectRatio, input.resolution]); } export function voiceSummary(input: { transcriptionModel?: string | null; readAloudModel?: string | null; voice?: string | null }): string { diff --git a/src/settings/tabs/defaults/VideoSettings.ts b/src/settings/tabs/defaults/VideoSettings.ts index a8a361d9..137f8c4d 100644 --- a/src/settings/tabs/defaults/VideoSettings.ts +++ b/src/settings/tabs/defaults/VideoSettings.ts @@ -54,12 +54,22 @@ export class VideoSettings implements FoldDefinition { }; }; + const isUsable = (item: ReturnType[number]): boolean => + item.enabled && item.configured && item.models.length > 0; + const updateSummary = (): void => { const current = selection(); + const all = availability(); + // A saved provider whose key was removed still resolves a model from + // the catalog; the summary must not present that as the active choice + // while the Provider dropdown marks it "(unavailable)". + const selected = all.find(item => item.provider === current.provider); host.setSummary(videoSummary({ modelName: current.modelDeclaration?.name ?? current.model, aspectRatio: current.aspectRatio, - resolution: current.resolution + resolution: current.resolution, + anyProviderUsable: all.some(isUsable), + providerUsable: selected ? isUsable(selected) : false })); }; @@ -73,7 +83,7 @@ export class VideoSettings implements FoldDefinition { }; const current = selection(); - const usable = availability().filter(item => item.enabled && item.configured && item.models.length > 0); + const usable = availability().filter(isUsable); new Setting(itemsEl) .setName(COPY.provider) diff --git a/tests/helpers/settingsRendererFixtures.ts b/tests/helpers/settingsRendererFixtures.ts index 3b5d8774..0431512f 100644 --- a/tests/helpers/settingsRendererFixtures.ts +++ b/tests/helpers/settingsRendererFixtures.ts @@ -62,6 +62,9 @@ export function imageServiceModule() { getModelsForProvider: jest.fn(async (provider: string) => provider === 'google' ? [{ id: 'gemini-3.1-flash-image', name: 'Gemini 3.1 Flash Image' }] : [{ id: `${provider}-image`, name: `${provider} image` }]), + // Every provider has a key here; DefaultsWithoutProvider covers the + // real service, which has no adapter (and no models) without one. + getInitializedProviders: jest.fn(() => ['google', 'openai', 'openrouter']), updateSettings: jest.fn() })) }; diff --git a/tests/unit/DefaultsWithoutProvider.test.ts b/tests/unit/DefaultsWithoutProvider.test.ts index b427e81e..ab9cc307 100644 --- a/tests/unit/DefaultsWithoutProvider.test.ts +++ b/tests/unit/DefaultsWithoutProvider.test.ts @@ -108,12 +108,42 @@ describe('Defaults with no provider set up', () => { } }); - it('names the default image model rather than showing its id', async () => { + it('does not name the default image model when no image provider is set up', async () => { const h = await renderDefaults(freshInstall()); try { const { provider, model } = DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel!; const name = getImageModelDisplayName(provider, model); + // The premise: the catalog can name it without a key, which is how the + // summary used to show it. expect(name).toBeTruthy(); + // Pre-fix: the summary was that name while Model said "No models available". + expect(foldSummary(h.root, FOLD_TITLES.images)).toBe(COPY.noImageProvider); + expect(optionLabels(control(findRow(foldItems(h.root, FOLD_TITLES.images), COPY.model), 'select'))) + .toEqual(['No models available']); + } finally { + h.tab.destroy(); + } + }); + + it('says "Not set" when another image provider is set up but the selected one is not', async () => { + const settings = freshInstall(); + settings.providers.openai = { ...settings.providers.openai, enabled: true, apiKey: 'sk-test' }; + const h = await renderDefaults(settings); + try { + expect(DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel!.provider).not.toBe('openai'); + expect(foldSummary(h.root, FOLD_TITLES.images)).toBe(COPY.notSet); + } finally { + h.tab.destroy(); + } + }); + + it('names the default image model, not its id, once its provider is set up', async () => { + const { provider, model } = DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel!; + const settings = freshInstall(); + settings.providers[provider] = { ...settings.providers[provider], enabled: true, apiKey: 'test-key' }; + const h = await renderDefaults(settings); + try { + const name = getImageModelDisplayName(provider, model); expect(name).not.toBe(model); expect(foldSummary(h.root, FOLD_TITLES.images)).toBe(name); } finally { @@ -121,6 +151,29 @@ describe('Defaults with no provider set up', () => { } }); + it('does not name a video model when no video provider is set up', async () => { + const h = await renderDefaults(freshInstall()); + try { + expect(foldSummary(h.root, FOLD_TITLES.video)).toBe(COPY.noVideoProvider); + } finally { + h.tab.destroy(); + } + }); + + it('says "Not set" for a saved video provider whose key was removed', async () => { + const settings = freshInstall(); + const saved = DEFAULT_LLM_PROVIDER_SETTINGS.defaultVideoModel!; + const other = saved.provider === 'google' ? 'openrouter' : 'google'; + settings.providers[other] = { ...settings.providers[other], enabled: true, apiKey: 'test-key' }; + const h = await renderDefaults(settings); + try { + // Pre-fix: " · 16:9 · 720p" while Provider read "(unavailable)". + expect(foldSummary(h.root, FOLD_TITLES.video)).toBe(COPY.notSet); + } finally { + h.tab.destroy(); + } + }); + it('names a saved transcription model before its provider list has loaded', async () => { const h = await renderDefaults(freshInstall({ defaultTranscriptionModel: { provider: 'deepgram', model: 'nova-3' } })); try { diff --git a/tests/unit/SettingsSectionSummaries.test.ts b/tests/unit/SettingsSectionSummaries.test.ts index 851888ea..726c1d63 100644 --- a/tests/unit/SettingsSectionSummaries.test.ts +++ b/tests/unit/SettingsSectionSummaries.test.ts @@ -63,8 +63,13 @@ it('builds the subagent tuning parts the way Chat options does', () => { }); it('summarises images, video and voice', () => { - expect(imagesSummary('Gemini 3.1 Flash Image')).toBe('Gemini 3.1 Flash Image'); + expect(imagesSummary({ modelName: 'Gemini 3.1 Flash Image' })).toBe('Gemini 3.1 Flash Image'); + expect(imagesSummary({ modelName: 'Nano Banana 2', providerUsable: false, anyProviderUsable: false })).toBe('No image provider'); + expect(imagesSummary({ modelName: 'Nano Banana 2', providerUsable: false, anyProviderUsable: true })).toBe('Not set'); + expect(imagesSummary({})).toBe('Not set'); expect(videoSummary({ modelName: 'Veo 3.1', aspectRatio: '16:9', resolution: '720p' })).toBe('Veo 3.1 · 16:9 · 720p'); + expect(videoSummary({ modelName: 'Veo 3.1', aspectRatio: '16:9', resolution: '720p', anyProviderUsable: false })).toBe('No video provider'); + expect(videoSummary({ modelName: 'Veo 3.1', aspectRatio: '16:9', resolution: '720p', providerUsable: false })).toBe('Not set'); expect(voiceSummary({ transcriptionModel: 'Deepgram Nova-3', readAloudModel: 'HeyGen Voice', voice: 'Alloy' })) .toBe('Deepgram Nova-3 · HeyGen Voice (Alloy)'); expect(voiceSummary({ transcriptionModel: 'Deepgram Nova-3', readAloudModel: null })).toBe('Deepgram Nova-3'); From 16d1b48eb564f02bc1010427b24af6819b9b8358 Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:13:03 -0400 Subject: [PATCH 5/6] docs(skills): record stale reads after rebuild and same-name replay Co-Authored-By: Claude Opus 5.5 --- .../nexus-storage/references/failure-modes.md | 17 +++++++++++++++++ .claude/skills/nexus-storage/refinement-log.md | 6 ++++++ .../nexus-storage/references/failure-modes.md | 17 +++++++++++++++++ .cline/skills/nexus-storage/refinement-log.md | 6 ++++++ .../nexus-storage/references/failure-modes.md | 17 +++++++++++++++++ .codex/skills/nexus-storage/refinement-log.md | 6 ++++++ .../nexus-storage/references/failure-modes.md | 17 +++++++++++++++++ .skills/nexus-storage/refinement-log.md | 6 ++++++ 8 files changed, 92 insertions(+) diff --git a/.claude/skills/nexus-storage/references/failure-modes.md b/.claude/skills/nexus-storage/references/failure-modes.md index 8f942a47..9b0e0330 100644 --- a/.claude/skills/nexus-storage/references/failure-modes.md +++ b/.claude/skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.claude/skills/nexus-storage/refinement-log.md b/.claude/skills/nexus-storage/refinement-log.md index 6f5bff1d..6d3a72d7 100644 --- a/.claude/skills/nexus-storage/refinement-log.md +++ b/.claude/skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/.cline/skills/nexus-storage/references/failure-modes.md b/.cline/skills/nexus-storage/references/failure-modes.md index 8f942a47..9b0e0330 100644 --- a/.cline/skills/nexus-storage/references/failure-modes.md +++ b/.cline/skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.cline/skills/nexus-storage/refinement-log.md b/.cline/skills/nexus-storage/refinement-log.md index 6f5bff1d..6d3a72d7 100644 --- a/.cline/skills/nexus-storage/refinement-log.md +++ b/.cline/skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/.codex/skills/nexus-storage/references/failure-modes.md b/.codex/skills/nexus-storage/references/failure-modes.md index 8f942a47..9b0e0330 100644 --- a/.codex/skills/nexus-storage/references/failure-modes.md +++ b/.codex/skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.codex/skills/nexus-storage/refinement-log.md b/.codex/skills/nexus-storage/refinement-log.md index 6f5bff1d..6d3a72d7 100644 --- a/.codex/skills/nexus-storage/refinement-log.md +++ b/.codex/skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/.skills/nexus-storage/references/failure-modes.md b/.skills/nexus-storage/references/failure-modes.md index 8f942a47..9b0e0330 100644 --- a/.skills/nexus-storage/references/failure-modes.md +++ b/.skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.skills/nexus-storage/refinement-log.md b/.skills/nexus-storage/refinement-log.md index 6f5bff1d..6d3a72d7 100644 --- a/.skills/nexus-storage/refinement-log.md +++ b/.skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md From a7fb8a153b016648da2b2a591ac62d3710fc041f Mon Sep 17 00:00:00 2001 From: Professor Synapse <131487882+ProfSynapse@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:26:17 -0400 Subject: [PATCH 6/6] feat(models): refresh image and video generation catalogs Image: - Add Nano Banana 2.1 (gemini-nano-banana-2.1) on Google direct and OpenRouter, GPT Image 2.5 Sunburst/Flare via OpenRouter, FLUX.3 Image, Seedream 5.0 Pro and Seedream 5.0 Flash via OpenRouter. - Mark gpt-image-1 (shuts down 2026-10-23) and gpt-image-1.5 / gpt-image-1-mini (2026-12-01) as deprecated; gemini-2.5-flash-image's shutdown moved to 2027-03-15 per Google's deprecations page. Video: - Veo routes accept 16:9 and 9:16 only: drop 1:1 from Veo 3.1 (Google direct) and Veo 3.1 Fast (OpenRouter); add 4k to Veo 3.1 on OpenRouter. - Send OpenRouter's case-sensitive '4K' instead of the internal '4k'. - Add Seedance 2.5, Kling v3.0 Pro, Grok Imagine Video 1.5 and Runway Gen-4.5 via OpenRouter. - Google retires every Veo 3.1 preview id on 2026-10-22 and its successor needs the Interactions API, so the shipped video default moves to OpenRouter's google/veo-3.1-fast. Regenerate the tool catalogs for the image model enum, and add a nexus-model-updates protocol for video models. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/nexus-model-updates/SKILL.md | 12 ++- .../protocols/add-image-model.md | 5 ++ .../protocols/add-video-model.md | 64 ++++++++++++++++ .../nexus-model-updates/refinement-log.md | 2 + .cline/skills/nexus-model-updates/SKILL.md | 12 ++- .../protocols/add-image-model.md | 5 ++ .../protocols/add-video-model.md | 64 ++++++++++++++++ .../nexus-model-updates/refinement-log.md | 2 + .codex/skills/nexus-model-updates/SKILL.md | 12 ++- .../protocols/add-image-model.md | 5 ++ .../protocols/add-video-model.md | 64 ++++++++++++++++ .../nexus-model-updates/refinement-log.md | 2 + .skills/nexus-model-updates/SKILL.md | 12 ++- .../protocols/add-image-model.md | 5 ++ .../protocols/add-video-model.md | 64 ++++++++++++++++ .skills/nexus-model-updates/refinement-log.md | 2 + cli-first-tool-schemas.json | 4 +- schemas/5.20.4/cli-tools.json | 4 +- schemas/5.20.4/mcp-tools.json | 2 +- .../llm/adapters/google/GeminiImageAdapter.ts | 23 +++++- .../llm/adapters/openai/OpenAIImageAdapter.ts | 10 ++- .../openrouter/OpenRouterImageAdapter.ts | 69 ++++++++++++++++- src/services/llm/types/ImageTypes.ts | 14 ++-- src/services/llm/types/VideoTypes.ts | 74 ++++++++++++++++++- .../video/adapters/OpenRouterVideoAdapter.ts | 4 +- src/types/llm/ProviderTypes.ts | 8 +- tests/unit/VideoGenerationService.test.ts | 59 +++++++++++++++ tool-schemas.json | 2 +- 28 files changed, 563 insertions(+), 42 deletions(-) create mode 100644 .claude/skills/nexus-model-updates/protocols/add-video-model.md create mode 100644 .cline/skills/nexus-model-updates/protocols/add-video-model.md create mode 100644 .codex/skills/nexus-model-updates/protocols/add-video-model.md create mode 100644 .skills/nexus-model-updates/protocols/add-video-model.md diff --git a/.claude/skills/nexus-model-updates/SKILL.md b/.claude/skills/nexus-model-updates/SKILL.md index ad0dcdc7..9e9d220a 100644 --- a/.claude/skills/nexus-model-updates/SKILL.md +++ b/.claude/skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.claude/skills/nexus-model-updates/protocols/add-image-model.md b/.claude/skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018..b36083d1 100644 --- a/.claude/skills/nexus-model-updates/protocols/add-image-model.md +++ b/.claude/skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.claude/skills/nexus-model-updates/protocols/add-video-model.md b/.claude/skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 00000000..3f9bc6cf --- /dev/null +++ b/.claude/skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.claude/skills/nexus-model-updates/refinement-log.md b/.claude/skills/nexus-model-updates/refinement-log.md index 847c52b6..987755fa 100644 --- a/.claude/skills/nexus-model-updates/refinement-log.md +++ b/.claude/skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.cline/skills/nexus-model-updates/SKILL.md b/.cline/skills/nexus-model-updates/SKILL.md index ad0dcdc7..9e9d220a 100644 --- a/.cline/skills/nexus-model-updates/SKILL.md +++ b/.cline/skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.cline/skills/nexus-model-updates/protocols/add-image-model.md b/.cline/skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018..b36083d1 100644 --- a/.cline/skills/nexus-model-updates/protocols/add-image-model.md +++ b/.cline/skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.cline/skills/nexus-model-updates/protocols/add-video-model.md b/.cline/skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 00000000..3f9bc6cf --- /dev/null +++ b/.cline/skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.cline/skills/nexus-model-updates/refinement-log.md b/.cline/skills/nexus-model-updates/refinement-log.md index 847c52b6..987755fa 100644 --- a/.cline/skills/nexus-model-updates/refinement-log.md +++ b/.cline/skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.codex/skills/nexus-model-updates/SKILL.md b/.codex/skills/nexus-model-updates/SKILL.md index ad0dcdc7..9e9d220a 100644 --- a/.codex/skills/nexus-model-updates/SKILL.md +++ b/.codex/skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.codex/skills/nexus-model-updates/protocols/add-image-model.md b/.codex/skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018..b36083d1 100644 --- a/.codex/skills/nexus-model-updates/protocols/add-image-model.md +++ b/.codex/skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.codex/skills/nexus-model-updates/protocols/add-video-model.md b/.codex/skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 00000000..3f9bc6cf --- /dev/null +++ b/.codex/skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.codex/skills/nexus-model-updates/refinement-log.md b/.codex/skills/nexus-model-updates/refinement-log.md index 847c52b6..987755fa 100644 --- a/.codex/skills/nexus-model-updates/refinement-log.md +++ b/.codex/skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.skills/nexus-model-updates/SKILL.md b/.skills/nexus-model-updates/SKILL.md index ad0dcdc7..9e9d220a 100644 --- a/.skills/nexus-model-updates/SKILL.md +++ b/.skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.skills/nexus-model-updates/protocols/add-image-model.md b/.skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018..b36083d1 100644 --- a/.skills/nexus-model-updates/protocols/add-image-model.md +++ b/.skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.skills/nexus-model-updates/protocols/add-video-model.md b/.skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 00000000..3f9bc6cf --- /dev/null +++ b/.skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.skills/nexus-model-updates/refinement-log.md b/.skills/nexus-model-updates/refinement-log.md index 847c52b6..987755fa 100644 --- a/.skills/nexus-model-updates/refinement-log.md +++ b/.skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/cli-first-tool-schemas.json b/cli-first-tool-schemas.json index 22351f2e..b9ee3e1d 100644 --- a/cli-first-tool-schemas.json +++ b/cli-first-tool-schemas.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "selector": "--help", "toolCount": 80, "agentCount": 14, @@ -1787,7 +1787,7 @@ "type": "string", "required": false, "positional": false, - "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" + "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-nano-banana-2.1, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-nano-banana-2.1\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" }, { "name": "aspectRatio", diff --git a/schemas/5.20.4/cli-tools.json b/schemas/5.20.4/cli-tools.json index 22351f2e..b9ee3e1d 100644 --- a/schemas/5.20.4/cli-tools.json +++ b/schemas/5.20.4/cli-tools.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "selector": "--help", "toolCount": 80, "agentCount": 14, @@ -1787,7 +1787,7 @@ "type": "string", "required": false, "positional": false, - "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" + "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-nano-banana-2.1, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-nano-banana-2.1\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" }, { "name": "aspectRatio", diff --git a/schemas/5.20.4/mcp-tools.json b/schemas/5.20.4/mcp-tools.json index 70d80698..b117cbd8 100644 --- a/schemas/5.20.4/mcp-tools.json +++ b/schemas/5.20.4/mcp-tools.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "nexusVersion": "5.20.4", - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "toolCount": 2, "tools": [ { diff --git a/src/services/llm/adapters/google/GeminiImageAdapter.ts b/src/services/llm/adapters/google/GeminiImageAdapter.ts index 36b0f26d..ad9d7882 100644 --- a/src/services/llm/adapters/google/GeminiImageAdapter.ts +++ b/src/services/llm/adapters/google/GeminiImageAdapter.ts @@ -148,6 +148,21 @@ export const GEMINI_IMAGE_MODEL_SPECS: Readonly/endpoints. + 'gemini-nano-banana-2.1': { + openRouterId: 'google/gemini-nano-banana-2.1', + displayName: 'Nano Banana 2.1 (via OpenRouter)', + costPerImage: 0.0336, + pricingNote: '1120 output tokens at $30/M for 1K; 2K and 4K use more tokens; no 512', + maxReferenceImages: 14, + resolutions: ['1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-10-14' + }, + 'gpt-image-2.5-sunburst': { + openRouterId: 'openai/gpt-image-2.5-sunburst', + displayName: 'GPT Image 2.5 Sunburst (via OpenRouter)', + costPerImage: 0.0132, + pricingNote: '$30/M output image tokens, $8/M image and $5/M text input; quality is left at auto, so a 1024x1024 image is ~439 tokens at medium and more at higher qualities', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 32000, + lastUpdated: '2026-10-14' + }, + 'gpt-image-2.5-flare': { + openRouterId: 'openai/gpt-image-2.5-flare', + displayName: 'GPT Image 2.5 Flare (via OpenRouter)', + costPerImage: 0.0132, + pricingNote: '$30/M output image tokens, $8/M image and $5/M text input; quality is left at auto, so a 1024x1024 image is ~439 tokens at medium and more at higher qualities', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 32000, + lastUpdated: '2026-10-14' + }, + 'flux-3-image': { + openRouterId: 'black-forest-labs/flux-3-image', + displayName: 'FLUX.3 Image (via OpenRouter)', + costPerImage: 0.048, + // The endpoint also lists 768 and 1.5K tiers, which imageSize cannot express. + pricingNote: 'Per image by resolution tier: 1K $0.048, 2K $0.10, 4K $0.607', + maxReferenceImages: 10, + resolutions: ['1K', '2K', '4K'], + contextWindow: 4096, + lastUpdated: '2026-10-14' + }, + // BytePlus documents a 921,600-pixel minimum for Seedream 5.0 Pro and Flash, + // so 1K is real here, unlike Seedream 4.5 and 5.0 Lite. + 'seedream-5-pro': { + openRouterId: 'bytedance-seed/seedream-5-0-pro', + displayName: 'Seedream 5.0 Pro (via OpenRouter)', + costPerImage: 0.045, + pricingNote: '$0.045 per image, $0.09 at high resolution; $0.003 per reference image', + maxReferenceImages: 14, + resolutions: ['1K', '2K'], + contextWindow: 32000, + lastUpdated: '2026-10-14' + }, + 'seedream-5-flash': { + openRouterId: 'bytedance-seed/seedream-5-0-flash', + displayName: 'Seedream 5.0 Flash (via OpenRouter)', + costPerImage: 0.018, + pricingNote: '$0.018 per image; reference images free', + maxReferenceImages: 14, + resolutions: ['1K', '2K'], + contextWindow: 32000, + lastUpdated: '2026-10-14' } }; @@ -305,7 +370,7 @@ export class OpenRouterImageAdapter extends BaseImageAdapter { private httpReferer: string; private xTitle: string; - // gemini-2.5-flash-image is scheduled for shutdown on 2026-10-02. + // gemini-2.5-flash-image is deprecated. private readonly defaultModel = 'gemini-3.1-flash-image'; // Aspect ratios accepted across the catalog. Individual endpoints may diff --git a/src/services/llm/types/ImageTypes.ts b/src/services/llm/types/ImageTypes.ts index 4af03e5f..1a9b2b92 100644 --- a/src/services/llm/types/ImageTypes.ts +++ b/src/services/llm/types/ImageTypes.ts @@ -241,10 +241,10 @@ export type ImageProvider = 'openai' | 'google' | 'openrouter'; export type ImageModel = | 'gpt-image-2.5-sunburst' // OpenAI GPT Image 2.5 Sunburst (OpenAI direct) | 'gpt-image-2.5-flare' // OpenAI GPT Image 2.5 Flare (OpenAI direct) - | 'gpt-image-1' // OpenAI GPT Image 1 (OpenAI direct) - | 'gpt-image-1.5' // OpenAI GPT Image 1.5 (OpenAI direct) - | 'gpt-image-1-mini' // OpenAI GPT Image 1 Mini (OpenAI direct) - | 'gemini-2.5-flash-image' // Google Nano Banana legacy, shuts down 2026-10-02 + | 'gpt-image-1' // OpenAI GPT Image 1 (OpenAI direct), shuts down 2026-10-23 + | 'gpt-image-1.5' // OpenAI GPT Image 1.5 (OpenAI direct), shuts down 2026-12-01 + | 'gpt-image-1-mini' // OpenAI GPT Image 1 Mini (OpenAI direct), shuts down 2026-12-01 + | 'gemini-2.5-flash-image' // Google Nano Banana legacy, deprecated (shutdown 2027-03-15) | 'gemini-3-pro-image-preview' // Google Nano Banana Pro preview id | 'gemini-3.1-flash-image-preview' // Google Nano Banana 2 preview id | 'gpt-5-image' // OpenAI GPT-5 Image (OpenRouter only) @@ -258,7 +258,11 @@ export type ImageModel = | 'gpt-5-image-mini' // OpenAI GPT-5 Image Mini (OpenRouter only) | 'gpt-image-2' // OpenAI GPT Image 2 (OpenRouter, and the OpenAI adapter) | 'seedream-4.5' // ByteDance Seedream 4.5 (OpenRouter only) - | 'seedream-5-lite'; // ByteDance Seedream 5.0 Lite (OpenRouter only) + | 'seedream-5-lite' // ByteDance Seedream 5.0 Lite (OpenRouter only) + | 'gemini-nano-banana-2.1' // Google Nano Banana 2.1 (Google direct and OpenRouter) + | 'flux-3-image' // Black Forest Labs FLUX.3 Image (OpenRouter only) + | 'seedream-5-pro' // ByteDance Seedream 5.0 Pro (OpenRouter only) + | 'seedream-5-flash'; // ByteDance Seedream 5.0 Flash (OpenRouter only) // Aspect ratio constants for Nano Banana models export enum AspectRatio { diff --git a/src/services/llm/types/VideoTypes.ts b/src/services/llm/types/VideoTypes.ts index a19074ae..667ab5ed 100644 --- a/src/services/llm/types/VideoTypes.ts +++ b/src/services/llm/types/VideoTypes.ts @@ -34,15 +34,25 @@ export interface ResolvedVideoSelection { reason?: string; } +/** + * Video model catalog. Google direct facts come from + * https://ai.google.dev/gemini-api/docs/video and /docs/deprecations; + * OpenRouter facts from https://openrouter.ai/api/v1/videos/models, which the + * OpenRouter adapter also re-reads at request time to validate a request. + */ const VIDEO_MODELS: VideoModelDeclaration[] = [ { + // Google shuts down all Veo 3.1 preview ids on 2026-10-22 and names + // gemini-omni-1.1-flash as the replacement. That model is served only by + // the Interactions API, which GoogleVideoAdapter does not speak yet, so + // there is no Google-direct successor in this catalog. provider: 'google', id: 'veo-3.1-generate-preview', - name: 'Veo 3.1', + name: 'Veo 3.1 preview (shuts down 2026-10-22)', execution: 'long-running-operation', supportsReferenceImage: true, supportsAudioPrompting: true, - aspectRatios: ['16:9', '9:16', '1:1'], + aspectRatios: ['16:9', '9:16'], resolutions: ['720p', '1080p', '4k'], durations: [4, 6, 8], defaultAspectRatio: '16:9', @@ -55,7 +65,7 @@ const VIDEO_MODELS: VideoModelDeclaration[] = [ execution: 'long-running-operation', supportsReferenceImage: true, supportsAudioPrompting: true, - aspectRatios: ['16:9', '9:16', '1:1'], + aspectRatios: ['16:9', '9:16'], resolutions: ['720p', '1080p', '4k'], durations: [4, 6, 8], defaultAspectRatio: '16:9', @@ -82,11 +92,67 @@ const VIDEO_MODELS: VideoModelDeclaration[] = [ supportsReferenceImage: true, supportsAudioPrompting: true, aspectRatios: ['16:9', '9:16'], - resolutions: ['720p', '1080p'], + resolutions: ['720p', '1080p', '4k'], durations: [4, 6, 8], defaultAspectRatio: '16:9', defaultResolution: '720p', }, + { + // Added 2026-10-14 from /api/v1/videos/models. Listing also offers 480p and 4:3/3:4/21:9, outside the internal unions. + provider: 'openrouter', + id: 'bytedance/seedance-2.5', + name: 'ByteDance: Seedance 2.5 via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: true, + supportsAudioPrompting: true, + aspectRatios: ['16:9', '9:16', '1:1'], + resolutions: ['720p'], + durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, + { + // Added 2026-10-14 from /api/v1/videos/models. First/last-frame control only; no input_references. + provider: 'openrouter', + id: 'kwaivgi/kling-v3.0-pro', + name: 'Kling: Video v3.0 Pro via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: false, + supportsAudioPrompting: true, + aspectRatios: ['16:9', '9:16', '1:1'], + resolutions: ['720p'], + durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, + { + // Added 2026-10-14 from /api/v1/videos/models. Starting-frame image only; the listing does not declare generate_audio. + provider: 'openrouter', + id: 'x-ai/grok-imagine-video-1.5', + name: 'xAI: Grok Imagine Video 1.5 via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: false, + supportsAudioPrompting: false, + aspectRatios: ['16:9', '9:16', '1:1'], + resolutions: ['720p', '1080p'], + durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, + { + // Added 2026-10-14 from /api/v1/videos/models. First-frame image only; no audio. + provider: 'openrouter', + id: 'runway/gen-4.5', + name: 'Runway: Gen-4.5 via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: false, + supportsAudioPrompting: false, + aspectRatios: ['16:9', '9:16'], + resolutions: ['720p'], + durations: [2, 3, 4, 5, 6, 7, 8, 9, 10], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, ]; export const VIDEO_PROVIDER_PRIORITY: VideoProvider[] = ['google', 'openrouter']; diff --git a/src/services/video/adapters/OpenRouterVideoAdapter.ts b/src/services/video/adapters/OpenRouterVideoAdapter.ts index a852f46d..730d5f91 100644 --- a/src/services/video/adapters/OpenRouterVideoAdapter.ts +++ b/src/services/video/adapters/OpenRouterVideoAdapter.ts @@ -201,7 +201,9 @@ export class OpenRouterVideoAdapter implements VideoGenerationAdapter { const body: Record = { model: request.model, prompt: request.prompt, - resolution: request.resolution, + // The internal value is '4k' (Google's spelling); OpenRouter's resolution + // enum is case-sensitive and spells it '4K'. + resolution: request.resolution === '4k' ? '4K' : request.resolution, aspect_ratio: request.aspectRatio, }; diff --git a/src/types/llm/ProviderTypes.ts b/src/types/llm/ProviderTypes.ts index c25ff96d..c0e63613 100644 --- a/src/types/llm/ProviderTypes.ts +++ b/src/types/llm/ProviderTypes.ts @@ -262,9 +262,13 @@ export const DEFAULT_LLM_PROVIDER_SETTINGS: LLMProviderSettings = { provider: 'google', model: 'gemini-3.1-flash-image' }, + // Google shuts down every Veo 3.1 preview id on 2026-10-22, and its + // successor (gemini-omni-1.1-flash) needs the Interactions API, which the + // Google video adapter does not speak. Veo 3.1 Fast via OpenRouter is the + // same model family on a route that is not being retired. defaultVideoModel: { - provider: 'google', - model: 'veo-3.1-generate-preview', + provider: 'openrouter', + model: 'google/veo-3.1-fast', aspectRatio: '16:9', resolution: '720p' }, diff --git a/tests/unit/VideoGenerationService.test.ts b/tests/unit/VideoGenerationService.test.ts index 6d71703e..30a45ebd 100644 --- a/tests/unit/VideoGenerationService.test.ts +++ b/tests/unit/VideoGenerationService.test.ts @@ -8,6 +8,7 @@ import { OpenRouterVideoAdapter } from '../../src/services/video/adapters/OpenRo import { GenerateVideoTool } from '../../src/agents/promptManager/tools/generateVideo'; import { CheckGeneratedArtifactTool } from '../../src/agents/promptManager/tools/checkGeneratedArtifact'; import { DEFAULT_LLM_PROVIDER_SETTINGS, type LLMProviderSettings } from '../../src/types/llm/ProviderTypes'; +import { getVideoModel } from '../../src/services/llm/types/VideoTypes'; type MockVault = Vault & { getAbstractFileByPath: jest.Mock; @@ -359,6 +360,53 @@ describe('OpenRouterVideoAdapter', () => { expect(result.providerJobId).toBe('job-1'); }); + it('sends 4K in the case OpenRouter accepts when the internal resolution is 4k', async () => { + let submitted: Record | undefined; + __setRequestUrlMock(async (request) => { + if (request.url.endsWith('/videos/models')) { + return { + status: 200, + headers: {}, + text: '{}', + json: { + data: [{ + id: 'google/veo-3.1', + supported_resolutions: ['720p', '1080p', '4K'], + supported_aspect_ratios: ['16:9', '9:16'], + supported_durations: [4, 6, 8], + }] + }, + arrayBuffer: new ArrayBuffer(0), + }; + } + if (request.url.endsWith('/videos')) { + submitted = JSON.parse(String(request.body)) as Record; + return { + status: 202, + headers: {}, + text: '{}', + json: { id: 'job-4k', status: 'completed', unsigned_urls: ['https://cdn.example.com/4k.mp4'] }, + arrayBuffer: new ArrayBuffer(0), + }; + } + return { status: 200, headers: {}, text: '', json: null, arrayBuffer: new Uint8Array([1]).buffer }; + }); + + const adapter = new OpenRouterVideoAdapter({ apiKey: 'test-key', vault: makeVault() }); + await adapter.generate({ + prompt: 'A lighthouse at dusk.', + provider: 'openrouter', + model: 'google/veo-3.1', + aspectRatio: '16:9', + resolution: '4k', + seconds: 8, + pollIntervalMs: 1, + timeoutMs: 1000, + }); + + expect(submitted?.resolution).toBe('4K'); + }); + it('does not attach the Bearer key when polling an off-allowlist polling_url', async () => { const authByUrl: Record = {}; __setRequestUrlMock(async (request) => { @@ -721,3 +769,14 @@ describe('GoogleVideoAdapter credential allowlist', () => { expect(keyByUrl[trickUrl]).toBeUndefined(); }); }); + +describe('shipped video default', () => { + it('names a catalog model that offers the shipped aspect ratio and resolution', () => { + const shipped = DEFAULT_LLM_PROVIDER_SETTINGS.defaultVideoModel; + expect(shipped).toMatchObject({ provider: 'openrouter', model: 'google/veo-3.1-fast' }); + const declaration = getVideoModel(shipped?.provider, shipped?.model); + expect(declaration).toBeDefined(); + expect(declaration?.aspectRatios).toContain(shipped?.aspectRatio); + expect(declaration?.resolutions).toContain(shipped?.resolution); + }); +}); diff --git a/tool-schemas.json b/tool-schemas.json index 70d80698..b117cbd8 100644 --- a/tool-schemas.json +++ b/tool-schemas.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "nexusVersion": "5.20.4", - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "toolCount": 2, "tools": [ {