From 3338b902a2e68080b5d3b19f6b9e1dafc11f0c7f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 26 Oct 2026 14:18:21 +0000 Subject: [PATCH] Present native PromptKit operation failures --- docs/roadmap/remediation.md | 12 ++- integrations/ckprompt/adapters.go | 33 +++++++- .../ckprompt/native_explanation_test.go | 80 +++++++++++++++++++ integrations/ckprompt/smoke.go | 4 +- 4 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 integrations/ckprompt/native_explanation_test.go diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 2e15453..9c98ffc 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,8 +1,6 @@ # Invocation reporting audit remediation -Status: five open remediation findings and two efficiency opportunities for -consideration. Findings 4 and 5 came from the interaction audit; findings 6 and 7 -came from the algorithmic audit. +Status: remediation in progress; 1 of 7 findings complete. ## Review scope @@ -65,7 +63,13 @@ findings 1–5. It made no implementation changes and ran no tests or benchmarks ## 1. Preserve native PromptKit operation failure explanations -Priority: P2. Status: open. +Priority: P2. Status: complete. + +- [x] Completed: bundled native operation failures now supply terminal explanations + without printing arbitrary application causes. Explicit provider context and + independent warnings retain their presentation paths. Native-error forwarding + and single-presentation regressions pass in ordinary and quiet modes. + Validation: `GOTOOLCHAIN=local go test ./...` in `integrations/ckprompt`. ### Finding and evidence diff --git a/integrations/ckprompt/adapters.go b/integrations/ckprompt/adapters.go index cbd6bbd..5a0b084 100644 --- a/integrations/ckprompt/adapters.go +++ b/integrations/ckprompt/adapters.go @@ -149,7 +149,8 @@ type Suite[I, S any] struct { // PromptKit unchanged; the loader receives the seed's original configuration // selection. A nil builder forwards the seed request unchanged. A builder // failure prevents engine loading and preserves its supported error identity. -// Native validation errors are returned with supported identity, and +// Native validation errors supply terminal explanations and are returned with +// supported identity, and // configuration diagnostics and terminal context from engine loading remain // separate in the acquisition. A nil loader is retained as a suite wiring // issue and does not install a validation callback. Callback references and @@ -192,6 +193,7 @@ func WithRequestValidation[I, S any]( } if err := engine.ValidateRequest(ctx, concreteRequest); err != nil { + messages = messages.withOperationError(err) return commandkit.Acquired[struct{}]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } if err := promptKitContextError(ctx); err != nil { @@ -209,6 +211,22 @@ type promptKitAcquisition struct { explanation commandkit.Explanation } +// withOperationError deliberately presents a native PromptKit operation error. +// Loader and request-builder errors remain application-owned. Native structured +// context takes precedence when an acquisition already supplies it. +func (messages promptKitAcquisition) withOperationError(err error) promptKitAcquisition { + if err == nil || messages.explanation.Message != "" { + return messages + } + for _, detail := range messages.explanation.Details { + if detail != nil { + return messages + } + } + messages.explanation.Message = err.Error() + return messages +} + // ConfigScope adapts PromptKit's captured configuration inspection to the // standard configuration commands, including native comparison when the scope // declares Diff. The loader is not called during declaration construction or @@ -266,6 +284,7 @@ func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) } differences, err := leftInspection.Diff(rightInspection) + messages = messages.withOperationError(err) if err != nil { return commandkit.Acquired[[]configkit.Difference]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err) } @@ -294,7 +313,8 @@ func promptKitContextError(ctx context.Context) error { // listing or inspection method. Applications can narrow, wrap, or replace these // callbacks before Command captures them. A nil loader is retained as an // adapter issue. Each callback returns independent loader diagnostics and -// terminal context alongside the native operation's value and error. +// terminal context alongside the native operation's value and error. Native +// operation errors supply terminal explanations when structured context is absent. 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")}} @@ -307,6 +327,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } prompts, err := engine.ListPrompts(ctx, request.Filter) + messages = messages.withOperationError(err) return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListProfiles: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) { @@ -315,6 +336,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } profiles, err := engine.ListProfiles(ctx, request.Filter) + messages = messages.withOperationError(err) return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListBackends: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) { @@ -323,6 +345,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } backends, err := engine.ListBackends(ctx, request.Filter) + messages = messages.withOperationError(err) return commandkit.Acquired[[]string]{Value: backends, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListNamespaces: func(ctx context.Context, request NamespaceRequest, input I, state S) (commandkit.Acquired[[]string], error) { @@ -331,6 +354,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[[]string]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } namespaces, err := engine.ListNamespaces(ctx, request.Kinds...) + messages = messages.withOperationError(err) return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, ListCollections: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) { @@ -339,6 +363,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } collections, err := engine.ListCollections(ctx) + messages = messages.withOperationError(err) return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) { @@ -347,6 +372,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } inspection, err := engine.InspectPrompt(ctx, request.ID) + messages = messages.withOperationError(err) return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) { @@ -355,6 +381,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } inspection, err := engine.InspectProfile(ctx, request.ID) + messages = messages.withOperationError(err) return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) { @@ -363,6 +390,7 @@ func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) ( return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } inspection, err := engine.InspectRequirements(ctx, request.Inspection) + messages = messages.withOperationError(err) return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err }, } @@ -390,6 +418,7 @@ func loadPromptKitEngine[I, S any]( } inspection, err := engine.InspectConfiguration() messages := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics()) + messages = messages.withOperationError(err) if err != nil { return nil, inspection, messages, err } diff --git a/integrations/ckprompt/native_explanation_test.go b/integrations/ckprompt/native_explanation_test.go new file mode 100644 index 0000000..cc325bf --- /dev/null +++ b/integrations/ckprompt/native_explanation_test.go @@ -0,0 +1,80 @@ +package ckprompt + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + "gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig" + promptkit "gitea.maximumdirect.net/eric/promptkit" +) + +func TestBundledNativeFailuresReachTerminalExplanation(t *testing.T) { + engine := adapterEngine(t) + load := func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) { + return engine, nil + } + for _, operation := range []string{"validation", "inspection"} { + for _, mode := range []string{"ordinary", "quiet"} { + t.Run(operation+"/"+mode, func(t *testing.T) { + suite := FromEngine(load) + suite = WithRequestValidation(suite, load, nil) + var nativeErr error + args := []string{"promptkit", "requirements", "validate", "briefing"} + if operation == "validation" { + original := suite.ValidateRequest + suite.ValidateRequest = func(ctx context.Context, request ValidationRequest, input, state struct{}) (commandkit.Acquired[struct{}], error) { + acquired, err := original(ctx, request, input, state) + nativeErr = err + acquired.Diagnostics = append(acquired.Diagnostics, commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent fixture warning")) + return acquired, err + } + } else { + args = []string{"promptkit", "show", "prompt", "missing"} + original := suite.InspectPrompt + suite.InspectPrompt = func(ctx context.Context, request PromptRequest, input, state struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) { + acquired, err := original(ctx, request, input, state) + nativeErr = err + return acquired, err + } + } + if mode == "quiet" { + args = append(args, "--quiet") + } + var diagnostics bytes.Buffer + got := newPromptTestApp(t, Command(suite)).Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: &diagnostics}) + if nativeErr == nil { + t.Fatal("fixture did not supply a native failure") + } + if got.Status != 1 || !errors.Is(got.Cause, nativeErr) || got.Explanation.Message != nativeErr.Error() { + t.Fatalf("native error was not preserved in outcome: %#v, error=%v", got, nativeErr) + } + if strings.Count(diagnostics.String(), nativeErr.Error()) != 1 || strings.Contains(diagnostics.String(), "independent fixture warning") { + t.Fatalf("terminal adaptation or warning visibility: %q", diagnostics.String()) + } + }) + } + } +} + +func TestNativeOperationExplanationPreservesSuppliedContext(t *testing.T) { + nativeErr := errors.New("native operation detail") + for _, explanation := range []commandkit.Explanation{ + {Message: "selected explanation"}, + {Details: []commandkit.Detail{func(w io.Writer) error { _, err := io.WriteString(w, "native structured explanation\n"); return err }}}, + } { + messages := (promptKitAcquisition{explanation: explanation}).withOperationError(nativeErr) + suite := Suite[struct{}, struct{}]{InspectPrompt: func(context.Context, PromptRequest, struct{}, struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) { + return commandkit.Acquired[*promptkit.PromptInspection]{Explanation: messages.explanation}, nativeErr + }} + var diagnostics bytes.Buffer + got := newPromptTestApp(t, Command(suite)).Execute(context.Background(), []string{"promptkit", "show", "prompt", "fixture", "--quiet"}, commandkit.Streams{Err: &diagnostics}) + if !errors.Is(got.Cause, nativeErr) || strings.Contains(diagnostics.String(), nativeErr.Error()) || strings.Count(diagnostics.String(), "explanation") != 1 { + t.Fatalf("supplied explanation was replaced or duplicated: %#v, %q", got, diagnostics.String()) + } + } +} diff --git a/integrations/ckprompt/smoke.go b/integrations/ckprompt/smoke.go index 5569a63..e679bec 100644 --- a/integrations/ckprompt/smoke.go +++ b/integrations/ckprompt/smoke.go @@ -27,7 +27,8 @@ type SmokeRequest struct { // without a builder, the seed is forwarded unchanged. Reports and errors from // PromptKit are both retained. A nil loader is kept as a suite wiring issue and // does not install a smoke-test callback. The callback also retains independent -// engine diagnostics and terminal context with the report and error. +// engine diagnostics and terminal context with the report and error. Native +// operation errors supply terminal explanations when structured context is absent. func WithSmokeTesting[I, S any]( suite Suite[I, S], load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error), @@ -65,6 +66,7 @@ func WithSmokeTesting[I, S any]( } report, err := engine.SmokeTest(ctx, concreteRequest) + messages = messages.withOperationError(err) return commandkit.Acquired[*promptkit.SmokeTestReport]{Value: report, Diagnostics: messages.diagnostics, Explanation: messages.explanation}, err } return suite