From 9c64811eb4b749b7964117d7cf0b370c27217715 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 12 Sep 2026 21:17:06 +0200 Subject: [PATCH] feat(sdk): first-class headless adapter per agent CLI (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving refactor. Per-CLI knowledge moves from function-scoped switches in cli-adapter.ts into a HeadlessAdapter interface with one implementation per CLI (claude, codex, relayflows-wrapper-v1). worker-cli.ts and cli/check.ts continue to use the same legacy dispatch helpers; those now delegate to the registered adapter for the resolved kind. Adding a new CLI (gemini, opencode, aider, goose, grok) is one new file that implements the interface plus one registry entry — no more editing worker-cli.ts. Files: - packages/sdk/src/adapters/base.ts (new — HeadlessAdapter interface) - packages/sdk/src/adapters/claude.ts (new) - packages/sdk/src/adapters/codex.ts (new) - packages/sdk/src/adapters/wrapper.ts (new) - packages/sdk/src/adapters/index.ts (new — registry + resolvers) - packages/sdk/src/cli-adapter.ts (delegates to registry, exports unchanged) - packages/sdk/tests/adapters/{claude,codex,registry}.test.ts (new — 18 tests covering identity/probe/execution shape parity + wrapper refusal) - packages/sdk/tsconfig.tests.json (include new tests) Co-Authored-By: Claude Opus 4.7 (1M context) Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 Session-Id: efeda5df-9b7c-48d4-b2ce-957f5bef0a82 --- packages/sdk/src/adapters/base.ts | 53 ++++++++ packages/sdk/src/adapters/claude.ts | 55 ++++++++ packages/sdk/src/adapters/codex.ts | 56 ++++++++ packages/sdk/src/adapters/index.ts | 36 +++++ packages/sdk/src/adapters/wrapper.ts | 42 ++++++ packages/sdk/src/cli-adapter.ts | 133 +++++-------------- packages/sdk/tests/adapters/claude.test.ts | 50 +++++++ packages/sdk/tests/adapters/codex.test.ts | 52 ++++++++ packages/sdk/tests/adapters/registry.test.ts | 48 +++++++ packages/sdk/tsconfig.tests.json | 5 +- 10 files changed, 427 insertions(+), 103 deletions(-) create mode 100644 packages/sdk/src/adapters/base.ts create mode 100644 packages/sdk/src/adapters/claude.ts create mode 100644 packages/sdk/src/adapters/codex.ts create mode 100644 packages/sdk/src/adapters/index.ts create mode 100644 packages/sdk/src/adapters/wrapper.ts create mode 100644 packages/sdk/tests/adapters/claude.test.ts create mode 100644 packages/sdk/tests/adapters/codex.test.ts create mode 100644 packages/sdk/tests/adapters/registry.test.ts diff --git a/packages/sdk/src/adapters/base.ts b/packages/sdk/src/adapters/base.ts new file mode 100644 index 000000000..a6daaddd7 --- /dev/null +++ b/packages/sdk/src/adapters/base.ts @@ -0,0 +1,53 @@ +/** + * First-class headless adapter contract (flows#141). + * + * Every supported agent CLI ships one implementation. `worker-cli.ts` and + * `cli/check.ts` dispatch through the registry in `./index.ts` — never string + * formatting against per-CLI knowledge inline. Adding a new CLI is one new + * file that implements this interface. + * + * Behavior-preserving migration: the exported functions in `cli-adapter.ts` + * (`agentExecution`, `llmExecution`, `authenticationProbe`, + * `modelReadinessProbe`, `adapterIdentification`) now delegate to the + * registered adapter for the resolved kind, so callers don't change. + */ + +export interface CliInvocation { + args: string[]; + timeoutMs: number; + /** Set only for wrapper readiness probes; raw providers receive a model flag. */ + modelEnv?: string; +} + +export interface CliAdapterIdentification { + invocation: CliInvocation; + expectedStdout?: string; +} + +/** + * The declared, per-CLI headless contract. Members intentionally mirror the + * function-scoped predecessors in `cli-adapter.ts` so migration is mechanical. + */ +export interface HeadlessAdapter { + /** Identity of this adapter — matches CliAdapterKind for registry keys. */ + readonly kind: string; + + /** Shape-check invocation before classifying an auth failure. */ + buildIdentification(): CliAdapterIdentification; + + /** Non-interactive auth-status probe. */ + buildAuthProbe(): CliInvocation; + + /** + * Real, noninteractive model round-trip. Providers hand the model via a + * native flag; wrappers hand it via the explicitly identified environment + * contract on `modelEnv`. + */ + buildModelReadinessProbe(model: string): CliInvocation; + + /** Agent-step worker argv for an instruction under an optional model. */ + buildAgentInvocation(instruction: string, model?: string): CliInvocation; + + /** LLM-step worker argv for a prompt under an optional model. */ + buildLlmInvocation(prompt: string, model?: string): CliInvocation; +} diff --git a/packages/sdk/src/adapters/claude.ts b/packages/sdk/src/adapters/claude.ts new file mode 100644 index 000000000..8d163118a --- /dev/null +++ b/packages/sdk/src/adapters/claude.ts @@ -0,0 +1,55 @@ +import type { + CliAdapterIdentification, + CliInvocation, + HeadlessAdapter, +} from './base.js'; + +const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.'; + +/** Claude Code CLI headless contract. Wire and behavior identical to the + * pre-#141 inline shape in `cli-adapter.ts` — only the packaging changed. */ +export const claudeAdapter: HeadlessAdapter = { + kind: 'claude', + + buildIdentification(): CliAdapterIdentification { + return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } }; + }, + + buildAuthProbe(): CliInvocation { + return { args: ['auth', 'status'], timeoutMs: 10_000 }; + }, + + buildModelReadinessProbe(model: string): CliInvocation { + return { + args: [ + '-p', '--model', model, '--tools', '', '--no-session-persistence', + MODEL_PROBE_PROMPT, + ], + timeoutMs: 60_000, + }; + }, + + buildAgentInvocation(instruction: string, model?: string): CliInvocation { + return { + args: [ + '-p', + // Symmetric to codex's --dangerously-bypass-approvals-and-sandbox: in + // agent mode the flow explicitly delegates writes. Without this + // claude's headless mode prompts for tool approval, gets no TTY, + // and completes "successfully" without touching files. + '--dangerously-skip-permissions', + ...(model === undefined ? [] : ['--model', model]), + instruction, + ], + timeoutMs: 0, + }; + }, + + buildLlmInvocation(prompt: string, model?: string): CliInvocation { + return { + args: ['-p', '--tools', '', '--no-session-persistence', + ...(model === undefined ? [] : ['--model', model]), prompt], + timeoutMs: 0, + }; + }, +}; diff --git a/packages/sdk/src/adapters/codex.ts b/packages/sdk/src/adapters/codex.ts new file mode 100644 index 000000000..193882c0a --- /dev/null +++ b/packages/sdk/src/adapters/codex.ts @@ -0,0 +1,56 @@ +import type { + CliAdapterIdentification, + CliInvocation, + HeadlessAdapter, +} from './base.js'; + +const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.'; + +/** OpenAI Codex CLI headless contract. Behavior identical to the pre-#141 + * inline shape in `cli-adapter.ts` — only the packaging changed. */ +export const codexAdapter: HeadlessAdapter = { + kind: 'codex', + + buildIdentification(): CliAdapterIdentification { + return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } }; + }, + + buildAuthProbe(): CliInvocation { + return { args: ['login', 'status'], timeoutMs: 10_000 }; + }, + + buildModelReadinessProbe(model: string): CliInvocation { + return { + args: [ + 'exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', + '--model', model, MODEL_PROBE_PROMPT, + ], + timeoutMs: 60_000, + }; + }, + + buildAgentInvocation(instruction: string, model?: string): CliInvocation { + return { + args: [ + 'exec', '--ephemeral', '--skip-git-repo-check', + // Agent-mode is where the flow explicitly delegates code changes to + // the CLI. Without this flag codex prompts for approval on every + // write, gets nothing (no TTY), and completes "successfully" without + // touching files — the dogfood no-op failure mode. LLM-mode stays + // read-only and does NOT get the bypass. + '--dangerously-bypass-approvals-and-sandbox', + ...(model === undefined ? [] : ['--model', model]), + instruction, + ], + timeoutMs: 0, + }; + }, + + buildLlmInvocation(prompt: string, model?: string): CliInvocation { + return { + args: ['exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', + ...(model === undefined ? [] : ['--model', model]), prompt], + timeoutMs: 0, + }; + }, +}; diff --git a/packages/sdk/src/adapters/index.ts b/packages/sdk/src/adapters/index.ts new file mode 100644 index 000000000..5258dec21 --- /dev/null +++ b/packages/sdk/src/adapters/index.ts @@ -0,0 +1,36 @@ +import { basename } from 'node:path'; + +import { claudeAdapter } from './claude.js'; +import { codexAdapter } from './codex.js'; +import { wrapperAdapter } from './wrapper.js'; +import type { HeadlessAdapter } from './base.js'; + +export type CliAdapterKind = 'claude' | 'codex' | 'relayflows-wrapper-v1'; + +/** The registered adapter set. Adding a CLI is adding one file + one entry. */ +const ADAPTERS: Record = { + claude: claudeAdapter, + codex: codexAdapter, + 'relayflows-wrapper-v1': wrapperAdapter, +}; + +/** Select the adapter for the resolved executable basename. */ +export function resolveAdapter(executable: string): HeadlessAdapter { + const name = basename(executable).replace(/\.exe$/i, ''); + if (name === 'claude') return ADAPTERS.claude; + if (name === 'codex') return ADAPTERS.codex; + return ADAPTERS['relayflows-wrapper-v1']; +} + +/** Backward-compatible kind lookup for callers still speaking string-tag lang. */ +export function resolveAdapterKind(executable: string): CliAdapterKind { + return resolveAdapter(executable).kind as CliAdapterKind; +} + +/** Registered adapters by kind (read-only view for tests and diagnostics). */ +export function registeredAdapters(): Readonly> { + return ADAPTERS; +} + +export { claudeAdapter, codexAdapter, wrapperAdapter }; +export type { HeadlessAdapter, CliInvocation, CliAdapterIdentification } from './base.js'; diff --git a/packages/sdk/src/adapters/wrapper.ts b/packages/sdk/src/adapters/wrapper.ts new file mode 100644 index 000000000..e39430807 --- /dev/null +++ b/packages/sdk/src/adapters/wrapper.ts @@ -0,0 +1,42 @@ +import type { + CliAdapterIdentification, + CliInvocation, + HeadlessAdapter, +} from './base.js'; + +/** + * `relayflows-adapter-v1` identifies a per-flow wrapper script — the pre-#141 + * per-flow shim shape. The wrapper answers a probe with a known token; agent + * and llm execution flow through `runWrapperSession`, so this adapter refuses + * to construct a direct-argv invocation for them. + */ +export const WRAPPER_IDENTIFY_ARG = '--relayflows-adapter-v1'; +export const WRAPPER_IDENTIFY_TOKEN = 'relayflows-agent-cli-v1'; +export const WRAPPER_EXECUTE_TOKEN = 'relayflows-agent-cli-v1-execute'; + +export const wrapperAdapter: HeadlessAdapter = { + kind: 'relayflows-wrapper-v1', + + buildIdentification(): CliAdapterIdentification { + return { + invocation: { args: [WRAPPER_IDENTIFY_ARG], timeoutMs: 10_000 }, + expectedStdout: WRAPPER_IDENTIFY_TOKEN, + }; + }, + + buildAuthProbe(): CliInvocation { + return { args: ['auth', 'status'], timeoutMs: 10_000 }; + }, + + buildModelReadinessProbe(model: string): CliInvocation { + return { args: ['auth', 'status'], timeoutMs: 60_000, modelEnv: model }; + }, + + buildAgentInvocation(): CliInvocation { + throw new Error('custom wrapper execution requires the runAgentCli same-process session'); + }, + + buildLlmInvocation(): CliInvocation { + throw new Error('custom wrapper execution requires the same-process session'); + }, +}; diff --git a/packages/sdk/src/cli-adapter.ts b/packages/sdk/src/cli-adapter.ts index 24f68c53e..0133c552d 100644 --- a/packages/sdk/src/cli-adapter.ts +++ b/packages/sdk/src/cli-adapter.ts @@ -1,50 +1,40 @@ -import { basename } from 'node:path'; - -export type CliAdapterKind = 'claude' | 'codex' | 'relayflows-wrapper-v1'; - -export interface CliInvocation { - args: string[]; - timeoutMs: number; - /** Set only for wrapper readiness probes; raw providers receive a model flag. */ - modelEnv?: string; -} +/** + * Legacy dispatch surface — retained so existing callers (`worker-cli.ts`, + * `cli/check.ts`, real-cli-adapters tests) compile unchanged. The per-CLI + * knowledge now lives in `./adapters/{claude,codex,wrapper}.ts` behind the + * `HeadlessAdapter` interface (flows#141). New CLIs implement the interface + * and register in `./adapters/index.ts`; this file no longer needs edits. + */ +import { + resolveAdapterKind, + registeredAdapters, +} from './adapters/index.js'; +import type { CliAdapterKind } from './adapters/index.js'; +import type { CliInvocation, CliAdapterIdentification } from './adapters/base.js'; -export interface CliAdapterIdentification { - invocation: CliInvocation; - expectedStdout?: string; -} +export type { CliAdapterKind } from './adapters/index.js'; +export type { CliInvocation, CliAdapterIdentification, HeadlessAdapter } from './adapters/base.js'; -export const WRAPPER_IDENTIFY_ARG = '--relayflows-adapter-v1'; -export const WRAPPER_IDENTIFY_TOKEN = 'relayflows-agent-cli-v1'; -export const WRAPPER_EXECUTE_TOKEN = 'relayflows-agent-cli-v1-execute'; +export { + WRAPPER_IDENTIFY_ARG, + WRAPPER_IDENTIFY_TOKEN, + WRAPPER_EXECUTE_TOKEN, +} from './adapters/wrapper.js'; -const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.'; +export { resolveAdapter, resolveAdapterKind, registeredAdapters } from './adapters/index.js'; /** Select a closed adapter from the resolved executable's basename. */ export function cliAdapterKind(executable: string): CliAdapterKind { - const name = basename(executable).replace(/\.exe$/i, ''); - if (name === 'claude') return 'claude'; - if (name === 'codex') return 'codex'; - return 'relayflows-wrapper-v1'; + return resolveAdapterKind(executable); } /** Prove the adapter command shape before classifying an auth failure. */ export function adapterIdentification(kind: CliAdapterKind): CliAdapterIdentification { - if (kind === 'claude') { - return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } }; - } - if (kind === 'codex') { - return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } }; - } - return { - invocation: { args: [WRAPPER_IDENTIFY_ARG], timeoutMs: 10_000 }, - expectedStdout: WRAPPER_IDENTIFY_TOKEN, - }; + return registeredAdapters()[kind].buildIdentification(); } export function authenticationProbe(kind: CliAdapterKind): CliInvocation { - if (kind === 'codex') return { args: ['login', 'status'], timeoutMs: 10_000 }; - return { args: ['auth', 'status'], timeoutMs: 10_000 }; + return registeredAdapters()[kind].buildAuthProbe(); } /** @@ -53,29 +43,7 @@ export function authenticationProbe(kind: CliAdapterKind): CliInvocation { * exact model through its explicitly identified environment contract. */ export function modelReadinessProbe(kind: CliAdapterKind, model: string): CliInvocation { - if (kind === 'claude') { - return { - args: [ - '-p', '--model', model, '--tools', '', '--no-session-persistence', - MODEL_PROBE_PROMPT, - ], - timeoutMs: 60_000, - }; - } - if (kind === 'codex') { - return { - args: [ - 'exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', - '--model', model, MODEL_PROBE_PROMPT, - ], - timeoutMs: 60_000, - }; - } - return { - args: ['auth', 'status'], - timeoutMs: 60_000, - modelEnv: model, - }; + return registeredAdapters()[kind].buildModelReadinessProbe(model); } /** Build the actual worker argv; this is shared contract, not probe-only lore. */ @@ -84,38 +52,12 @@ export function agentExecution( instruction: string, model?: string, ): CliInvocation { - if (kind === 'claude') { - return { - args: [ - '-p', - // Symmetric to codex's --dangerously-bypass-approvals-and-sandbox: in - // agent mode the flow explicitly delegates writes. Without this - // claude's headless mode prompts for tool approval, gets no TTY, - // and completes successfully without touching files. - '--dangerously-skip-permissions', - ...(model === undefined ? [] : ['--model', model]), - instruction, - ], - timeoutMs: 0, - }; - } - if (kind === 'codex') { - return { - args: [ - 'exec', '--ephemeral', '--skip-git-repo-check', - // Agent-mode is where the flow explicitly delegates code changes to - // the CLI. Without this flag codex prompts for approval on every - // write, gets nothing (no TTY), and completes "successfully" without - // touching files — the dogfood no-op failure mode. LLM-mode below - // stays read-only and does NOT get the bypass. - '--dangerously-bypass-approvals-and-sandbox', - ...(model === undefined ? [] : ['--model', model]), - instruction, - ], - timeoutMs: 0, - }; - } - throw new Error('custom wrapper execution requires the runAgentCli same-process session'); + return registeredAdapters()[kind].buildAgentInvocation(instruction, model); +} + +/** Reuse the workspace-free model probe's provider flags for a real LLM call. */ +export function llmExecution(kind: CliAdapterKind, prompt: string, model?: string): CliInvocation { + return registeredAdapters()[kind].buildLlmInvocation(prompt, model); } export function displayInvocation(cli: string, invocation: CliInvocation): string { @@ -125,19 +67,6 @@ export function displayInvocation(cli: string, invocation: CliInvocation): strin : `RELAYFLOW_MODEL=${shellDisplayWord(invocation.modelEnv)} ${command}`; } -/** Reuse the workspace-free model probe's provider flags for a real LLM call. */ -export function llmExecution(kind: CliAdapterKind, prompt: string, model?: string): CliInvocation { - if (kind === 'claude') { - return { args: ['-p', '--tools', '', '--no-session-persistence', - ...(model === undefined ? [] : ['--model', model]), prompt], timeoutMs: 0 }; - } - if (kind === 'codex') { - return { args: ['exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', - ...(model === undefined ? [] : ['--model', model]), prompt], timeoutMs: 0 }; - } - throw new Error('custom wrapper execution requires the same-process session'); -} - function shellDisplayWord(word: string): string { return /^[A-Za-z0-9_./:-]+$/.test(word) ? word : JSON.stringify(word); } diff --git a/packages/sdk/tests/adapters/claude.test.ts b/packages/sdk/tests/adapters/claude.test.ts new file mode 100644 index 000000000..f56db9d51 --- /dev/null +++ b/packages/sdk/tests/adapters/claude.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { claudeAdapter } from '../../src/adapters/claude.js'; + +describe('claudeAdapter — HeadlessAdapter contract', () => { + it('identifies itself as kind "claude"', () => { + expect(claudeAdapter.kind).toBe('claude'); + }); + + it('buildIdentification uses the auth-status help shape', () => { + const id = claudeAdapter.buildIdentification(); + expect(id.invocation.args).toEqual(['auth', 'status', '--help']); + expect(id.invocation.timeoutMs).toBeGreaterThan(0); + expect(id.expectedStdout).toBeUndefined(); + }); + + it('buildAuthProbe is a non-interactive auth-status', () => { + expect(claudeAdapter.buildAuthProbe().args).toEqual(['auth', 'status']); + }); + + it('buildModelReadinessProbe passes the model via --model and forbids tools + session persistence', () => { + const inv = claudeAdapter.buildModelReadinessProbe('opus-4-x'); + expect(inv.args).toContain('--model'); + expect(inv.args).toContain('opus-4-x'); + expect(inv.args).toContain('--tools'); + expect(inv.args).toContain('--no-session-persistence'); + }); + + it('buildAgentInvocation ends with the instruction and lets timeouts stay unbounded', () => { + const inv = claudeAdapter.buildAgentInvocation('write a haiku', 'opus-4-x'); + expect(inv.args[0]).toBe('-p'); + expect(inv.args.at(-1)).toBe('write a haiku'); + expect(inv.args).toContain('--model'); + expect(inv.args).toContain('opus-4-x'); + expect(inv.timeoutMs).toBe(0); + }); + + it('buildAgentInvocation omits --model when no model is provided', () => { + const inv = claudeAdapter.buildAgentInvocation('write a haiku'); + expect(inv.args).not.toContain('--model'); + expect(inv.args.at(-1)).toBe('write a haiku'); + }); + + it('buildLlmInvocation matches the non-agent shape (no tools, no session persistence)', () => { + const inv = claudeAdapter.buildLlmInvocation('summarize', 'opus-4-x'); + expect(inv.args).toContain('--tools'); + expect(inv.args).toContain('--no-session-persistence'); + expect(inv.args).toContain('--model'); + expect(inv.args.at(-1)).toBe('summarize'); + }); +}); diff --git a/packages/sdk/tests/adapters/codex.test.ts b/packages/sdk/tests/adapters/codex.test.ts new file mode 100644 index 000000000..7299a92a9 --- /dev/null +++ b/packages/sdk/tests/adapters/codex.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { codexAdapter } from '../../src/adapters/codex.js'; + +describe('codexAdapter — HeadlessAdapter contract', () => { + it('identifies itself as kind "codex"', () => { + expect(codexAdapter.kind).toBe('codex'); + }); + + it('buildIdentification uses login-status help', () => { + const id = codexAdapter.buildIdentification(); + expect(id.invocation.args).toEqual(['login', 'status', '--help']); + expect(id.expectedStdout).toBeUndefined(); + }); + + it('buildAuthProbe is login-status', () => { + expect(codexAdapter.buildAuthProbe().args).toEqual(['login', 'status']); + }); + + it('buildModelReadinessProbe runs in ephemeral read-only exec with --model', () => { + const inv = codexAdapter.buildModelReadinessProbe('gpt-6-astra'); + expect(inv.args[0]).toBe('exec'); + expect(inv.args).toContain('--ephemeral'); + expect(inv.args).toContain('--sandbox'); + expect(inv.args).toContain('read-only'); + expect(inv.args).toContain('--skip-git-repo-check'); + expect(inv.args).toContain('--model'); + expect(inv.args).toContain('gpt-6-astra'); + }); + + it('buildAgentInvocation uses exec --ephemeral --skip-git-repo-check with instruction at the tail', () => { + const inv = codexAdapter.buildAgentInvocation('build the thing', 'gpt-6-astra'); + expect(inv.args[0]).toBe('exec'); + expect(inv.args).toContain('--ephemeral'); + expect(inv.args).toContain('--skip-git-repo-check'); + expect(inv.args).toContain('--model'); + expect(inv.args).toContain('gpt-6-astra'); + expect(inv.args.at(-1)).toBe('build the thing'); + expect(inv.timeoutMs).toBe(0); + }); + + it('buildAgentInvocation omits --model when unset and never adds --sandbox to agent execution', () => { + const inv = codexAdapter.buildAgentInvocation('build the thing'); + expect(inv.args).not.toContain('--model'); + expect(inv.args).not.toContain('--sandbox'); + }); + + it('buildLlmInvocation adds --sandbox read-only', () => { + const inv = codexAdapter.buildLlmInvocation('summarize'); + expect(inv.args).toContain('--sandbox'); + expect(inv.args).toContain('read-only'); + }); +}); diff --git a/packages/sdk/tests/adapters/registry.test.ts b/packages/sdk/tests/adapters/registry.test.ts new file mode 100644 index 000000000..9de76d022 --- /dev/null +++ b/packages/sdk/tests/adapters/registry.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + registeredAdapters, + resolveAdapter, + resolveAdapterKind, +} from '../../src/adapters/index.js'; +import { + agentExecution, + authenticationProbe, + cliAdapterKind, + llmExecution, + modelReadinessProbe, + adapterIdentification, +} from '../../src/cli-adapter.js'; + +describe('adapters registry + cli-adapter parity', () => { + it('registers claude, codex, and the wrapper protocol', () => { + const kinds = Object.keys(registeredAdapters()).sort(); + expect(kinds).toEqual(['claude', 'codex', 'relayflows-wrapper-v1']); + }); + + it('resolves by basename', () => { + expect(resolveAdapter('/usr/local/bin/claude').kind).toBe('claude'); + expect(resolveAdapter('/opt/homebrew/bin/codex').kind).toBe('codex'); + expect(resolveAdapter('/opt/homebrew/bin/codex.exe').kind).toBe('codex'); + expect(resolveAdapter('./bin/my-flow-wrapper').kind).toBe('relayflows-wrapper-v1'); + expect(resolveAdapterKind('claude')).toBe('claude'); + expect(cliAdapterKind('codex')).toBe('codex'); + }); + + it('legacy helpers match adapter contracts', () => { + for (const kind of ['claude', 'codex'] as const) { + const adapter = registeredAdapters()[kind]; + expect(agentExecution(kind, 'X', 'M')).toEqual(adapter.buildAgentInvocation('X', 'M')); + expect(llmExecution(kind, 'Y', 'M')).toEqual(adapter.buildLlmInvocation('Y', 'M')); + expect(authenticationProbe(kind)).toEqual(adapter.buildAuthProbe()); + expect(modelReadinessProbe(kind, 'M')).toEqual(adapter.buildModelReadinessProbe('M')); + expect(adapterIdentification(kind)).toEqual(adapter.buildIdentification()); + } + }); + + it('wrapper adapter refuses direct-argv execution', () => { + const wrapper = registeredAdapters()['relayflows-wrapper-v1']; + expect(() => wrapper.buildAgentInvocation('X')).toThrow(/same-process session/); + expect(() => wrapper.buildLlmInvocation('Y')).toThrow(/same-process session/); + expect(wrapper.buildIdentification().expectedStdout).toBe('relayflows-agent-cli-v1'); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index f0fa6bf29..2e049e0da 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -36,7 +36,10 @@ "tests/scope-preflight.test.ts", "tests/worker-cli-cwd.test.ts", "tests/agent-relay-transport.test.ts", - "tests/webhook-hardening.test.ts" + "tests/webhook-hardening.test.ts", + "tests/adapters/claude.test.ts", + "tests/adapters/codex.test.ts", + "tests/adapters/registry.test.ts" ], "exclude": ["node_modules", "dist"] }