feat(#1680): ADR-1239 Phase C-1 — model adapter seam (passive + active) [AC3] (#1804)

* feat(#1680): ADR-1239 Phase C-1 — model adapter seam (passive + active) [AC3]

Phase 3 slice 3 (AC3). Two model-layer adapters selected by the negotiated
modelMode axis (host-integration.cts):

- passive: formalizes today's tier routing from src/model-resolver.cts —
  resolveModel delegates straight to resolveModelForTier (byte-for-behavior).
  This is the CLI runtimes (claude/gemini/codex/opencode/cursor/...): GSD injects
  prompts / a per-agent model field.
- active: a host-supplied sendRequest seam (VS Code vscode.lm / pi providers) —
  GSD calls the model through the host. Ships as a fail-closed seam (throws until
  a provider is bound); Phase 5 wires a concrete provider.

createModelAdapter({modelMode}, {sendRequest?}) — factory gating throws on
invalid mode. Proactive CI gates applied: ADR-457 eslint ignores entry +
INVENTORY-MANIFEST cli_modules entry + comment-wording audited for the
injection-scan substring trap.

* chore(changeset): add Changed fragment for model adapter seam (#1680)
This commit is contained in:
Tom Boucher
2026-06-28 11:27:44 -04:00
committed by GitHub
parent da368311ea
commit b152f7e64c
5 changed files with 160 additions and 0 deletions

View File

@@ -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.
<!-- docs-exempt: internal adapter seam; no user-facing command/flag/config/schema/doc surface -->

View File

@@ -352,6 +352,7 @@
"loop-resolver.cjs",
"markdown-sectionizer.cjs",
"milestone.cjs",
"model-adapter.cjs",
"model-catalog.cjs",
"model-profiles.cjs",
"model-resolver.cjs",

View File

@@ -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',
],
},

78
src/model-adapter.cts Normal file
View File

@@ -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);
},
});
}

View File

@@ -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`);
}
});