Migrate maintained examples to invocation reporting

This commit is contained in:
2026-10-26 03:44:19 +00:00
parent b6f22462d3
commit 6264a87183
8 changed files with 204 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
# Invocation reporting implementation plan # Invocation reporting implementation plan
Status: Stages 1–16 complete. Stage 17 is next. Status: Stages 1–17 complete. Stage 18 is next.
## Objective and authority ## Objective and authority
@@ -642,7 +642,7 @@ explanation, then exits with status 1 as expected.
## Stage 17: Migrate maintained integration consumers ## Stage 17: Migrate maintained integration consumers
Status: pending. Status: complete.
Primary directories: Primary directories:
[ckconfig example](../../integrations/ckconfig/examples/simple) and [ckconfig example](../../integrations/ckconfig/examples/simple) and
@@ -664,6 +664,16 @@ Validation: both example packages compile in their owning modules and run
without external services. Confirm custom commands and integration commands without external services. Confirm custom commands and integration commands
share the same reporting flags and terminal behavior. share the same reporting flags and terminal behavior.
Completion: migrated both example-owned handlers to invocation reporters and
explicit explanations with optional causes. The ConfigKit consumer demonstrates
a provider schema warning in verbose mode, ordinary report output, and quiet
suppression. The composed consumer retains aliased help and offline smoke usage;
its tests cover a native report with an independent provider warning, terminal
context, and an operation error, plus quiet custom-command success and domain
failure. Both integration modules pass `GOTOOLCHAIN=local go test ./...`,
including both example packages. Representative verbose ConfigKit, quiet
ConfigKit, aliased quiet help, and offline PromptKit smoke commands also pass.
## Stage 18: Reconcile the full test suite and architectural boundaries ## Stage 18: Reconcile the full test suite and architectural boundaries
Status: pending. Status: pending.

View File

@@ -4,6 +4,8 @@ Run the maintained example with:
```sh ```sh
go run . config show go run . config show
go run . config show --config fixtures/before.yml --verbose
go run . config show --config fixtures/before.yml --quiet
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
go run . greet go run . greet
go run . greet --count=0 --tag=first --tag second go run . greet --count=0 --tag=first --tag second
@@ -15,3 +17,6 @@ omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
With no `--config`, application settings use the in-memory default. A supplied With no `--config`, application settings use the in-memory default. A supplied
configuration and overlay are passed to ConfigKit unchanged. The comparison configuration and overlay are passed to ConfigKit unchanged. The comparison
command loads the two local fixtures and reports `changes.greeting: Goodbye`. command loads the two local fixtures and reports `changes.greeting: Goodbye`.
The before fixture uses schema version 1, which ConfigKit reports as a provider
warning in verbose mode. Ordinary mode hides the warning while preserving the
effective YAML report; quiet mode suppresses that report.

View File

@@ -1 +1,3 @@
schema:
version: 2
greeting: Goodbye greeting: Goodbye

View File

@@ -1 +1,3 @@
schema:
version: 1
greeting: Hello greeting: Hello

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"strconv" "strconv"
@@ -12,6 +13,9 @@ import (
) )
type appConfiguration struct { type appConfiguration struct {
Schema struct {
Version int `yaml:"version"`
} `yaml:"schema"`
Greeting string `yaml:"greeting"` Greeting string `yaml:"greeting"`
} }
@@ -22,12 +26,25 @@ type greetInput struct {
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) { func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
source := configkit.Mapping("simple application settings", map[string]any{ source := configkit.Mapping("simple application settings", map[string]any{
"schema": map[string]any{"version": 2},
"greeting": "Hello", "greeting": "Hello",
}) })
if selection.Config.Set { if selection.Config.Set {
source = configkit.File(selection.Config.Value) source = configkit.File(selection.Config.Value)
} }
options := configkit.Options[appConfiguration]{} options := configkit.Options[appConfiguration]{
Definition: configkit.Definition[appConfiguration]{
Version: &configkit.VersionRule{
Path: configkit.Keys("schema", "version"),
Check: func(version configkit.Version, validation *configkit.Validation) error {
if version.Text == "1" {
validation.Warning(configkit.Keys("schema", "version"), "schema version 1 is supported; upgrade to version 2 before the next example release")
}
return nil
},
},
},
}
if selection.Overlay.Set { if selection.Overlay.Set {
options.Overlay = selection.Overlay.Value options.Overlay = selection.Overlay.Value
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."} options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
@@ -50,9 +67,10 @@ func newApp() (*commandkit.App, error) {
}), }),
}, },
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome { Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
reporter := invocation.Reporter()
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{}) configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
if err != nil { if err != nil {
return commandkit.Failure(err) return commandkit.Failure("Could not load application configuration.").WithCause(err)
} }
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value) count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set { if input.Count.Set {
@@ -62,9 +80,10 @@ func newApp() (*commandkit.App, error) {
if input.Tags.Set { if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value) tags = fmt.Sprintf("explicit %v", input.Tags.Value)
} }
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil { _ = reporter.RenderOutput(func(writer io.Writer) error {
return commandkit.Failure(err) _, err := fmt.Fprintf(writer, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags)
} return err
})
return commandkit.Success() return commandkit.Success()
}, },
}) })

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"io" "io"
"strings"
"testing" "testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit" commandkit "gitea.maximumdirect.net/eric/CommandKit"
@@ -42,7 +43,7 @@ func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
output.Reset() output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard}) outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\n" { if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\nschema:\n version: 2\n" {
t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String()) t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String())
} }
@@ -60,6 +61,73 @@ func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) {
} }
} }
func TestSimpleConfigurationReportingModes(t *testing.T) {
for _, test := range []struct {
name string
args []string
wantOutput string
wantDiagnostic string
quiet bool
}{
{
name: "ordinary output hides provider warning",
args: []string{"config", "show", "--config", "fixtures/before.yml"},
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
},
{
name: "quiet suppresses configuration report",
args: []string{"config", "show", "--config", "fixtures/before.yml", "--quiet"},
quiet: true,
},
{
name: "verbose shows ConfigKit warning",
args: []string{"config", "show", "--config", "fixtures/before.yml", "--verbose"},
wantOutput: "greeting: Hello\nschema:\n version: 1\n",
wantDiagnostic: "schema version 1 is supported",
},
} {
t.Run(test.name, func(t *testing.T) {
outcome, output, diagnostics := executeSimple(t, test.args...)
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
t.Fatalf("outcome = %#v", outcome)
}
if output != test.wantOutput {
t.Errorf("output = %q, want %q", output, test.wantOutput)
}
if test.wantDiagnostic != "" && !strings.Contains(diagnostics, test.wantDiagnostic) {
t.Errorf("diagnostics = %q, want provider warning %q", diagnostics, test.wantDiagnostic)
}
if test.wantDiagnostic == "" && diagnostics != "" {
t.Errorf("diagnostics = %q, want no warning outside verbose mode", diagnostics)
}
if strings.Contains(diagnostics, "ERROR:") {
t.Errorf("diagnostics %q unexpectedly contain a terminal failure", diagnostics)
}
if test.quiet && (outcome.Presentation.Output.State != commandkit.DeliverySuppressed || outcome.Presentation.Diagnostics.State != commandkit.DeliverySuppressed) {
t.Errorf("quiet presentation = %#v, want report and provider warning suppressed", outcome.Presentation)
}
})
}
}
func TestSimpleCustomCommandUsesSharedReportingFlags(t *testing.T) {
outcome, output, diagnostics := executeSimple(t, "greet", "--quiet")
if outcome.Kind != commandkit.OutcomeSuccess || output != "" || diagnostics != "" {
t.Fatalf("quiet greet outcome/output/diagnostics = %#v / %q / %q", outcome, output, diagnostics)
}
}
func executeSimple(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
t.Helper()
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
return outcome, output.String(), diagnostics.String()
}
func field(mapping *yaml.Node, name string) *yaml.Node { func field(mapping *yaml.Node, name string) *yaml.Node {
if mapping == nil || mapping.Kind != yaml.MappingNode { if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil return nil

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"strconv" "strconv"
@@ -65,6 +66,8 @@ type session struct {
Summary string Summary string
} }
var errSessionNotFound = errors.New("saved session was not found")
func campaignSelector() commandkit.Flag[*inspectionInput] { func campaignSelector() commandkit.Flag[*inspectionInput] {
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString, return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Usage: "Select campaign inputs."}, (*string)(nil), parseString,
func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value }) func(input **inspectionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
@@ -200,10 +203,14 @@ func newApp() (*commandkit.App, error) {
} }
func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) { func newAppWithLLMClient(client promptkit.LLMClient) (*commandkit.App, error) {
return newAppWithEngineLoader(client, loadPromptEngine) return newAppWithEngineLoader(client, loadPromptEngine, nil)
} }
func newAppWithEngineLoader(client promptkit.LLMClient, engineLoader func(context.Context, ckconfig.Selection, *inspectionInput, *runCache) (*promptkit.Engine, error)) (*commandkit.App, error) { func newAppWithEngineLoader(
client promptkit.LLMClient,
engineLoader func(context.Context, ckconfig.Selection, *inspectionInput, *runCache) (*promptkit.Engine, error),
smokeTest func(context.Context, ckprompt.SmokeRequest, *inspectionInput, *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error),
) (*commandkit.App, error) {
if client == nil { if client == nil {
client = &exampleLLMClient{} client = &exampleLLMClient{}
} }
@@ -237,6 +244,9 @@ func newAppWithEngineLoader(client promptkit.LLMClient, engineLoader func(contex
definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()} definitions.Selectors = []commandkit.Flag[*inspectionInput]{campaignSelector()}
definitions = ckprompt.WithRequestValidation(definitions, engineLoader, buildValidationRequest) definitions = ckprompt.WithRequestValidation(definitions, engineLoader, buildValidationRequest)
definitions = ckprompt.WithSmokeTesting(definitions, engineLoader, nil) definitions = ckprompt.WithSmokeTesting(definitions, engineLoader, nil)
if smokeTest != nil {
definitions.SmokeTest = smokeTest
}
definitionCommand := ckprompt.Command(definitions) definitionCommand := ckprompt.Command(definitions)
store := map[string]session{ store := map[string]session{
@@ -250,27 +260,30 @@ func newAppWithEngineLoader(client promptkit.LLMClient, engineLoader func(contex
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."}, true, parseString, Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "Session identifier."}, true, parseString,
func(input *restoreInput, value commandkit.Supplied[string]) { input.Session = value.Value })}, func(input *restoreInput, value commandkit.Supplied[string]) { input.Session = value.Value })},
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome { Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return commandkit.Failure(err) return commandkit.Failure("Session restore was canceled.").WithCause(err)
} }
application, err := loadApplicationConfig(invocation.Context, ckconfig.Selection{}, nil, nil) application, err := loadApplicationConfig(invocation.Context, ckconfig.Selection{}, nil, nil)
if err != nil { if err != nil {
return commandkit.Failure(err) return commandkit.Failure("Could not load application configuration.").WithCause(err)
} }
name, _, err := selectedCampaign(application.Config, input.Campaign) name, _, err := selectedCampaign(application.Config, input.Campaign)
if err != nil { if err != nil {
return commandkit.Failure(err) return commandkit.Failure(err.Error()).WithCause(err)
} }
record, ok := store[input.Session] record, ok := store[input.Session]
if !ok { if !ok {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")} return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errSessionNotFound, Explanation: commandkit.Explanation{Message: "Saved session was not found."}}
} }
if record.Campaign != name { if record.Campaign != name {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)} cause := fmt.Errorf("session belongs to campaign %q", record.Campaign)
} return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: cause, Explanation: commandkit.Explanation{Message: "Session campaign does not match the selected campaign."}}
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
return commandkit.Failure(err)
} }
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := fmt.Fprintf(writer, "restored %s: %s\n", record.ID, record.Summary)
return err
})
return commandkit.Success() return commandkit.Success()
}, },
}) })

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io" "io"
"reflect" "reflect"
"strings" "strings"
@@ -351,6 +352,66 @@ func TestComposedCatalogsAndSmokeUseInjectedClientOnlyForInference(t *testing.T)
} }
} }
func TestComposedSmokeReportSurvivesInferenceFailure(t *testing.T) {
wantCause := errors.New("local example inference failed")
smokeTest := func(_ context.Context, request ckprompt.SmokeRequest, _ *inspectionInput, _ *runCache) (commandkit.Acquired[*promptkit.SmokeTestReport], error) {
return commandkit.Acquired[*promptkit.SmokeTestReport]{
Value: &promptkit.SmokeTestReport{Kind: request.Request.Kind, Completed: true},
Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, "independent smoke warning")},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "Native text inference context.\n")
return err
}}},
}, wantCause
}
app, err := newAppWithEngineLoader(&exampleLLMClient{}, loadPromptEngine, smokeTest)
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"promptkit", "test", "text_inference", "--campaign", "spring", "--prompt", "briefing", "--profile", "spring", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Cause, wantCause) {
t.Fatalf("smoke failure outcome = %#v; report %q; diagnostics %q", outcome, output.String(), diagnostics.String())
}
if !strings.Contains(output.String(), "kind: text_inference") || !strings.Contains(output.String(), "completed: true") {
t.Fatalf("native smoke report was not delivered: %q", output.String())
}
warningAt := strings.Index(diagnostics.String(), "independent smoke warning")
contextAt := strings.Index(diagnostics.String(), "Native text inference context")
if warningAt < 0 || contextAt < 0 || warningAt >= contextAt || strings.Contains(diagnostics.String(), wantCause.Error()) {
t.Fatalf("terminal explanation/diagnostics = %q / %q", outcome.Explanation.Message, diagnostics.String())
}
if outcome.Explanation.Message != "" {
t.Errorf("unexpected synthesized terminal explanation %q", outcome.Explanation.Message)
}
}
func TestComposedCustomRestoreUsesSharedReportingAndTerminalFailure(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"session", "restore", "session-42", "--campaign", "spring", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeSuccess || output.Len() != 0 || diagnostics.Len() != 0 {
t.Fatalf("quiet custom command outcome/output/diagnostics = %#v / %q / %q", outcome, output.String(), diagnostics.String())
}
output.Reset()
diagnostics.Reset()
outcome = app.Execute(context.Background(), []string{"session", "restore", "missing", "--quiet"}, commandkit.Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != commandkit.OutcomeDomain || outcome.Status != 4 || outcome.Domain != "session_not_found" || !errors.Is(outcome.Cause, errSessionNotFound) {
t.Fatalf("missing session outcome = %#v", outcome)
}
if output.Len() != 0 || outcome.Explanation.Message == "" || !strings.Contains(diagnostics.String(), outcome.Explanation.Message) {
t.Fatalf("terminal failure output/explanation/diagnostics = %q / %q / %q", output.String(), outcome.Explanation.Message, diagnostics.String())
}
if strings.Contains(diagnostics.String(), errSessionNotFound.Error()) {
t.Errorf("terminal presentation exposed the underlying cause: %q", diagnostics.String())
}
}
func TestComposedAliasesReachCanonicalCommands(t *testing.T) { func TestComposedAliasesReachCanonicalCommands(t *testing.T) {
clientCalls := 0 clientCalls := 0
engineLoads := 0 engineLoads := 0
@@ -358,7 +419,7 @@ func TestComposedAliasesReachCanonicalCommands(t *testing.T) {
engineLoads++ engineLoads++
return loadPromptEngine(ctx, selection, input, cache) return loadPromptEngine(ctx, selection, input, cache)
} }
app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &clientCalls}, engineLoader) app, err := newAppWithEngineLoader(&exampleLLMClient{calls: &clientCalls}, engineLoader, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -368,6 +429,11 @@ func TestComposedAliasesReachCanonicalCommands(t *testing.T) {
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "field-notes") || engineLoads != 0 || clientCalls != 0 { if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "field-notes") || engineLoads != 0 || clientCalls != 0 {
t.Fatalf("group help alias outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls) t.Fatalf("group help alias outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls)
} }
output.Reset()
outcome = app.Execute(context.Background(), []string{"pk", "--hlep", "--quiet"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.Len() != 0 || engineLoads != 0 || clientCalls != 0 {
t.Fatalf("quiet aliased help outcome/output/engine loads/client calls = (%#v, %q, %d, %d)", outcome, output.String(), engineLoads, clientCalls)
}
for _, args := range [][]string{ for _, args := range [][]string{
{"list", "collections", "--campaign", "spring"}, {"list", "collections", "--campaign", "spring"},