* 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:
7
.changeset/humble-geese-roam.md
Normal file
7
.changeset/humble-geese-roam.md
Normal 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 -->
|
||||
@@ -352,6 +352,7 @@
|
||||
"loop-resolver.cjs",
|
||||
"markdown-sectionizer.cjs",
|
||||
"milestone.cjs",
|
||||
"model-adapter.cjs",
|
||||
"model-catalog.cjs",
|
||||
"model-profiles.cjs",
|
||||
"model-resolver.cjs",
|
||||
|
||||
@@ -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
78
src/model-adapter.cts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
73
tests/model-adapter.test.cjs
Normal file
73
tests/model-adapter.test.cjs
Normal 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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user