diff --git a/apps/cockpit/cockpit-capability-wiring.spec.ts b/apps/cockpit/cockpit-capability-wiring.spec.ts new file mode 100644 index 000000000..f15186f11 --- /dev/null +++ b/apps/cockpit/cockpit-capability-wiring.spec.ts @@ -0,0 +1,86 @@ +import { cockpitManifest } from '@threadplane/cockpit-registry'; +import { capabilities } from './scripts/capability-registry'; +import { + buildNavigationTree, + capabilityModules, +} from './src/lib/route-resolution'; + +/** + * The cockpit site is assembled from three lists that nothing forced to agree: + * + * - `apps/cockpit/scripts/capability-registry.ts` — what serve/build/deploy know about; + * - `libs/cockpit-registry` `cockpitManifest` — what the Next route can resolve; + * - `capabilityModules` in `route-resolution.ts` — what supplies a page's assets. + * + * When the `runtimes` product shipped, only the first list learned about it, so + * `/runtimes/core-capabilities//overview/` threw + * "No manifest entry found …" and every runtime page 500'd in production while + * the whole suite stayed green. These assertions are the missing coupling. + */ +describe('cockpit capability wiring', () => { + const manifestKey = (e: { product: string; section: string; topic: string }) => + `${e.product}/${e.section}/${e.topic}`; + + it('gives every registered capability a resolvable manifest entry', () => { + const manifestKeys = new Set(cockpitManifest.map(manifestKey)); + + const unroutable = capabilities + .map((capability) => `${capability.product}/core-capabilities/${capability.topic}`) + .filter((key) => !manifestKeys.has(key)); + + expect(unroutable).toEqual([]); + }); + + it('gives every registered capability a cockpit module in route-resolution', () => { + const moduleKeys = new Set( + capabilityModules.map((module) => manifestKey(module.manifestIdentity)) + ); + + const unwired = capabilities + .map((capability) => `${capability.product}/core-capabilities/${capability.topic}`) + .filter((key) => !moduleKeys.has(key)); + + expect(unwired).toEqual([]); + }); + + it('points every cockpit module at a capability that still exists', () => { + const capabilityKeys = new Set( + capabilities.map( + (capability) => `${capability.product}/core-capabilities/${capability.topic}` + ) + ); + + const orphans = capabilityModules + .map((module) => manifestKey(module.manifestIdentity)) + .filter((key) => !capabilityKeys.has(key)); + + expect(orphans).toEqual([]); + }); + + it('surfaces every manifest product in the navigation tree', () => { + const manifestProducts = [...new Set(cockpitManifest.map((entry) => entry.product))]; + const navigationProducts = buildNavigationTree(cockpitManifest).map( + (product) => product.product + ); + + expect([...manifestProducts].sort()).toEqual([...navigationProducts].sort()); + + for (const product of buildNavigationTree(cockpitManifest)) { + const entries = product.sections.flatMap((section) => section.entries); + expect({ product: product.product, empty: entries.length === 0 }).toEqual({ + product: product.product, + empty: false, + }); + } + }); + + it('keeps every registry product inside the CockpitProduct union', () => { + // `cockpitManifest` is typed `CockpitManifestEntry[]`, so a product that is + // not in the union cannot appear here — the runtime check is that the + // registry's products are all representable in the manifest. + const manifestProducts = new Set(cockpitManifest.map((entry) => entry.product)); + const registryProducts = [...new Set(capabilities.map((c) => c.product))]; + + expect(registryProducts.filter((p) => !manifestProducts.has(p))).toEqual([]); + }); +}); diff --git a/apps/cockpit/src/lib/navigation-labels.ts b/apps/cockpit/src/lib/navigation-labels.ts index 73641ee93..f0f588d48 100644 --- a/apps/cockpit/src/lib/navigation-labels.ts +++ b/apps/cockpit/src/lib/navigation-labels.ts @@ -3,10 +3,11 @@ export const PRODUCT_LABELS: Record = { 'langgraph': 'LangGraph', 'render': 'Render', 'chat': 'Chat', + 'runtimes': 'Runtimes', }; export function stripProductPrefix(title: string): string { - const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ']; + const prefixes = ['Deep Agents ', 'LangGraph ', 'Render ', 'Chat ', 'Runtimes ']; for (const p of prefixes) { if (title.startsWith(p)) return title.slice(p.length); } diff --git a/apps/cockpit/src/lib/route-resolution.spec.ts b/apps/cockpit/src/lib/route-resolution.spec.ts index 5a5e65dd6..7b0956eeb 100644 --- a/apps/cockpit/src/lib/route-resolution.spec.ts +++ b/apps/cockpit/src/lib/route-resolution.spec.ts @@ -47,22 +47,78 @@ describe('buildNavigationTree', () => { it('groups manifest entries by product and section', () => { const tree = buildNavigationTree(cockpitManifest); - expect(tree).toHaveLength(5); - expect(tree[0]).toMatchObject({ - product: 'deep-agents', - }); - expect(tree[1]).toMatchObject({ - product: 'langgraph', - }); - expect(tree[2]).toMatchObject({ - product: 'ag-ui', - }); - expect(tree[3]).toMatchObject({ - product: 'render', - }); - expect(tree[4]).toMatchObject({ - product: 'chat', + expect(tree.map((product) => product.product)).toEqual([ + 'deep-agents', + 'langgraph', + 'ag-ui', + 'render', + 'chat', + 'runtimes', + ]); + }); + + it('lists every runtimes topic under core-capabilities', () => { + const runtimes = buildNavigationTree(cockpitManifest).find( + (product) => product.product === 'runtimes' + ); + const coreCapabilities = runtimes?.sections.find( + (section) => section.section === 'core-capabilities' + ); + + expect(coreCapabilities?.entries.map((entry) => entry.topic)).toEqual([ + 'microsoft-agent-framework', + 'aws-strands', + 'mastra', + ]); + }); +}); + +describe('runtimes capability presentation', () => { + const resolveRuntime = (topic: string, language: 'python' | 'typescript' = 'python') => + resolveCockpitEntry({ + manifest: cockpitManifest, + product: 'runtimes', + section: 'core-capabilities', + topic, + page: 'overview', + language, }); + + it('resolves each runtime topic instead of throwing', () => { + for (const topic of ['microsoft-agent-framework', 'aws-strands', 'mastra']) { + expect(resolveRuntime(topic)).toMatchObject({ + product: 'runtimes', + topic, + entryKind: 'capability', + }); + } + }); + + it('serves Python-lane runtimes from their registered module assets', () => { + const presentation = getCapabilityPresentation(resolveRuntime('aws-strands')); + + expect(presentation.kind).toBe('capability'); + if (presentation.kind !== 'capability') return; + expect(presentation.runtimeUrl).toBe('runtimes/aws-strands'); + expect(presentation.backendAssetPaths).toContain( + 'cockpit/runtimes/aws-strands/python/src/agent.py' + ); + }); + + it('falls back to the Angular-lane module for a runtime with no Python lane', () => { + const presentation = getCapabilityPresentation(resolveRuntime('mastra')); + + expect(presentation.kind).toBe('capability'); + if (presentation.kind !== 'capability') return; + // The manifest entry's language is 'python' (the canonical URL lane) but + // Mastra's only descriptor is the Angular one — the lookup must still find + // it rather than falling through to non-existent cockpit/runtimes/mastra/python paths. + expect(presentation.codeAssetPaths).toContain( + 'cockpit/runtimes/mastra/angular/src/app/mastra.component.ts' + ); + expect(presentation.promptAssetPaths).toEqual([ + 'cockpit/runtimes/mastra/angular/prompts/mastra.md', + ]); }); }); diff --git a/apps/cockpit/src/lib/route-resolution.ts b/apps/cockpit/src/lib/route-resolution.ts index 2dba74b29..0013018db 100644 --- a/apps/cockpit/src/lib/route-resolution.ts +++ b/apps/cockpit/src/lib/route-resolution.ts @@ -18,6 +18,7 @@ import { agUiToolViewsPythonModule } from '../../../../cockpit/ag-ui/tool-views/ import { agUiJsonRenderPythonModule } from '../../../../cockpit/ag-ui/json-render/python/src/index'; import { agUiClientToolsPythonModule } from '../../../../cockpit/ag-ui/client-tools/python/src/index'; import { agUiA2uiPythonModule } from '../../../../cockpit/ag-ui/a2ui/python/src/index'; +import { agUiSubagentsPythonModule } from '../../../../cockpit/ag-ui/subagents/python/src/index'; import { deepAgentsMemoryPythonModule } from '../../../../cockpit/deep-agents/memory/python/src/index'; import { deepAgentsPlanningPythonModule } from '../../../../cockpit/deep-agents/planning/python/src/index'; import { deepAgentsFilesystemPythonModule } from '../../../../cockpit/deep-agents/filesystem/python/src/index'; @@ -41,6 +42,11 @@ import { chatGenerativeUiPythonModule } from '../../../../cockpit/chat/generativ import { chatDebugPythonModule } from '../../../../cockpit/chat/debug/python/src/index'; import { chatThemingPythonModule } from '../../../../cockpit/chat/theming/python/src/index'; import { chatA2uiPythonModule } from '../../../../cockpit/chat/a2ui/python/src/index'; +import { runtimesMicrosoftAgentFrameworkPythonModule } from '../../../../cockpit/runtimes/microsoft-agent-framework/python/src/index'; +import { runtimesAwsStrandsPythonModule } from '../../../../cockpit/runtimes/aws-strands/python/src/index'; +// Mastra has no Python lane — its backend is the Node AG-UI service +// deployments/ag-ui-mastra — so its descriptor lives beside the Angular app. +import { runtimesMastraAngularModule } from '../../../../cockpit/runtimes/mastra/angular/src/index'; export interface ResolveCockpitEntryOptions { manifest: CockpitManifestEntry[]; @@ -79,7 +85,33 @@ export type CapabilityPresentation = devPort?: number; }; -const capabilityModules = [ +/** + * Shape a `cockpit/**\/src/index.ts` descriptor must satisfy to be wired into + * the cockpit. Each example declares its own structural copy of this interface + * (standalone-examples rule), so the fields diverge: the Angular lane carries + * no backend/docs assets. Declaring the element type here keeps the registry + * heterogeneous without widening every reader to a union. + */ +export interface RegisteredCapabilityModule { + id: string; + manifestIdentity: { + product: string; + section: string; + topic: string; + page: string; + language: string; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths?: string[]; + docsAssetPaths?: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const capabilityModules: RegisteredCapabilityModule[] = [ langgraphStreamingPythonModule, langgraphPersistencePythonModule, langgraphInterruptsPythonModule, @@ -95,6 +127,7 @@ const capabilityModules = [ agUiJsonRenderPythonModule, agUiClientToolsPythonModule, agUiA2uiPythonModule, + agUiSubagentsPythonModule, deepAgentsMemoryPythonModule, deepAgentsPlanningPythonModule, deepAgentsFilesystemPythonModule, @@ -118,6 +151,9 @@ const capabilityModules = [ chatDebugPythonModule, chatThemingPythonModule, chatA2uiPythonModule, + runtimesMicrosoftAgentFrameworkPythonModule, + runtimesAwsStrandsPythonModule, + runtimesMastraAngularModule, ]; export const toCockpitPath = (entry: CockpitManifestEntry): string => @@ -183,7 +219,14 @@ export const resolveCockpitEntry = ({ export const buildNavigationTree = ( manifest: CockpitManifestEntry[] ): NavigationProduct[] => { - const products: CockpitManifestEntry['product'][] = ['deep-agents', 'langgraph', 'ag-ui', 'render', 'chat']; + const products: CockpitManifestEntry['product'][] = [ + 'deep-agents', + 'langgraph', + 'ag-ui', + 'render', + 'chat', + 'runtimes', + ]; const sections: CockpitManifestEntry['section'][] = [ 'getting-started', 'core-capabilities', @@ -221,14 +264,22 @@ export const getCapabilityPresentation = ( }; } - const module = capabilityModules.find( - (candidate) => - candidate.manifestIdentity.product === entry.product && - candidate.manifestIdentity.section === entry.section && - candidate.manifestIdentity.topic === entry.topic && - candidate.manifestIdentity.page === entry.page && - candidate.manifestIdentity.language === entry.language - ); + const matchesIdentity = (candidate: RegisteredCapabilityModule): boolean => + candidate.manifestIdentity.product === entry.product && + candidate.manifestIdentity.section === entry.section && + candidate.manifestIdentity.topic === entry.topic && + candidate.manifestIdentity.page === entry.page; + + // Prefer the module whose lane matches the requested language. Fall back to + // the topic's only module when no lane matches: a topic with no Python lane + // (runtimes/mastra) still resolves to its real assets instead of silently + // falling through to the manifest's generic, non-existent Python paths. + const module = + capabilityModules.find( + (candidate) => + matchesIdentity(candidate) && + candidate.manifestIdentity.language === entry.language + ) ?? capabilityModules.find(matchesIdentity); return { kind: 'capability', diff --git a/libs/cockpit-registry/src/lib/manifest.spec.ts b/libs/cockpit-registry/src/lib/manifest.spec.ts index aff5294d5..7dfa834b4 100644 --- a/libs/cockpit-registry/src/lib/manifest.spec.ts +++ b/libs/cockpit-registry/src/lib/manifest.spec.ts @@ -97,6 +97,7 @@ describe('cockpitManifest', () => { 'ag-ui/getting-started/overview', 'render/getting-started/overview', 'chat/getting-started/overview', + 'runtimes/getting-started/overview', ]); }); @@ -105,7 +106,7 @@ describe('cockpitManifest', () => { (entry) => entry.entryKind === 'capability' ); - expect(capabilityEntries).toHaveLength(36); + expect(capabilityEntries).toHaveLength(42); for (const entry of capabilityEntries) { expect(entry.supportedLanguages).toEqual(['python']); diff --git a/libs/cockpit-registry/src/lib/manifest.ts b/libs/cockpit-registry/src/lib/manifest.ts index 505a91c8f..b71703d59 100644 --- a/libs/cockpit-registry/src/lib/manifest.ts +++ b/libs/cockpit-registry/src/lib/manifest.ts @@ -28,11 +28,20 @@ const APPROVED_TOPICS = { 'subgraphs', 'time-travel', 'deployment-runtime', + 'client-tools', ], }, 'ag-ui': { 'getting-started': ['overview'], - 'core-capabilities': ['streaming', 'interrupts', 'tool-views', 'json-render', 'a2ui'], + 'core-capabilities': [ + 'streaming', + 'interrupts', + 'tool-views', + 'json-render', + 'client-tools', + 'a2ui', + 'subagents', + ], }, render: { 'getting-started': ['overview'], @@ -61,8 +70,31 @@ const APPROVED_TOPICS = { 'a2ui', ], }, + runtimes: { + 'getting-started': ['overview'], + 'core-capabilities': [ + 'microsoft-agent-framework', + 'aws-strands', + 'mastra', + ], + }, } as const; +/** + * Example-directory lane for a capability topic, as `/`. + * Every topic ships its example under `cockpit///python` + * except the ones listed here: the Mastra runtime's backend is the Node + * AG-UI service `deployments/ag-ui-mastra`, so its assets and smoke target + * live in the `angular` lane instead. Keeping the lane explicit is what + * keeps `promptAssetPaths`/`codeAssetPaths` pointing at files that exist. + */ +const TOPIC_LANES: Record = { + 'runtimes/mastra': 'angular', +}; + +const getLane = (product: string, topic: string): 'python' | 'angular' => + TOPIC_LANES[`${product}/${topic}`] ?? 'python'; + const toTitle = (value: string): string => value .split('-') @@ -81,6 +113,8 @@ const getProductTitle = (product: CockpitProduct): string => { return 'Render'; case 'chat': return 'Chat'; + case 'runtimes': + return 'Runtimes'; } }; @@ -99,13 +133,13 @@ const getDocsPath = ( ): string => `/docs/${product}/${section}/${topic}/overview/python`; const getPromptAssetPath = (product: CockpitProduct, topic: string): string => - `cockpit/${product}/${topic}/python/prompts/${topic}.md`; + `cockpit/${product}/${topic}/${getLane(product, topic)}/prompts/${topic}.md`; const getCodeAssetPath = (product: CockpitProduct, topic: string): string => - `cockpit/${product}/${topic}/python/src/index.ts`; + `cockpit/${product}/${topic}/${getLane(product, topic)}/src/index.ts`; const getSmokeTarget = (product: CockpitProduct, topic: string): string => - `cockpit-${product}-${topic}-python:smoke`; + `cockpit-${product}-${topic}-${getLane(product, topic)}:smoke`; const getRuntimeClass = (topic: string): CockpitRuntimeClass => topic === 'deployment-runtime' ? 'deployed-service' : 'local-service'; diff --git a/libs/cockpit-registry/src/lib/manifest.types.ts b/libs/cockpit-registry/src/lib/manifest.types.ts index 8fe165444..71192c475 100644 --- a/libs/cockpit-registry/src/lib/manifest.types.ts +++ b/libs/cockpit-registry/src/lib/manifest.types.ts @@ -1,4 +1,14 @@ -export type CockpitProduct = 'deep-agents' | 'langgraph' | 'ag-ui' | 'render' | 'chat'; +export type CockpitProduct = + | 'deep-agents' + | 'langgraph' + | 'ag-ui' + | 'render' + | 'chat' + /** + * The one-capability-many-runtimes axis (`cockpit/runtimes//`): + * non-LangGraph AG-UI backends measured against the same Agent contract. + */ + | 'runtimes'; export type CockpitSection = 'getting-started' | 'core-capabilities';