From ebd29598db9fa1f7feba0f496592b0f3a2ef113e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 12 Oct 2026 16:54:00 +0000 Subject: [PATCH] Add PromptKit requirements adapter --- docs/roadmap/implementation.md | 13 ++- integrations/ckprompt/adapters.go | 53 +++++++--- integrations/ckprompt/adapters_test.go | 135 +++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 17 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 80ce7eb..7460d03 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement PromptKit requirements inspection -Status: ready for implementation; no stages have been completed. +Status: in progress; Stage 1 is complete. ## Authority and working rules @@ -116,6 +116,17 @@ adds the callback; its CLI availability arrives in Stage 3. Update existing absence tests when the command assembly starts counting the fifth callback in that stage, rather than treating their old four-callback assumption as policy. +**Completed:** Added `RequirementsRequest`, the optional suite callback, and +the `FromEngine` native adapter in `integrations/ckprompt/adapters.go`; added +adapter coverage in `adapters_test.go`. The ignored workspace already included +core, ckconfig, and ckprompt, and the baseline ckprompt suite passed. After the +change, `GOTOOLCHAIN=local go test ./integrations/ckprompt -run +'TestFromEngine(InspectsRequirementsWithNativeRequest|RequirementsRetainLoadDiagnosticsAndSelectionErrors|RequirementsHonorCancellationAfterLoad|PassesRequestsAndRetainsNilLoaderIssue)$' +-count=1` and `GOTOOLCHAIN=local go test ./...` from `integrations/ckprompt` +passed. The tests verify native result equivalence, loader arguments, retained +ConfigKit diagnostics, nil-engine handling, native selection error identity, and +cancellation after loading. No material limitations were encountered. + ## Stage 2: Implement the requirements YAML report **Outcome:** native inspections map to the accepted YAML shape without losing diff --git a/integrations/ckprompt/adapters.go b/integrations/ckprompt/adapters.go index ecb5ed2..8c7ba5c 100644 --- a/integrations/ckprompt/adapters.go +++ b/integrations/ckprompt/adapters.go @@ -31,9 +31,18 @@ type ProfileRequest struct { ID string } -// Suite describes application-owned input, shared state, and optional catalog -// and named inspection capabilities. Its private issues retain adapter -// construction failures. +// RequirementsRequest selects a prompt context for native requirements +// inspection. +type RequirementsRequest struct { + // Selection carries the configuration and overlay values supplied to the command. + Selection ckconfig.Selection + // Inspection carries PromptKit's discovery context for the selected prompt. + Inspection promptkit.RequirementInspectionRequest +} + +// Suite describes application-owned input, shared state, and optional catalog, +// named inspection, and requirements inspection capabilities. Its private +// issues retain adapter construction failures. // Factory results and callback effects remain caller-owned: Command does not // deep-copy arbitrary objects or repair aliases returned by factories. // A reached operation prepares one input, applies its captured selectors, and @@ -41,24 +50,28 @@ type ProfileRequest struct { // Factories are not invoked during construction, installation, help, or version, // and need not run after failure. type Suite[I, S any] struct { - // NewInput is called once when definition or catalog input preparation is - // reached, before applying selectors and calling CheckInput. + // NewInput is called once when definition, requirements, or catalog input + // preparation is reached, before applying selectors and calling CheckInput. // If nil, input is the zero value of I, including nil for pointer types. NewInput func() I // NewState is called once when an inspection or catalog callback is reached, // after input checks pass. If nil, state is the zero value of S, including // nil for pointer types. NewState func() S - // Selectors declares the original typed flags accepted by definition and - // catalog commands. + // Selectors declares the original typed flags accepted by definition, + // requirements, and catalog commands. Selectors []commandkit.Flag[I] - // CheckInput validates selected definition or catalog input before state - // creation. + // CheckInput validates selected definition, requirements, or catalog input + // before state creation. CheckInput func(ckconfig.Selection, I) error // InspectPrompt inspects the selected prompt after input and state preparation. InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error) // InspectProfile inspects the selected profile after input and state preparation. InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error) + // InspectRequirements inspects native PromptKit requirements after input and + // state preparation. It is optional; a nil callback omits the requirements + // command without disabling other capabilities. + InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error) // ListPrompts lists prompt identities after input and state preparation. // It is optional; a nil callback does not install a prompt catalog command. ListPrompts func(context.Context, ckconfig.Selection, I, S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) @@ -143,13 +156,13 @@ func promptKitContextError(ctx context.Context) error { return ctx.Err() } -// FromEngine supplies callbacks for prompt and profile catalogs and named -// inspections backed by a PromptKit engine loader. It does not call the loader -// during construction. Each selected operation passes its context, supplied -// selection, initialized input, and shared invocation state to the loader before -// calling PromptKit's public listing or inspection method. The callbacks can be -// narrowed before Command captures them. A nil loader is retained as an adapter -// issue. +// FromEngine supplies callbacks for prompt and profile catalogs, named +// inspections, and requirements inspection backed by a PromptKit engine loader. +// It does not call the loader during construction. Each selected operation +// passes its context, supplied selection, initialized input, and shared +// invocation state to the loader before calling PromptKit's public listing or +// inspection method. Applications can narrow these callbacks before Command +// captures them. A nil loader is retained as an adapter issue. func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] { if load == nil { return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}} @@ -188,6 +201,14 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( inspection, err := engine.InspectProfile(ctx, request.ID) return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err }, + InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) { + engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load) + if err != nil { + return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: diagnostics}, err + } + inspection, err := engine.InspectRequirements(ctx, request.Inspection) + return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: diagnostics}, err + }, } } diff --git a/integrations/ckprompt/adapters_test.go b/integrations/ckprompt/adapters_test.go index 8c9952c..892634c 100644 --- a/integrations/ckprompt/adapters_test.go +++ b/integrations/ckprompt/adapters_test.go @@ -66,6 +66,26 @@ profiles: api_key_required: true ` +const adapterRequirementsPrompt = `prompts: + briefing: + "1": + default_profile: default + session_id: '{{input "session-only"}} {{.session_variable}}' + messages: + - role: user + content: '{{if eq .audience "internal"}}{{input "internal-notes"}}{{else}}{{input "public-notes"}}{{end}} {{.audience}}' + output: + format: text + validation_mode: none +backends: + local: + endpoint: "https://example.invalid/v1" +profiles: + default: + backend: local + model: example +` + func adapterEngine(t *testing.T) *promptkit.Engine { return adapterEngineFrom(t, "adapter fixture", adapterPrompt) } @@ -321,11 +341,126 @@ func TestFromEnginePassesRequestsAndRetainsNilLoaderIssue(t *testing.T) { empty.ListProfiles = nil empty.InspectPrompt = nil empty.InspectProfile = nil + empty.InspectRequirements = nil if len(empty.issues) != 1 || !strings.Contains(empty.issues[0].Error(), "PromptKit engine loader is nil") { t.Fatalf("nil-loader issue was lost when callbacks were cleared: %#v", empty.issues) } } +func TestFromEngineInspectsRequirementsWithNativeRequest(t *testing.T) { + type input struct{ value string } + type state struct{ value string } + type contextKey struct{} + + engine := adapterEngineFrom(t, "requirements adapter fixture", adapterRequirementsPrompt) + wantContext := context.WithValue(context.Background(), contextKey{}, "requirements invocation") + wantInput := input{value: "typed input"} + wantState := state{value: "shared state"} + selection := ckconfig.Selection{ + Config: commandkit.Supplied[string]{Value: "prompt.yml", Set: true}, + Overlay: commandkit.Supplied[string]{Value: "overlay.yml", Set: true}, + } + request := RequirementsRequest{ + Selection: selection, + Inspection: promptkit.RequirementInspectionRequest{ + PromptID: "briefing", + PromptVersion: "1", + ProfileID: "default", + Vars: map[string]string{"audience": "internal"}, + SessionID: "session-42", + }, + } + want, err := engine.InspectRequirements(wantContext, request.Inspection) + if err != nil { + t.Fatalf("direct native requirements inspection: %v", err) + } + + loaderCalls := 0 + suite := FromEngine(func(ctx context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { + loaderCalls++ + if ctx.Value(contextKey{}) != "requirements invocation" || !reflect.DeepEqual(gotSelection, selection) || gotInput != wantInput || gotState != wantState { + t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), gotSelection, gotInput, gotState) + } + return engine, nil + }) + acquired, err := suite.InspectRequirements(wantContext, request, wantInput, wantState) + if err != nil || acquired.Value == nil || !reflect.DeepEqual(acquired.Value, want) || loaderCalls != 1 { + t.Fatalf("requirements inspection = (%#v, %v), calls=%d; want native result %#v", acquired, err, loaderCalls, want) + } + if acquired.Value.PromptID != "briefing" || acquired.Value.PromptVersion != "1" || acquired.Value.ProfileID != "default" { + t.Fatalf("requirements selection = %#v", acquired.Value) + } + inputs := make(map[string]promptkit.InputRequirement, len(acquired.Value.Inputs)) + for _, input := range acquired.Value.Inputs { + inputs[input.Name] = input + } + if inputs["internal-notes"].Kind != promptkit.RequirementRequired || inputs["public-notes"].Kind != promptkit.RequirementOptional { + t.Fatalf("caller variable did not reach native discovery: %#v", acquired.Value.Inputs) + } + if _, found := inputs["session-only"]; found { + t.Fatalf("session template was not bypassed by the supplied session ID: %#v", acquired.Value.Inputs) + } + variables := make(map[string]promptkit.VariableRequirement, len(acquired.Value.Variables)) + for _, variable := range acquired.Value.Variables { + variables[variable.Name] = variable + } + if !variables["audience"].Supplied { + t.Fatalf("caller variable presence was not forwarded: %#v", acquired.Value.Variables) + } +} + +func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testing.T) { + _, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: ["))) + if loadErr == nil { + t.Fatal("invalid configuration unexpectedly loaded") + } + loadFailure := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + return nil, loadErr + }) + acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0) + var retained *configkit.LoadError + if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil { + t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err) + } + if acquired.Diagnostics[0].Severity != commandkit.SeverityError { + t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics) + } + + nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + return nil, nil + }) + acquired, err = nilEngine.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0) + if err == nil || acquired.Value != nil || !strings.Contains(err.Error(), "nil engine") { + t.Fatalf("nil engine result = (%#v, %v)", acquired, err) + } + + engine := adapterEngine(t) + selectionFailure := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + return engine, nil + }) + acquired, err = selectionFailure.InspectRequirements(context.Background(), RequirementsRequest{ + Inspection: promptkit.RequirementInspectionRequest{PromptID: "missing", ProfileID: "default"}, + }, 0, 0) + if !errors.Is(err, promptkit.ErrPromptNotFound) || acquired.Value != nil { + t.Fatalf("native selection failure = (%#v, %v), want ErrPromptNotFound", acquired, err) + } +} + +func TestFromEngineRequirementsHonorCancellationAfterLoad(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + engine := adapterEngine(t) + suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + cancel() + return engine, nil + }) + acquired, err := suite.InspectRequirements(ctx, RequirementsRequest{ + Inspection: promptkit.RequirementInspectionRequest{PromptID: "briefing", ProfileID: "default"}, + }, 0, 0) + if !errors.Is(err, context.Canceled) || acquired.Value != nil { + t.Fatalf("cancelled requirements inspection = (%#v, %v)", acquired, err) + } +} + func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) { type input struct{ label string } type state struct{ label string }