diff --git a/.changeset/humble-geese-roam.md b/.changeset/humble-geese-roam.md new file mode 100644 index 000000000..a3789d05a --- /dev/null +++ b/.changeset/humble-geese-roam.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1804 +--- +**Internal: the model adapter seam exposes `passive` + `active` adapters selected by `modelMode`** — `createModelAdapter({modelMode})` (new `src/model-adapter.cts`): `passive` formalizes today's tier routing (delegates to `model-resolver.resolveModelForTier`), `active` is a host-supplied `sendRequest` seam (VS Code `vscode.lm` / pi providers), fail-closed until Phase 5 binds a concrete provider (ADR-1239 Phase C-1 / #1680 AC3). No user-facing change — the seam is not yet wired to any runtime path. + + diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 9cc2031ef..106d8e77c 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -352,6 +352,7 @@ "loop-resolver.cjs", "markdown-sectionizer.cjs", "milestone.cjs", + "model-adapter.cjs", "model-catalog.cjs", "model-profiles.cjs", "model-resolver.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index bebd85ff3..115b69482 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -204,6 +204,7 @@ export default tseslint.config( 'gsd-core/bin/lib/embedding-adapter.cjs', 'gsd-core/bin/lib/adapter-declarative.cjs', 'gsd-core/bin/lib/adapter-imperative.cjs', + 'gsd-core/bin/lib/model-adapter.cjs', ], }, diff --git a/src/model-adapter.cts b/src/model-adapter.cts new file mode 100644 index 000000000..c560094e2 --- /dev/null +++ b/src/model-adapter.cts @@ -0,0 +1,78 @@ +/** + * Model adapter seam (ADR-1239 Phase C-1, AC3 / #1680). + * + * Two model-layer adapters selected by the negotiated `modelMode` axis + * (host-integration.cts): + * + * - `passive` — GSD can only inject prompts / a per-agent `model` field (the + * CLI runtimes: claude/gemini/codex/opencode/cursor/…). Formalizes today's + * tier routing from src/model-resolver.cts: `resolveModel` delegates + * straight to `resolveModelForTier`, so passive reproduces current behavior + * byte-for-behavior. + * - `active` — the host exposes a provider `sendRequest` (VS Code `vscode.lm`, + * pi providers). GSD calls the model through the host. Ships here as a SEAM: + * a host-supplied `sendRequest` slot, fail-closed until a real consumer + * binds it (Phase 5 / #1682). + * + * Minimal (per ADR-1239 open wire-shape question): one factory, two shapes + * discriminated by `mode`. Concrete provider protocol (request/response shape) + * is fixed when a real active host lands in Phase 5. + */ +'use strict'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import modelResolver = require('./model-resolver.cjs'); + +export type ModelMode = 'passive' | 'active'; + +export interface ModelAdapter { + readonly mode: ModelMode; +} + +export interface PassiveModelAdapter extends ModelAdapter { + readonly mode: 'passive'; + /** Resolve a model id for a tier. Delegates to model-resolver's tier routing. */ + resolveModel(args: { cwd: string; agentType: string; attempt?: number }): string; +} + +export interface ActiveModelAdapter extends ModelAdapter { + readonly mode: 'active'; + /** Host-supplied model-call primitive. Throws (fail-closed) if not bound. */ + sendRequest(req: unknown): unknown; +} + +export interface CreateModelAdapterOptions { + /** Required for `active`: the host's model-call primitive. Ignored for `passive`. */ + sendRequest?: (req: unknown) => unknown; +} + +export function createModelAdapter( + { modelMode }: { modelMode: ModelMode }, + options: CreateModelAdapterOptions = {}, +): ModelAdapter { + if (modelMode !== 'passive' && modelMode !== 'active') { + throw new TypeError(`createModelAdapter: modelMode must be 'passive' | 'active' (got ${JSON.stringify(modelMode)})`); + } + if (modelMode === 'passive') { + return Object.freeze({ + mode: 'passive' as const, + resolveModel({ cwd, agentType, attempt }: { cwd: string; agentType: string; attempt?: number }): string { + return modelResolver.resolveModelForTier(cwd, agentType, attempt); + }, + }); + } + // active: bind the host's sendRequest, fail-closed if absent. + const sendRequest = options.sendRequest; + return Object.freeze({ + mode: 'active' as const, + sendRequest(req: unknown): unknown { + if (typeof sendRequest !== 'function') { + throw new Error( + 'ActiveModelAdapter.sendRequest: no host provider bound — the active model seam ' + + 'requires a sendRequest primitive from the host (Phase 5 wires a concrete provider).', + ); + } + return sendRequest(req); + }, + }); +} diff --git a/tests/model-adapter.test.cjs b/tests/model-adapter.test.cjs new file mode 100644 index 000000000..38b9eca27 --- /dev/null +++ b/tests/model-adapter.test.cjs @@ -0,0 +1,73 @@ +'use strict'; +/** + * Tests for the model adapter seam (ADR-1239 Phase C-1, AC3 / #1680). + * + * Pins: + * 1. PASSIVE — `resolveModel` delegates to model-resolver's tier routing + * (reproduces today's behavior). + * 2. ACTIVE — `sendRequest` calls the host-supplied primitive; FAIL-CLOSED + * (throws) when no provider is bound. + * 3. FACTORY GATING — invalid modelMode throws. + * + * Behavioral tests only; delegation verified via module-ref monkeypatch. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createModelAdapter } = require('../gsd-core/bin/lib/model-adapter.cjs'); +const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); + +test('passive model adapter: resolveModel delegates to model-resolver tier routing (reproduces today behavior)', () => { + const adapter = createModelAdapter({ modelMode: 'passive' }); + assert.strictEqual(adapter.mode, 'passive'); + const original = modelResolver.resolveModelForTier; + let captured = null; + modelResolver.resolveModelForTier = function (...a) { captured = a; return 'sonnet'; }; + try { + const out = adapter.resolveModel({ cwd: '/tmp/proj', agentType: 'planner', attempt: 2 }); + assert.strictEqual(out, 'sonnet', 'resolveModel must return the resolver result'); + assert.deepStrictEqual(captured, ['/tmp/proj', 'planner', 2], 'must delegate (cwd, agentType, attempt) verbatim'); + } finally { + modelResolver.resolveModelForTier = original; + } +}); + +test('passive model adapter: attempt is optional (delegates undefined when omitted)', () => { + const adapter = createModelAdapter({ modelMode: 'passive' }); + const original = modelResolver.resolveModelForTier; + let captured = null; + modelResolver.resolveModelForTier = function (...a) { captured = a; return 'haiku'; }; + try { + adapter.resolveModel({ cwd: '/tmp/proj', agentType: 'executor' }); + assert.deepStrictEqual(captured, ['/tmp/proj', 'executor', undefined], 'attempt defaults to undefined'); + } finally { + modelResolver.resolveModelForTier = original; + } +}); + +test('active model adapter: sendRequest invokes the bound host provider', () => { + const calls = []; + const adapter = createModelAdapter( + { modelMode: 'active' }, + { sendRequest: (req) => { calls.push(req); return { ok: true, echo: req }; } }, + ); + assert.strictEqual(adapter.mode, 'active'); + const out = adapter.sendRequest({ prompt: 'hi' }); + assert.deepStrictEqual(calls, [{ prompt: 'hi' }], 'host provider invoked with the request'); + assert.deepStrictEqual(out, { ok: true, echo: { prompt: 'hi' } }, 'host provider return value passed through'); +}); + +test('active model adapter: FAIL-CLOSED when no host provider is bound (sendRequest throws)', () => { + const adapter = createModelAdapter({ modelMode: 'active' }); + assert.throws( + () => adapter.sendRequest({ prompt: 'hi' }), + /no host provider bound/, + 'sendRequest must throw when no provider is bound (fail-closed, never silently no-op)', + ); +}); + +test('createModelAdapter: invalid modelMode throws (fail-closed construction)', () => { + for (const bad of ['host', 'dynamic', '', null, undefined, 3]) { + assert.throws(() => createModelAdapter({ modelMode: bad }), TypeError, `modelMode=${JSON.stringify(bad)} must throw TypeError`); + } +});