diff --git a/ui/tests/agentCapabilityPorts.test.mjs b/ui/tests/agentCapabilityPorts.test.mjs new file mode 100644 index 00000000..190b2a6b --- /dev/null +++ b/ui/tests/agentCapabilityPorts.test.mjs @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, extname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { JSDOM } from 'jsdom' + +/** + * Paso 3 gate — freeze the current Agent Mode ports. + * + * This test is the WanGP-wall equivalent for Wizard/UI communication: + * it names today's leaks so they cannot grow, and it fails when a + * later slice PR forgets to shrink the allowlist. + * + * It does not move domain logic. applicationAdapters.ts remains the + * only authorized store-writing adapter module. + */ + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)) +const SRC = join(TEST_DIR, '../src') +const AGENT_ROOT = join(SRC, 'features/agent') +const FEATURES_ROOT = join(SRC, 'features') + +const SET_STATE_ALLOWLIST = [ + ['labActions.ts', 'stageStoryMusicVideo', 2], + ['labActions.ts', 'stageStoryVideo', 3], + ['queueActions.ts', 'cancelCanonicalQueueTask', 1], + ['queueActions.ts', 'resumeCanonicalQueueTask', 1], + ['workspaceActions.ts', 'authoritativeWorkspaces', 1], +] + +const SLICE_AGENT_IMPORT_ALLOWLIST = [ + ['series/SeriesEpisodePanel.tsx', '../agent/agentUiBus'], + ['series/SeriesLabPanel.tsx', '../agent/agentUiBus'], + ['series/SeriesReviewPanel.tsx', '../agent/agentUiBus'], + ['stories/StoryLabPanel.tsx', '../agent/agentUiBus'], +] + +const LEGACY_EXECUTE_ALLOWLIST = [ + 'attach_videoclip_alternative_song', + 'mount_videoclip_alternative_song', + 'prepare_video', + 'prepare_image', + 'prepare_audio', + 'prepare_3d', + 'queue_sfx_pack', + 'start_generation', + 'attach_studio_references', + 'configure_studio_loras', + 'inspect_queue', + 'cancel_task', + 'resume_task', + 'retry_task', + 'select_workspace', + 'create_workspace', + 'generate_comic_panel', + 'update_character_kit', + 'add_video_editor_clips', + 'order_video_editor_clips', + 'trim_video_editor_clip', + 'add_video_editor_audio', + 'validate_video_editor_timeline', + 'export_video_editor', + 'track_video_editor_export', +] + +const AGENT_ACTIONS_IMPORTS = [ + '../../api/client', + '../../stores/useStore', + '../../types', + '../comics/generateArtwork', + '../comics/store', + '../stories/musicVideoLook', + './agentContract', + './agentExamples', + './agentUiBus', + './alternativeSongActions', + './applicationAdapters', + './audioActions', + './capabilityRegistry', + './capabilityRunner', + './characterKitActions', + './commandContract', + './labActions', + './queueActions', + './sfxPack', + './studioGuidance', + './videoEditorActions', + './wizardContext', + './workspaceActions', +] + +const LAB_ACTIONS_IMPORTS = [ + '../../api/client', + '../../stores/useStore', + '../comics/generateArtwork', + '../comics/model', + '../comics/store', + '../series/model', + '../series/store', + '../stories/adaptations', + '../stories/model', + '../stories/musicModel', + '../stories/musicVideoLook', + '../stories/musicVideoSelection', + '../stories/provider', + '../stories/store', + '../stories/types', + './agentActions', + './agentContract', + './agentUiBus', +] + +function walk(dir) { + const files = [] + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) files.push(...walk(path)) + else if (extname(path) === '.ts' || extname(path) === '.tsx') files.push(path) + } + return files +} + +function enclosingFunction(lines, index) { + for (let i = index; i >= 0; i -= 1) { + const named = lines[i].match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/) + if (named) return named[1] + const assigned = lines[i].match(/^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/) + if (assigned) return assigned[1] + } + return '' +} + +function setStateSites() { + const found = [] + for (const path of walk(AGENT_ROOT)) { + const file = relative(AGENT_ROOT, path).replaceAll('\\', '/') + if (file === 'applicationAdapters.ts') continue + const lines = readFileSync(path, 'utf8').split('\n') + for (let i = 0; i < lines.length; i += 1) { + if (!/\buseStore\.setState\s*\(/.test(lines[i])) continue + found.push([file, enclosingFunction(lines, i)]) + } + } + return found +} + +function countTuples(rows) { + const counts = new Map() + for (const [file, fn] of rows) { + const key = `${file}\0${fn}` + counts.set(key, (counts.get(key) || 0) + 1) + } + return [...counts.entries()] + .map(([key, count]) => { + const [file, fn] = key.split('\0') + return [file, fn, count] + }) + .sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])) +} + +function importSpecifiers(source) { + const found = new Set() + const pattern = /(?:from\s*|import\s*\()\s*['"]([^'"]+)['"]/g + let match + while ((match = pattern.exec(source))) found.add(match[1]) + return [...found].sort() +} + +function sliceAgentImports() { + const found = [] + for (const path of walk(FEATURES_ROOT)) { + const rel = relative(FEATURES_ROOT, path).replaceAll('\\', '/') + if (rel.startsWith('agent/')) continue + const source = readFileSync(path, 'utf8') + const pattern = /(?:from\s*|import\s*\()\s*['"]([^'"]*agent\/[^'"]+)['"]/g + let match + while ((match = pattern.exec(source))) found.push([rel, match[1]]) + } + return found.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])) +} + +function diffLists(actual, expected) { + const a = new Set(actual.map(item => JSON.stringify(item))) + const b = new Set(expected.map(item => JSON.stringify(item))) + return { + added: [...a].filter(item => !b.has(item)).map(item => JSON.parse(item)), + removed: [...b].filter(item => !a.has(item)).map(item => JSON.parse(item)), + } +} + +function usesAdapters(execute) { + return /context\.adapters|\.adapters\./.test(Function.prototype.toString.call(execute)) +} + +function installDom() { + const dom = new JSDOM('', { url: 'http://localhost/' }) + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + }) + window.matchMedia = () => ({ matches: false }) +} + +test('useStore.setState in features/agent stays on the named allowlist outside applicationAdapters', () => { + const actual = countTuples(setStateSites()) + const { added, removed } = diffLists(actual, SET_STATE_ALLOWLIST) + assert.deepEqual( + { actual, added, removed }, + { actual: SET_STATE_ALLOWLIST, added: [], removed: [] }, + 'Direct store writes in Agent Mode must shrink the allowlist when a function moves to a slice adapter, and must not grow. ' + + `added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`, + ) + assert.equal(actual.reduce((total, row) => total + row[2], 0), 8) +}) + +test('other feature slices do not import Agent Mode except the frozen UI-bus listeners', () => { + const actual = sliceAgentImports() + const { added, removed } = diffLists(actual, SLICE_AGENT_IMPORT_ALLOWLIST) + assert.deepEqual( + { actual, added, removed }, + { actual: SLICE_AGENT_IMPORT_ALLOWLIST, added: [], removed: [] }, + 'Story/Series (and later slices) may keep listening on agentUiBus until that bus moves. ' + + 'They must not import agent domain modules. ' + + `added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`, + ) +}) + +test('capabilities execute through adapters except the frozen legacy executors', async () => { + installDom() + const { listCapabilities } = await import('../src/features/agent/capabilityRegistry.ts') + const registered = listCapabilities() + const legacy = registered.filter(item => !usesAdapters(item.execute)).map(item => item.name).sort() + const expected = [...LEGACY_EXECUTE_ALLOWLIST].sort() + const added = legacy.filter(name => !expected.includes(name)) + const removed = expected.filter(name => !legacy.includes(name)) + assert.deepEqual( + { legacy, added, removed }, + { legacy: expected, added: [], removed: [] }, + 'New capabilities must call context.adapters.*. Moving a legacy executor onto an adapter must shrink this list. ' + + `added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`, + ) + assert.equal(registered.length, 73) + assert.equal(legacy.length, 25) +}) + +test('agentActions.ts and labActions.ts keep their current module graph until a slice PR shrinks it', () => { + const agentActions = importSpecifiers(readFileSync(join(AGENT_ROOT, 'agentActions.ts'), 'utf8')) + const labActions = importSpecifiers(readFileSync(join(AGENT_ROOT, 'labActions.ts'), 'utf8')) + const agentDiff = diffLists(agentActions, AGENT_ACTIONS_IMPORTS) + const labDiff = diffLists(labActions, LAB_ACTIONS_IMPORTS) + assert.deepEqual( + { agentActions, ...agentDiff }, + { agentActions: AGENT_ACTIONS_IMPORTS, added: [], removed: [] }, + `agentActions imports changed: added=${JSON.stringify(agentDiff.added)} removed=${JSON.stringify(agentDiff.removed)}`, + ) + assert.deepEqual( + { labActions, ...labDiff }, + { labActions: LAB_ACTIONS_IMPORTS, added: [], removed: [] }, + `labActions imports changed: added=${JSON.stringify(labDiff.added)} removed=${JSON.stringify(labDiff.removed)}`, + ) +})