diff --git a/cli/tests/e2e/framework-model-inheritance.e2e.test.ts b/cli/tests/e2e/framework-model-inheritance.e2e.test.ts new file mode 100644 index 000000000..a2e58c625 --- /dev/null +++ b/cli/tests/e2e/framework-model-inheritance.e2e.test.ts @@ -0,0 +1,50 @@ +import { mkdir, readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { frameworkBuildTargetModes } from "../../src/contexts/translate/domain/build-target.js"; +import "../../src/runtime/wiring/translate.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { createTestEnv, runCli } from "./helpers.js"; + +// Every target/mode the CLI announces, built from the shipped plugins rather than a fixture. +const CELLS = frameworkBuildTargetModes(); + +// A recipe may show a model in an example block; only a declaration the host reads counts. +function overridesModel(path: string, content: string): boolean { + if (path.endsWith(".toml")) return /^model\s*=/m.test(content); + const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return frontmatter !== null && /^model\s*:/m.test(frontmatter[1]); +} + +describe.concurrent("E2E: the shipped framework leaves model selection to the host", () => { + it.each(CELLS)( + "$target $mode emits no model override", + async ({ target, mode }) => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv( + `model-${target}-${mode}` + ); + try { + const outDir = join(tempDir, "dist"); + await mkdir(outDir, { recursive: true }); + const args = ["translate", REPOSITORY_ROOT, "--to", target, "--out", outDir, "--as", mode]; + const build = await runCli(args, projectDir, fakeHome); + expect(build.exitCode, build.stderr).toBe(0); + + const files = await readdir(outDir, { recursive: true, withFileTypes: true }); + const emitted = files.filter((entry) => entry.isFile()); + expect(emitted.length).toBeGreaterThan(0); + const overrides: string[] = []; + for (const entry of emitted) { + const path = join(entry.parentPath, entry.name); + if (overridesModel(path, await readFile(path, "utf-8"))) { + overrides.push(path.slice(outDir.length)); + } + } + expect(overrides).toEqual([]); + } finally { + await cleanup(); + } + }, + 120_000 + ); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8976344d..f7f0cc082 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,6 +125,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Skills | Links stay inside the skill directory. Flat distribution renames skills `-`; marketplace installation preserves the tree. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Bundled scripts | Named plugin-relative in backticks, never linked. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Hook adapters | CLI owns OpenCode's shared host protocol; plugins own payload mapping. | [Delivery and compatibility](../cli/ARCHITECTURE.md#hook-adaptation) | +| Model selection | The user and host own the concrete model and reasoning settings; no shipped skill or agent pins one, and a target build carries over a model the user wrote only where the target supports one. | [Source guard](../scripts/__tests__/model-selection.test.js) ยท [Build guard](../cli/tests/e2e/framework-model-inheritance.e2e.test.ts) | ## Capability discovery and addressing