diff --git a/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js b/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js index 5b789600..40bfcc42 100644 --- a/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js +++ b/hypaware-core/smoke/flows/walkthrough_picker_to_first_query.js @@ -16,6 +16,7 @@ import { registerCoreCommands } from '../../../src/core/cli/core_commands.js' import { createKernelRuntime } from '../../../src/core/runtime/activation.js' import { activatePlugins } from '../../../src/core/runtime/loader.js' import { loadManifests } from '../../../src/core/manifest.js' +import { discoverBundledPlugins } from '../../../src/core/runtime/bundled.js' import { resolveDependencies } from '../../../src/core/dep_graph.js' import { defaultConfigPath } from '../../../src/core/config/schema.js' import { requireAiGatewayRuntime } from '../../plugins-workspace/ai-gateway/src/runtime.js' @@ -36,7 +37,9 @@ import { requireAiGatewayRuntime } from '../../plugins-workspace/ai-gateway/src/ * Assertions (per bead hy-5oz4): * * - Non-interactive picker selections generate a config matching the - * expected v2 shape (both AI upstreams, OTEL, Parquet sink). + * expected v2 shape (both AI upstreams, OTEL, Parquet sink), plus the + * riders those picks pull in (LLP 0213 #d1): the written config is wider + * than the six plugins this smoke activates by injection. * - Dry-run daemon install chooses the stable binary path passed via * `--bin ` and outputs a sensible target path. * - Claude + Codex attach dry-runs produce expected file edits *without* @@ -238,7 +241,7 @@ export async function run({ harness, expect }) { // ----- 2. Config written matches Phase 5 shape ----- const configPath = defaultConfigPath(harness.hypHome) const written = JSON.parse(await fs.readFile(configPath, 'utf8')) - const expected = goldenPickerConfig(harness.hypHome) + const expected = await goldenPickerConfig(harness.hypHome) expect.that( 'config: Phase 5 picker config matches expected shape', written, @@ -549,38 +552,103 @@ export async function run({ harness, expect }) { } } +/** + * Bundled plugins that ride the composed picks rather than being picked: + * a manifest whose `compose_with` names plugins that are, transitively, + * already composed (picked, or riders composed in an earlier pass) is + * written into the config with no picker row and no prompt + * ([LLP 0213 #d1](../../../llp/0213-graph-plugin-always-active.decision.md)). + * + * Derived from the manifests instead of restated by name, because a literal + * list here is a second copy of a declaration that lives in the plugins: the + * next derived-data plugin would re-red this smoke for correctly doing what + * its manifest asks. What the golden keeps literal is what the *picker* + * decides (which rows compose, the upstreams, the OTLP port, the sink shape, + * retention); a rider is decided by the plugin, so it is read from there. + * + * Run to a fixpoint, mirroring `ridersFor`: a rider whose `compose_with` + * names another rider (rather than a picked plugin) still lands, so the + * golden does not go stale the moment a rider rides a rider. + * + * This deliberately does not call `composePickerConfig` or `ridersFor`: an + * expectation that runs the code under test asserts nothing. It reads the + * declarations and applies the rule to the literal picked set below, so a + * plugin appearing in the config that is neither picked nor declared as a + * rider still fails the assertion. The rider *mechanism* (fixpoint, the + * unmet-condition case, reconfigure) is unit-tested in + * `test/core/compose-picker-config.test.js`. + * + * Reading only `loaded` keeps the default-activation boundary without + * restating it: it agrees with the cut `ridersInDefaultSet` makes, because + * the allowlist and the excluded set together cover every bundled plugin and + * `loadPickerCatalog` reads only those two buckets. + * + * @param {string[]} picked plugin names the picker composed from its rows + * @returns {Promise} + * @ref LLP 0213#d1 [tests]: the graph pair reaches a default install by riding the gateway pick, asserted by declaration rather than by name + */ +async function composedRiders(picked) { + const { loaded } = await discoverBundledPlugins() + const present = new Set(picked) + /** @type {string[]} */ + const riders = [] + let grew = true + while (grew) { + grew = false + for (const { manifest } of loaded) { + const waitsFor = manifest.compose_with + if (!Array.isArray(waitsFor) || waitsFor.length === 0) continue + if (present.has(manifest.name)) continue + if (!waitsFor.every((name) => present.has(name))) continue + present.add(manifest.name) + riders.push(manifest.name) + grew = true + } + } + return riders +} + /** * @param {string} hypHome */ -function goldenPickerConfig(hypHome) { +async function goldenPickerConfig(hypHome) { + /** @type {{ name: string, config?: unknown }[]} */ + const plugins = [ + { + name: '@hypaware/ai-gateway', + config: { + upstreams: [ + { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/v1/messages', provider: 'anthropic' }, + { name: 'openai', base_url: 'https://api.openai.com', path_prefix: '/v1', provider: 'openai' }, + { name: 'chatgpt', base_url: 'https://chatgpt.com', path_prefix: '/backend-api/codex', provider: 'chatgpt' }, + ], + }, + }, + { + name: '@hypaware/otel', + config: { listen_host: '127.0.0.1', listen_port: 4318 }, + }, + { name: '@hypaware/local-fs' }, + { name: '@hypaware/format-parquet' }, + { + name: '@hypaware/claude', + config: { proxy: '@hypaware/ai-gateway' }, + }, + { + name: '@hypaware/codex', + config: { proxy: '@hypaware/ai-gateway' }, + }, + ] + + // Riders land after every picked plugin, in the order the composer folds + // them: `composePickerConfig` appends them last, once the picks are settled. + for (const rider of await composedRiders(plugins.map((p) => p.name))) { + plugins.push({ name: rider }) + } + return { version: 2, - plugins: [ - { - name: '@hypaware/ai-gateway', - config: { - upstreams: [ - { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/v1/messages', provider: 'anthropic' }, - { name: 'openai', base_url: 'https://api.openai.com', path_prefix: '/v1', provider: 'openai' }, - { name: 'chatgpt', base_url: 'https://chatgpt.com', path_prefix: '/backend-api/codex', provider: 'chatgpt' }, - ], - }, - }, - { - name: '@hypaware/otel', - config: { listen_host: '127.0.0.1', listen_port: 4318 }, - }, - { name: '@hypaware/local-fs' }, - { name: '@hypaware/format-parquet' }, - { - name: '@hypaware/claude', - config: { proxy: '@hypaware/ai-gateway' }, - }, - { - name: '@hypaware/codex', - config: { proxy: '@hypaware/ai-gateway' }, - }, - ], + plugins, sinks: { local: { writer: '@hypaware/format-parquet',