Merge remote-tracking branch 'origin/master' into codex/invariant-service-seam
# Conflicts: # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-07-07-session-prefix.md # .agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml # docs/architecture.md # docs/config-catalog.md # docs/core-data-structures/session.md # docs/rfc/INDEX.md # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/src/index.ts # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/support/invariants/README.md # packages/support/invariants/src/index.ts # packages/support/invariants/tests/invariants.spec.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# `@deepseek-ai/dsh-helper`
|
||||
|
||||
Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture RFC](../../../docs/rfc/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale.
|
||||
Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale.
|
||||
|
||||
The package owns the builtin typed-spec catalog, provider/app behavior entities, structured project file objects, helper-owned project templates, the shared typed `TextTemplate` renderer, package-manager strategies, local-plugin blueprints, typed questions, and the clack prompt adapter. It never boots a Cordis application.
|
||||
|
||||
@@ -18,6 +18,10 @@ The package root explicitly exports only the objects consumed by `create-sdk` an
|
||||
|
||||
None, as the project domain edits files and never mounts a live agent or model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written.
|
||||
|
||||
@@ -26,6 +26,7 @@ export class FeatureConfigurator {
|
||||
* @param current - currently installed selection, when configuring.
|
||||
* @param prefilledOptions - options already chosen by a tree picker.
|
||||
* @param prefilledSecrets - non-interactive secret values supplied by creation.
|
||||
* @param prefilledValues - non-interactive value inputs supplied by a headless spec.
|
||||
* @returns normalized selection with captured values and secrets.
|
||||
*/
|
||||
async configure(
|
||||
@@ -34,6 +35,7 @@ export class FeatureConfigurator {
|
||||
current?: FeatureSelection,
|
||||
prefilledOptions?: readonly string[],
|
||||
prefilledSecrets: Readonly<Record<string, string>> = {},
|
||||
prefilledValues: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<FeatureSelection> {
|
||||
let options: readonly string[]
|
||||
switch (feature.mode) {
|
||||
@@ -69,6 +71,11 @@ export class FeatureConfigurator {
|
||||
id: feature.id,
|
||||
options,
|
||||
}
|
||||
const coercedPrefilled: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(prefilledValues)) {
|
||||
if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
|
||||
coercedPrefilled[key] = value
|
||||
}
|
||||
const values: Record<string, string> = {}
|
||||
for (const input of feature.valueInputs(selected, profile)) {
|
||||
const existing = current?.values?.[input.id]
|
||||
@@ -81,7 +88,7 @@ export class FeatureConfigurator {
|
||||
...existing === undefined ? {} : { initialValue: existing },
|
||||
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
|
||||
})
|
||||
values[input.id] = requireAnswer(await question.resolve(this.port))
|
||||
values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
|
||||
}
|
||||
const base: FeatureSelection = Object.keys(values).length === 0
|
||||
? selected
|
||||
|
||||
@@ -43,3 +43,4 @@ export {
|
||||
} from './questions/question.ts'
|
||||
export type { Question } from './questions/question.ts'
|
||||
export { ClackPromptPort } from './questions/clack-prompt-port.ts'
|
||||
export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'
|
||||
|
||||
@@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env):
|
||||
|
||||
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
||||
export class NodeCommandRunner implements CommandRunner {
|
||||
/** Spawn one child and settle only after its exit. */
|
||||
private readonly output: NodeJS.WritableStream | undefined
|
||||
|
||||
/**
|
||||
* @param output - redirect target for child stdout+stderr; the child inherits
|
||||
* this process's stdio when absent. Callers whose own stdout carries a machine
|
||||
* protocol (create-sdk --json NDJSON) redirect child output to keep the
|
||||
* protocol stream pure.
|
||||
*/
|
||||
constructor(output?: NodeJS.WritableStream) {
|
||||
this.output = output
|
||||
}
|
||||
|
||||
/** Spawn one child and settle only after exit, with redirected stdio drained. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = this.output
|
||||
if (output === undefined) {
|
||||
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
return
|
||||
}
|
||||
const child = spawn(command, [...args], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
stdio: 'inherit',
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
})
|
||||
child.stdout.pipe(output, { end: false })
|
||||
child.stderr.pipe(output, { end: false })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -148,6 +169,25 @@ export abstract class PackageManager {
|
||||
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build add-dependency command arguments for one already-normalized source spec.
|
||||
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
addCommand(spec: string): readonly string[] {
|
||||
return ['add', spec]
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
|
||||
* @param spec - a package-manager-native dependency source.
|
||||
* @param cwd - project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
|
||||
}
|
||||
|
||||
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
|
||||
const result = await runner.run(this.name, args, cwd)
|
||||
if (result.signal !== null) {
|
||||
@@ -184,6 +224,11 @@ export class NpmPackageManager extends PackageManager {
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `file:${relativePath}`
|
||||
}
|
||||
|
||||
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
|
||||
override addCommand(spec: string): readonly string[] {
|
||||
return ['install', spec]
|
||||
}
|
||||
}
|
||||
|
||||
/** pnpm workspace behavior. */
|
||||
|
||||
@@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
this.addedPlugins.add(entry.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a Cordis entry for an external dependency the package manager has already
|
||||
* added (github or npm), without generating files or re-adding the dependency.
|
||||
* @param id - stable Cordis config entry id.
|
||||
* @param packageName - the installed dependency's package name.
|
||||
*/
|
||||
addExternalPlugin(id: string, packageName: string): void {
|
||||
this.assertOpen()
|
||||
if (!this.manifest().npmDependency(packageName)) {
|
||||
throw new Error(`external plugin dependency is not installed: ${packageName}`)
|
||||
}
|
||||
const cordis = this.cordis()
|
||||
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
|
||||
cordis.addEntry({ id, name: packageName })
|
||||
this.addedPlugins.add(id)
|
||||
}
|
||||
|
||||
/** Enable or disable one custom/manual Cordis config entry by stable id. */
|
||||
setCustomPluginDisabled(id: string, disabled: boolean): void {
|
||||
this.assertOpen()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Non-interactive prompt port for headless create/config and skill-driven runs.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConfirmPromptRequest,
|
||||
MultiSelectPromptRequest,
|
||||
NestedMultiSelectRequest,
|
||||
NestedMultiSelectValue,
|
||||
PromptOutcome,
|
||||
PromptPort,
|
||||
SecretPromptRequest,
|
||||
SelectPromptRequest,
|
||||
TextPromptRequest,
|
||||
} from './prompt-port.ts'
|
||||
|
||||
/**
|
||||
* Raised when a headless run reaches a decision that was neither prefilled nor
|
||||
* carries a usable default. The message names the unanswered prompt so an agent
|
||||
* or CI caller can see exactly which input the spec must supply.
|
||||
*/
|
||||
export class HeadlessPromptError extends Error {
|
||||
/** The unanswered prompt's user-facing message. */
|
||||
readonly prompt: string
|
||||
|
||||
/** Build an error naming the unanswered prompt. */
|
||||
constructor(prompt: string) {
|
||||
super(`headless run needs an answer for: ${prompt}`)
|
||||
this.name = 'HeadlessPromptError'
|
||||
this.prompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an answered outcome. */
|
||||
function answered<T>(value: T): Promise<PromptOutcome<T>> {
|
||||
return Promise.resolve({ status: 'answered', value })
|
||||
}
|
||||
|
||||
/** Reject with a named unanswered-prompt error. */
|
||||
function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
|
||||
return Promise.reject(new HeadlessPromptError(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link PromptPort} that never blocks on a terminal.
|
||||
*
|
||||
* Answers are expected to arrive as prefilled values through the `Question` /
|
||||
* `FeatureConfigurator` layers, so in a fully specified run this port is never
|
||||
* reached. When it *is* reached, it takes the prompt's own declared default
|
||||
* (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
|
||||
* {@link HeadlessPromptError}. Nested feature selection has no scalar default,
|
||||
* so it always fails loud — headless callers must supply the feature set through
|
||||
* the spec rather than the tree picker.
|
||||
*/
|
||||
export class HeadlessPromptPort implements PromptPort {
|
||||
/** Answer visible text from its default, or fail loud. */
|
||||
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
const fallback = request.initialValue ?? request.defaultValue
|
||||
if (fallback === undefined) return unanswered(request.message)
|
||||
const diagnostic = request.validate?.(fallback)
|
||||
if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
|
||||
return answered(fallback)
|
||||
}
|
||||
|
||||
/** A secret has no safe default: always fail loud. */
|
||||
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return unanswered(request.message)
|
||||
}
|
||||
|
||||
/** Answer a single choice from its initial value, or fail loud. */
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
|
||||
if (request.initialValue === undefined) return unanswered(request.message)
|
||||
return answered(request.initialValue)
|
||||
}
|
||||
|
||||
/** Answer a multi-choice from its initial values, or fail loud when required. */
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
const initial = request.initialValues ?? []
|
||||
if (request.required && initial.length === 0) return unanswered(request.message)
|
||||
return answered(initial)
|
||||
}
|
||||
|
||||
/** Answer a confirmation from its initial value, or fail loud. */
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
|
||||
if (request.initialValue === undefined) return unanswered(request.message)
|
||||
return answered(request.initialValue)
|
||||
}
|
||||
|
||||
/** Nested feature selection has no scalar default: always fail loud. */
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return unanswered(request.message)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
|
||||
import { EnvFile } from '../src/documents/env-file.ts'
|
||||
@@ -299,6 +300,11 @@ describe('package manager strategies', () => {
|
||||
await npm.install('/tmp', runner)
|
||||
await npm.build('/tmp', runner)
|
||||
expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']])
|
||||
await npm.add('some-pkg@1.0.0', '/tmp', runner)
|
||||
const pnpm = createPackageManager('pnpm', '10.0.0')
|
||||
await pnpm.add('github:o/r#sha', '/tmp', runner)
|
||||
expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0'])
|
||||
expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha'])
|
||||
const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
|
||||
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
|
||||
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
|
||||
@@ -321,6 +327,19 @@ describe('package manager strategies', () => {
|
||||
const runner = new NodeCommandRunner()
|
||||
await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
|
||||
let redirected = ''
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
|
||||
})
|
||||
const redirecting = new NodeCommandRunner(output)
|
||||
await expect(redirecting.run(
|
||||
process.execPath,
|
||||
['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
|
||||
root,
|
||||
)).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
expect(redirected).toContain('child-out')
|
||||
expect(redirected).toContain('child-err')
|
||||
await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('discovers and rewrites a repository-local NPM dependency closure', async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
|
||||
|
||||
/** Unwrap an answered outcome or fail the test. */
|
||||
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
|
||||
const outcome = await promise
|
||||
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
describe('HeadlessPromptError', () => {
|
||||
it('names the unanswered prompt', () => {
|
||||
const error = new HeadlessPromptError('DeepSeek API key')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('HeadlessPromptError')
|
||||
expect(error.prompt).toBe('DeepSeek API key')
|
||||
expect(error.message).toContain('DeepSeek API key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessPromptPort', () => {
|
||||
const port = new HeadlessPromptPort()
|
||||
|
||||
describe('text', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
|
||||
})
|
||||
|
||||
it('falls back to the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
|
||||
})
|
||||
|
||||
it('prefers the initial value over the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
|
||||
})
|
||||
|
||||
it('fails loud when no default exists', async () => {
|
||||
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
|
||||
it('fails loud when the default is invalid', async () => {
|
||||
await expect(port.text({
|
||||
message: 'name',
|
||||
defaultValue: '',
|
||||
validate: value => value.length === 0 ? 'required' : undefined,
|
||||
})).rejects.toThrow(/required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('secret', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiselect', () => {
|
||||
it('returns the initial values', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('returns an empty selection when none are supplied and none are required', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud when required and nothing is preselected', async () => {
|
||||
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nestedMultiselect', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -709,6 +709,28 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
|
||||
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
|
||||
})
|
||||
|
||||
it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-'))
|
||||
temporary.push(root)
|
||||
const creation = request()
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await edit.commit()
|
||||
const manifestPath = join(root, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record<string, string> }
|
||||
manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' }
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
const reopened = await SdkProject.open(root)
|
||||
const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile))
|
||||
edit2.addExternalPlugin('ext-plugin', 'ext-plugin')
|
||||
expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists')
|
||||
expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed')
|
||||
const commit = await edit2.commit()
|
||||
expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension points', () => {
|
||||
|
||||
@@ -450,4 +450,35 @@ describe('feature configurator', () => {
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
|
||||
.rejects.toThrow('has no default option')
|
||||
})
|
||||
|
||||
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort([])
|
||||
const result = await new FeatureConfigurator(port).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'prefilled-key' },
|
||||
{ baseURL: 'https://prefilled' },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
options: ['custom'],
|
||||
values: { baseURL: 'https://prefilled' },
|
||||
secrets: { apiKey: 'prefilled-key' },
|
||||
})
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-string prefilled feature value', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'k' },
|
||||
{ baseURL: 123 },
|
||||
)).rejects.toThrow('must be a string')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user