diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5cc7c04..59f4fd0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement the optional integration modules and captured registration API -Status: implementation in progress. Stages 1 through 10 are complete; subsequent work follows the ordered roadmap. +Status: implementation in progress. Stages 1 through 11 are complete; subsequent work follows the ordered roadmap. Execute the stages in order. Each stage is sized as a separate Luna coding-agent assignment. This document authorizes no publication or changes to sibling repositories. @@ -543,6 +543,18 @@ inspection through PromptKit's released public APIs. facets, both adapters' nil wiring and retained errors, callback narrowing, diagnostics on lookup failure, and no construction-time acquisition. +**Completion evidence — complete:** `ConfigScope` exposes PromptKit's native +effective/source/explanation facets and validation capability through the public +ckconfig scope contract. `FromEngine` returns the selected typed suite and named +callbacks, passing selection, input, state and context to the loader; request +identity is preserved and an empty prompt version keeps PromptKit's default +selection semantics. Both adapters use one private acquisition/diagnostic path. +Nil loaders are retained through `ckconfig.WithScopeError` or the suite's private +issues, including after scope capability narrowing or callback removal. Focused +adapter tests pass with a temporary workspace and a clean copy of ckconfig's +production files; the full ckprompt module suite remains deferred until Stage 13 +replaces the mixed-package prototype tests. + ## Stage 12: Assemble named PromptKit inspection and reports **Outcome:** independent optional prompt/profile commands use the core pipeline. diff --git a/integrations/ckprompt/adapters.go b/integrations/ckprompt/adapters.go new file mode 100644 index 0000000..990fca2 --- /dev/null +++ b/integrations/ckprompt/adapters.go @@ -0,0 +1,165 @@ +package ckprompt + +import ( + "context" + "errors" + "fmt" + "io" + "strconv" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + promptkit "gitea.maximumdirect.net/eric/promptkit" +) + +// PromptRequest selects one named prompt for inspection. +type PromptRequest struct { + Selection ckconfig.Selection + ID string + Version commandkit.Supplied[string] +} + +// ProfileRequest selects one named profile for inspection. +type ProfileRequest struct { + Selection ckconfig.Selection + ID string +} + +// Suite describes application-owned input, shared state, and named inspection +// capabilities. Its private issues retain adapter construction failures. +type Suite[I, S any] struct { + NewInput func() I + NewState func() S + Selectors []commandkit.Flag[I] + CheckInput func(ckconfig.Selection, I) error + InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error) + InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error) + + issues []error +} + +type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error) + +// ConfigScope adapts PromptKit's captured configuration inspection to the +// standard configuration commands. The engine is acquired only when an +// operation selects this scope. +func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] { + scope := ckconfig.Scope[I, S]{ + Name: "promptkit", + Summary: "PromptKit engine configuration.", + Operations: []ckconfig.Operation{ckconfig.Validate, ckconfig.Show, ckconfig.Sources, ckconfig.Explain}, + } + if load == nil { + return ckconfig.WithScopeError(scope, errors.New("PromptKit configuration loader is nil")) + } + + scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) { + _, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, load) + if err != nil { + return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err + } + return commandkit.Acquired[ckconfig.View]{ + Value: ckconfig.View{ + Effective: inspection, + Sources: inspection, + Explanation: inspection, + }, + Diagnostics: diagnostics, + }, nil + } + return scope +} + +// FromEngine creates named prompt and profile inspection callbacks backed by a +// PromptKit engine loader. 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")}} + } + + return Suite[I, S]{ + InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) { + engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, load) + if err != nil { + return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err + } + inspection, err := engine.InspectPrompt(ctx, request.ID, request.Version.Value) + return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err + }, + InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) { + engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, load) + if err != nil { + return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err + } + inspection, err := engine.InspectProfile(ctx, request.ID) + return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err + }, + } +} + +func loadPromptKitEngine[I, S any]( + ctx context.Context, + selection ckconfig.Selection, + input I, + state S, + load engineLoader[I, S], +) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) { + engine, err := load(ctx, selection, input, state) + if err != nil { + var loadErr *configkit.LoadError + if errors.As(err, &loadErr) { + return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", loadErr.Sources(), loadErr.Diagnostics()), err + } + return nil, promptkit.ConfigurationInspection{}, nil, err + } + if engine == nil { + return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine") + } + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, promptkit.ConfigurationInspection{}, nil, err + } + } + inspection, err := engine.InspectConfiguration() + if err != nil { + return nil, promptkit.ConfigurationInspection{}, nil, err + } + diagnostics := promptKitDiagnostics("promptkit", inspection.Sources(), inspection.Diagnostics()) + return engine, inspection, diagnostics, nil +} + +func promptKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic { + if len(records) == 0 { + return nil + } + severity := commandkit.SeverityWarning + for _, record := range records { + if record.Severity == configkit.SeverityError { + severity = commandkit.SeverityError + break + } + } + // Snapshot provider-owned slices before deferring their report to invocation. + sources = append([]configkit.SourceInfo(nil), sources...) + records = append([]configkit.Diagnostic(nil), records...) + return []commandkit.Diagnostic{{ + Severity: severity, + Write: func(writer io.Writer) error { + if _, err := fmt.Fprintf(writer, "Configuration scope %s:\n", strconv.Quote(scope)); err != nil { + return err + } + if len(sources) > 0 { + if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil { + return err + } + for _, source := range sources { + if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil { + return err + } + } + } + return configkit.WriteDiagnostics(writer, records) + }, + }} +} diff --git a/integrations/ckprompt/adapters_test.go b/integrations/ckprompt/adapters_test.go new file mode 100644 index 0000000..200b2b8 --- /dev/null +++ b/integrations/ckprompt/adapters_test.go @@ -0,0 +1,197 @@ +package ckprompt + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "testing/fstest" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + promptkit "gitea.maximumdirect.net/eric/promptkit" +) + +const adapterPrompt = `prompts: + briefing: + "1": + messages: + - role: user + content: "Hello" + 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 { + t.Helper() + inputs, err := promptkit.FilesystemInputs("adapter fixture", fstest.MapFS{ + "config/prompt.yml": &fstest.MapFile{Data: []byte(adapterPrompt)}, + }, "config") + if err != nil { + t.Fatal(err) + } + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: inputs}}, + }}) + if err != nil { + t.Fatalf("construct PromptKit fixture: %v", err) + } + return engine +} + +func TestConfigScopeUsesNativeInspectionFacetsAndInvocationValues(t *testing.T) { + type input struct{ token string } + type state struct{ token string } + type contextKey struct{} + + engine := adapterEngine(t) + wantContext := context.WithValue(context.Background(), contextKey{}, "invocation") + wantInput := input{token: "input"} + wantState := state{token: "state"} + loaderCalls := 0 + scope := ConfigScope(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { + loaderCalls++ + if ctx.Value(contextKey{}) != "invocation" || !selection.Config.Set || selection.Config.Value != "prompt-config.yml" || gotInput != wantInput || gotState != wantState { + t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState) + } + return engine, nil + }) + if scope.Name != "promptkit" || len(scope.Operations) != 4 || scope.Acquire == nil { + t.Fatalf("configuration scope = %#v", scope) + } + acquired, err := scope.Acquire(wantContext, ckconfig.Request{ + Operation: ckconfig.Show, + Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "prompt-config.yml", Set: true}}, + }, wantInput, wantState) + if err != nil || loaderCalls != 1 { + t.Fatalf("scope acquisition = (%#v, %v), calls=%d", acquired, err, loaderCalls) + } + var effective bytes.Buffer + if err := acquired.Value.Effective.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "briefing:") { + t.Fatalf("native effective report = %q, %v", effective.String(), err) + } + if sources := acquired.Value.Sources.Sources(); len(sources) == 0 || sources[0].ID == "" { + t.Fatalf("native sources = %#v", sources) + } + if explanation, ok := acquired.Value.Explanation.Explain(configkit.Path{}); !ok || !explanation.Effective.Present { + t.Fatalf("native root explanation = %#v, found=%t", explanation, ok) + } +} + +func TestConfigScopeRetainsNilLoaderIssueAfterCapabilitiesAreCleared(t *testing.T) { + scope := ConfigScope[int, int](nil) + if scope.Acquire != nil { + t.Fatal("nil loader installed an acquisition callback") + } + scope.Operations = nil + app, err := commandkit.New(commandkit.Identity{Name: "example"}) + if err != nil { + t.Fatal(err) + } + declaration := ckconfig.Command(ckconfig.Suite[int, int]{Scopes: []ckconfig.Scope[int, int]{scope}}) + if err := app.Register(declaration); err == nil || !strings.Contains(err.Error(), "PromptKit configuration loader is nil") { + t.Fatalf("narrowed nil-loader scope registered: %v", err) + } +} + +func TestFromEnginePassesRequestsAndRetainsNilLoaderIssue(t *testing.T) { + type input struct{ value string } + type state struct{ value string } + type contextKey struct{} + + engine := adapterEngine(t) + wantContext := context.WithValue(context.Background(), contextKey{}, "prompt invocation") + wantInput := input{value: "typed input"} + wantState := state{value: "shared state"} + loaderCalls := 0 + suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) { + loaderCalls++ + if ctx.Value(contextKey{}) != "prompt invocation" || !selection.Overlay.Set || selection.Overlay.Value != "overlay.yml" || gotInput != wantInput || gotState != wantState { + t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState) + } + return engine, nil + }) + selection := ckconfig.Selection{Overlay: commandkit.Supplied[string]{Value: "overlay.yml", Set: true}} + version := commandkit.Supplied[string]{Value: "1", Set: true} + prompt, err := suite.InspectPrompt(wantContext, PromptRequest{Selection: selection, ID: "briefing", Version: version}, wantInput, wantState) + if err != nil || prompt.Value == nil || prompt.Value.PromptID != "briefing" || prompt.Value.PromptVersion != "1" || loaderCalls != 1 { + t.Fatalf("prompt inspection = (%#v, %v), calls=%d", prompt, err, loaderCalls) + } + defaultPrompt, err := suite.InspectPrompt(wantContext, PromptRequest{Selection: selection, ID: "briefing"}, wantInput, wantState) + if err != nil || defaultPrompt.Value == nil || defaultPrompt.Value.PromptVersion != "1" || loaderCalls != 2 { + t.Fatalf("empty version default = (%#v, %v), calls=%d", defaultPrompt, err, loaderCalls) + } + profile, err := suite.InspectProfile(wantContext, ProfileRequest{Selection: selection, ID: "default"}, wantInput, wantState) + if err != nil || profile.Value == nil || profile.Value.ProfileID != "default" || loaderCalls != 3 { + t.Fatalf("profile inspection = (%#v, %v), calls=%d", profile, err, loaderCalls) + } + if version.Set != true || version.Value != "1" { + t.Fatalf("custom callback request lost version presence: %#v", version) + } + + empty := FromEngine[int, int](nil) + empty.InspectPrompt = nil + empty.InspectProfile = 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 TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) { + loadErr := errors.New("loader failure") + suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + return nil, loadErr + }) + acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0) + if err != loadErr || len(acquired.Diagnostics) != 0 { + t.Fatalf("loader failure = (%#v, %v), want original error", acquired, err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + engine := adapterEngine(t) + called := false + suite = FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) { + called = true + return engine, nil + }) + acquired, err = suite.InspectProfile(ctx, ProfileRequest{ID: "default"}, 0, 0) + if !errors.Is(err, context.Canceled) || acquired.Value != nil || !called { + t.Fatalf("cancelled profile acquisition = (%#v, %v), loader called=%t", acquired, err, called) + } +} + +func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T) { + writeErr := errors.New("diagnostic writer failed") + diagnostics := promptKitDiagnostics("promptkit", []configkit.SourceInfo{{ID: "source"}}, []configkit.Diagnostic{{ + Severity: configkit.SeverityWarning, + Message: "safe warning", + }}) + if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning { + t.Fatalf("diagnostics = %#v", diagnostics) + } + if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) { + t.Fatalf("diagnostic write error = %v", err) + } + var output bytes.Buffer + if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) { + t.Fatalf("diagnostic text = %q, %v", output.String(), err) + } +} + +type failingDiagnosticWriter struct{ err error } + +func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err } + +var _ io.Writer = failingDiagnosticWriter{} diff --git a/integrations/ckprompt/promptkit_scope.go b/integrations/ckprompt/promptkit_scope.go deleted file mode 100644 index 592b3df..0000000 --- a/integrations/ckprompt/promptkit_scope.go +++ /dev/null @@ -1,41 +0,0 @@ -package ckprompt - -import ( - "context" - "fmt" - - "gitea.maximumdirect.net/eric/promptkit" -) - -// NewPromptKitScope adapts PromptKit's captured configuration inspection to -// CommandKit's configuration commands. The loader runs only when a selected -// configuration operation acquires this scope. Successful construction and -// inspection supply validation; the adapter does not execute prompts, resolve -// credentials, or probe model availability. -func NewPromptKitScope[C any]( - load func(context.Context, Selection, C) (*promptkit.Engine, error), -) (ConfigScope[C], error) { - if load == nil { - return ConfigScope[C]{}, fmt.Errorf("PromptKit configuration loader is nil") - } - - return ConfigScope[C]{ - Name: "promptkit", - Summary: "PromptKit engine configuration.", - Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain}, - Acquire: func(ctx context.Context, request ConfigRequest, appContext C) (Acquired[ConfigView], error) { - _, inspection, diagnostics, err := loadPromptKitInspection(ctx, request.Selection, appContext, load) - if err != nil { - return Acquired[ConfigView]{Diagnostics: diagnostics}, err - } - return Acquired[ConfigView]{ - Value: ConfigView{ - Effective: inspection, - Sources: inspection, - Explanation: inspection, - }, - Diagnostics: diagnostics, - }, nil - }, - }, nil -}