diff --git a/internal/agentcapabilities/markdown_helpers_test.go b/internal/agentcapabilities/markdown_helpers_test.go
new file mode 100644
index 000000000..b2105eca0
--- /dev/null
+++ b/internal/agentcapabilities/markdown_helpers_test.go
@@ -0,0 +1,173 @@
+package agentcapabilities
+
+import "testing"
+
+// This white-box test covers the three pure helper functions in markdown.go:
+// - markdownCountWord(count int) string
+// - pluralize(word string, count int) string
+// - mcpCapabilityCategoryHeading(category string, categories []CapabilityCategory) string
+//
+// The tests assert the exact returned string for every switch arm, the default
+// arm, boundary values, and empty/nil inputs. No source behaviour is changed.
+
+func TestMarkdownCountWord(t *testing.T) {
+ tests := []struct {
+ name string
+ count int
+ want string
+ }{
+ // Each named switch arm.
+ {name: "zero", count: 0, want: "zero"},
+ {name: "one", count: 1, want: "one"},
+ {name: "two", count: 2, want: "two"},
+ {name: "three", count: 3, want: "three"},
+ {name: "four", count: 4, want: "four"},
+ {name: "five upper boundary of word arms", count: 5, want: "five"},
+ // Default arm: decimal formatting.
+ {name: "six first default value", count: 6, want: "6"},
+ {name: "negative default value", count: -1, want: "-1"},
+ {name: "two digit default value", count: 42, want: "42"},
+ {name: "large default value", count: 1000000, want: "1000000"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := markdownCountWord(tc.count); got != tc.want {
+ t.Fatalf("markdownCountWord(%d) = %q, want %q", tc.count, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPluralize(t *testing.T) {
+ tests := []struct {
+ name string
+ word string
+ count int
+ want string
+ }{
+ // count == 1 identity branch.
+ {name: "count one returns word unchanged", word: "surface", count: 1, want: "surface"},
+ {name: "count one with empty word", word: "", count: 1, want: ""},
+ // else branch: append "s".
+ {name: "count zero appends s", word: "surface", count: 0, want: "surfaces"},
+ {name: "count two appends s", word: "surface", count: 2, want: "surfaces"},
+ {name: "negative count appends s", word: "surface", count: -3, want: "surfaces"},
+ {name: "empty word appends s", word: "", count: 5, want: "s"},
+ // Confirms the implementation is a naive append: no "es"/"ies" rules.
+ {name: "naive append does not special case box", word: "box", count: 3, want: "boxs"},
+ {name: "count one does not special case box", word: "box", count: 1, want: "box"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := pluralize(tc.word, tc.count); got != tc.want {
+ t.Fatalf("pluralize(%q, %d) = %q, want %q", tc.word, tc.count, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestMCPCapabilityCategoryHeading(t *testing.T) {
+ tests := []struct {
+ name string
+ category string
+ categories []CapabilityCategory
+ want string
+ }{
+ // Matched descriptor with a non-empty label returns the label.
+ {
+ name: "matched descriptor non-empty label returns label",
+ category: "context",
+ categories: []CapabilityCategory{{ID: "context", Label: "Context (read-only)"}},
+ want: "Context (read-only)",
+ },
+ // Matched descriptor with an empty label falls back to the raw id.
+ {
+ name: "matched descriptor empty label returns raw category",
+ category: "context",
+ categories: []CapabilityCategory{{ID: "context", Label: ""}},
+ want: "context",
+ },
+ // Matched descriptor with a whitespace-only label is treated as empty.
+ {
+ name: "matched descriptor whitespace label returns raw category",
+ category: "context",
+ categories: []CapabilityCategory{{ID: "context", Label: " "}},
+ want: "context",
+ },
+ // The descriptor ID is trimmed before comparison.
+ {
+ name: "matched descriptor id is trimmed before comparison",
+ category: "context",
+ categories: []CapabilityCategory{{ID: " context ", Label: "Context"}},
+ want: "Context",
+ },
+ // First matching descriptor wins: an empty label on the first match
+ // short-circuits and returns the raw id even if a later descriptor
+ // carries a label.
+ {
+ name: "first match with empty label wins over later label",
+ category: "context",
+ categories: []CapabilityCategory{{ID: "context", Label: ""}, {ID: "context", Label: "Context"}},
+ want: "context",
+ },
+ // No match: the "uncategorized" sentinel maps to the title-cased fallback.
+ {
+ name: "no match uncategorized sentinel returns title cased fallback",
+ category: "uncategorized",
+ categories: []CapabilityCategory{{ID: "context", Label: "Context"}},
+ want: "Uncategorized",
+ },
+ // No match: any other id is returned verbatim.
+ {
+ name: "no match unknown category returns raw category",
+ category: "custom",
+ categories: []CapabilityCategory{{ID: "context", Label: "Context"}},
+ want: "custom",
+ },
+ // No match against an empty descriptor slice returns the raw category.
+ {
+ name: "no match empty descriptor slice returns raw category",
+ category: "custom",
+ categories: []CapabilityCategory{},
+ want: "custom",
+ },
+ // No match against a nil descriptor slice: the uncategorized fallback.
+ {
+ name: "nil descriptor slice uncategorized sentinel",
+ category: "uncategorized",
+ categories: nil,
+ want: "Uncategorized",
+ },
+ // An explicit "uncategorized" descriptor with an empty label returns the
+ // lowercase raw id, NOT the title-cased "Uncategorized" fallback,
+ // because the descriptor loop takes precedence.
+ {
+ name: "explicit uncategorized descriptor empty label returns raw id",
+ category: "uncategorized",
+ categories: []CapabilityCategory{{ID: "uncategorized", Label: ""}},
+ want: "uncategorized",
+ },
+ // An explicit "uncategorized" descriptor with a label returns the label.
+ {
+ name: "explicit uncategorized descriptor with label returns label",
+ category: "uncategorized",
+ categories: []CapabilityCategory{{ID: "uncategorized", Label: "Miscellaneous"}},
+ want: "Miscellaneous",
+ },
+ // Empty category with no matching descriptor returns the empty string.
+ {
+ name: "empty category no match returns empty",
+ category: "",
+ categories: []CapabilityCategory{{ID: "context", Label: "Context"}},
+ want: "",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := mcpCapabilityCategoryHeading(tc.category, tc.categories); got != tc.want {
+ t.Fatalf("mcpCapabilityCategoryHeading(%q, %+v) = %q, want %q",
+ tc.category, tc.categories, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/agentcapabilities/projection_pathparams_test.go b/internal/agentcapabilities/projection_pathparams_test.go
new file mode 100644
index 000000000..a6044a004
--- /dev/null
+++ b/internal/agentcapabilities/projection_pathparams_test.go
@@ -0,0 +1,227 @@
+package agentcapabilities
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestSubstitutePathParametersTable exercises every branch of
+// SubstitutePathParameters: the no-placeholder passthrough, the happy
+// substitution path (single, multiple, and repeated placeholders), the
+// missing-argument error path, the non-string-argument error path, and the
+// combined missing + non-string collection path (whose order is deterministic
+// because ReplaceAllStringFunc walks placeholder matches left-to-right rather
+// than iterating the args map). It also pins regex edge cases (placeholder
+// names that the {a-zA-Z][a-zA-Z0-9]*} grammar rejects are left literal) and
+// the end-to-end percent-escaping behavior surfaced through
+// EscapePathSegmentParameter.
+func TestSubstitutePathParametersTable(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ args map[string]any
+ wantPath string
+ wantErr bool
+ errSubstr string
+ }{
+ {
+ name: "no placeholders returns path unchanged",
+ path: "/api/agent/patrol-control/status",
+ args: map[string]any{"resourceId": "ignored"},
+ wantPath: "/api/agent/patrol-control/status",
+ },
+ {
+ name: "empty path returns empty string with no error",
+ path: "",
+ args: nil,
+ wantPath: "",
+ },
+ {
+ name: "nil args with no placeholders returns path unchanged",
+ path: "/static/route",
+ args: nil,
+ wantPath: "/static/route",
+ },
+ {
+ name: "nil args map with placeholder reported missing",
+ path: "/api/resources/{resourceId}",
+ args: nil,
+ wantErr: true,
+ errSubstr: "missing path argument(s): resourceId",
+ },
+ {
+ name: "empty args map with placeholder reported missing",
+ path: "/api/resources/{resourceId}/operator-state",
+ args: map[string]any{},
+ wantErr: true,
+ errSubstr: "missing path argument(s): resourceId",
+ },
+ {
+ name: "single placeholder substituted and escaped",
+ path: "/api/resources/{resourceId}/operator-state",
+ args: map[string]any{"resourceId": "vm:101"},
+ wantPath: "/api/resources/vm%3A101/operator-state",
+ },
+ {
+ name: "multiple placeholders substituted in match order",
+ path: "/api/config/nodes/{nodeId}/test/{resourceId}",
+ args: map[string]any{"nodeId": "pve/lab node", "resourceId": "vm:101"},
+ wantPath: "/api/config/nodes/pve%2Flab%20node/test/vm%3A101",
+ },
+ {
+ name: "repeated placeholder substituted at every occurrence",
+ path: "/{a}/and/{a}",
+ args: map[string]any{"a": "x:y"},
+ wantPath: "/x%3Ay/and/x%3Ay",
+ },
+ {
+ name: "integer arg reported as not a string",
+ path: "/api/actions/{actionId}/decision",
+ args: map[string]any{"actionId": 42},
+ wantErr: true,
+ errSubstr: "actionId (not a string)",
+ },
+ {
+ name: "bool arg reported as not a string",
+ path: "/api/actions/{actionId}/decision",
+ args: map[string]any{"actionId": true},
+ wantErr: true,
+ errSubstr: "actionId (not a string)",
+ },
+ {
+ name: "map arg reported as not a string",
+ path: "/api/actions/{actionId}/decision",
+ args: map[string]any{"actionId": map[string]any{"nested": 1}},
+ wantErr: true,
+ errSubstr: "actionId (not a string)",
+ },
+ {
+ name: "slice arg reported as not a string",
+ path: "/api/actions/{actionId}/decision",
+ args: map[string]any{"actionId": []string{"a", "b"}},
+ wantErr: true,
+ errSubstr: "actionId (not a string)",
+ },
+ {
+ name: "explicit nil arg value reported as not a string",
+ path: "/api/actions/{actionId}/decision",
+ args: map[string]any{"actionId": nil},
+ wantErr: true,
+ errSubstr: "actionId (not a string)",
+ },
+ {
+ name: "mixed missing and non-string collected in placeholder order",
+ path: "/{first}/{second}/{third}",
+ args: map[string]any{"second": 42},
+ wantErr: true,
+ errSubstr: "missing path argument(s): first, second (not a string), third",
+ },
+ {
+ name: "empty string value is a valid segment",
+ path: "/api/{a}/end",
+ args: map[string]any{"a": ""},
+ wantPath: "/api//end",
+ },
+ {
+ name: "extra unknown string args are ignored",
+ path: "/api/{a}",
+ args: map[string]any{"a": "x", "extra": "y"},
+ wantPath: "/api/x",
+ },
+ {
+ name: "extra non-string arg unreferenced by path is ignored",
+ path: "/api/{a}",
+ args: map[string]any{"a": "x", "ignored": 42},
+ wantPath: "/api/x",
+ },
+ {
+ name: "placeholder name with leading underscore is not matched stays literal",
+ path: "/api/{_name}",
+ args: map[string]any{"_name": "x"},
+ wantPath: "/api/{_name}",
+ },
+ {
+ name: "placeholder name containing dash is not matched stays literal",
+ path: "/api/{action-id}",
+ args: map[string]any{"action-id": "x"},
+ wantPath: "/api/{action-id}",
+ },
+ {
+ name: "placeholder name containing underscore is not matched stays literal",
+ path: "/api/{a_b}",
+ args: map[string]any{"a_b": "x"},
+ wantPath: "/api/{a_b}",
+ },
+ {
+ name: "placeholder name with trailing digits is matched",
+ path: "/api/{node1}",
+ args: map[string]any{"node1": "a/b"},
+ wantPath: "/api/a%2Fb",
+ },
+ {
+ name: "value of only unreserved bytes stays literal",
+ path: "/{a}",
+ args: map[string]any{"a": "Aa0-._~"},
+ wantPath: "/Aa0-._~",
+ },
+ {
+ name: "multibyte value escapes each UTF-8 byte as uppercase hex",
+ path: "/{a}",
+ args: map[string]any{"a": "é"},
+ wantPath: "/%C3%A9",
+ },
+ {
+ name: "control byte tab escaped as percent hex",
+ path: "/{a}",
+ args: map[string]any{"a": "a\tb"},
+ wantPath: "/a%09b",
+ },
+ {
+ name: "slash space and colon all escaped in a single value",
+ path: "/{a}",
+ args: map[string]any{"a": "a b/c:d"},
+ wantPath: "/a%20b%2Fc%3Ad",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ gotPath, err := SubstitutePathParameters(tc.path, tc.args)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("SubstitutePathParameters err = nil, want non-nil; path=%q args=%#v got=%q", tc.path, tc.args, gotPath)
+ }
+ if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) {
+ t.Fatalf("SubstitutePathParameters err = %q, want substring %q", err.Error(), tc.errSubstr)
+ }
+ if gotPath != "" {
+ t.Fatalf("SubstitutePathParameters path = %q on error, want empty string", gotPath)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("SubstitutePathParameters err = %v, want nil; path=%q args=%#v", err, tc.path, tc.args)
+ }
+ if gotPath != tc.wantPath {
+ t.Fatalf("SubstitutePathParameters path = %q, want %q", gotPath, tc.wantPath)
+ }
+ })
+ }
+}
+
+// TestSubstitutePathParametersErrorIsNonTyped confirms the error returned by
+// SubstitutePathParameters is a plain fmt.Errorf-wrapped string (not a typed
+// sentinel like CapabilityLookupError), pinning the error-shape contract that
+// adapters rely on for path-argument validation.
+func TestSubstitutePathParametersErrorIsNonTyped(t *testing.T) {
+ _, err := SubstitutePathParameters("/{missing}", map[string]any{})
+ if err == nil {
+ t.Fatal("SubstitutePathParameters err = nil, want non-nil for missing argument")
+ }
+ if _, ok := err.(CapabilityLookupError); ok {
+ t.Fatalf("SubstitutePathParameters must not return CapabilityLookupError, got %T: %v", err, err)
+ }
+ if !strings.HasPrefix(err.Error(), "missing path argument(s):") {
+ t.Fatalf("SubstitutePathParameters err = %q, want 'missing path argument(s):' prefix", err.Error())
+ }
+}
diff --git a/internal/ai/chat/service_investigation_error_test.go b/internal/ai/chat/service_investigation_error_test.go
new file mode 100644
index 000000000..ee4c58832
--- /dev/null
+++ b/internal/ai/chat/service_investigation_error_test.go
@@ -0,0 +1,379 @@
+package chat
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
+
+// chatinvesterrorCustom is a concrete error type used to exercise
+// errors.As across both failure channels of InvestigationRunError.
+type chatinvesterrorCustom struct{ msg string }
+
+func (e *chatinvesterrorCustom) Error() string { return e.msg }
+
+// chatinvesterrorWrap is a second distinct concrete type so that an
+// errors.As match on one channel does not accidentally match the other.
+type chatinvesterrorWrap struct{ inner error }
+
+func (e *chatinvesterrorWrap) Error() string { return "wrap:" + e.inner.Error() }
+
+func (e *chatinvesterrorWrap) Unwrap() error { return e.inner }
+
+func TestNewInvestigationRunError(t *testing.T) {
+ runErr := errors.New("runtime exploded")
+ proposalErr := errors.New("proposal invalid")
+
+ cases := []struct {
+ name string
+ runErr error
+ proposalErr error
+ wantNil bool
+ wantRunFailure error
+ wantPropFail error
+ }{
+ {
+ name: "both nil returns nil",
+ runErr: nil,
+ proposalErr: nil,
+ wantNil: true,
+ wantRunFailure: nil,
+ wantPropFail: nil,
+ },
+ {
+ name: "run only populates run channel",
+ runErr: runErr,
+ proposalErr: nil,
+ wantNil: false,
+ wantRunFailure: runErr,
+ wantPropFail: nil,
+ },
+ {
+ name: "proposal only populates proposal channel",
+ runErr: nil,
+ proposalErr: proposalErr,
+ wantNil: false,
+ wantRunFailure: nil,
+ wantPropFail: proposalErr,
+ },
+ {
+ name: "both populated preserves both channels",
+ runErr: runErr,
+ proposalErr: proposalErr,
+ wantNil: false,
+ wantRunFailure: runErr,
+ wantPropFail: proposalErr,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := NewInvestigationRunError(tc.runErr, tc.proposalErr)
+ if tc.wantNil {
+ if got != nil {
+ t.Fatalf("expected nil error, got %+v", got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatalf("expected non-nil error, got nil")
+ }
+ if run := got.RunFailure(); run != tc.wantRunFailure {
+ t.Errorf("RunFailure() = %v, want %v", run, tc.wantRunFailure)
+ }
+ if prop := got.ProposalFailure(); prop != tc.wantPropFail {
+ t.Errorf("ProposalFailure() = %v, want %v", prop, tc.wantPropFail)
+ }
+ })
+ }
+}
+
+func TestInvestigationRunError_Error(t *testing.T) {
+ runErr := errors.New("run failed")
+ proposalErr := errors.New("proposal failed")
+
+ cases := []struct {
+ name string
+ err *InvestigationRunError
+ wantStr string
+ }{
+ {
+ name: "nil receiver returns empty string",
+ err: nil,
+ wantStr: "",
+ },
+ {
+ name: "run channel only renders run message",
+ err: NewInvestigationRunError(runErr, nil),
+ wantStr: "run failed",
+ },
+ {
+ name: "proposal channel only renders proposal message",
+ err: NewInvestigationRunError(nil, proposalErr),
+ wantStr: "proposal failed",
+ },
+ {
+ name: "both channels join with newline",
+ err: NewInvestigationRunError(runErr, proposalErr),
+ wantStr: "run failed\nproposal failed",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := tc.err.Error()
+ if got != tc.wantStr {
+ t.Errorf("Error() = %q, want %q", got, tc.wantStr)
+ }
+ })
+ }
+}
+
+func TestInvestigationRunError_Unwrap(t *testing.T) {
+ runSentinel := errors.New("sentinel-run")
+ proposalSentinel := errors.New("sentinel-proposal")
+ runTyped := &chatinvesterrorCustom{msg: "typed-run"}
+ proposalTyped := &chatinvesterrorWrap{inner: errors.New("inner-proposal")}
+
+ t.Run("nil receiver returns nil slice", func(t *testing.T) {
+ var err *InvestigationRunError
+ if got := err.Unwrap(); got != nil {
+ t.Fatalf("nil receiver Unwrap() = %v, want nil", got)
+ }
+ })
+
+ t.Run("both channels surface in slice preserving order and nil entries", func(t *testing.T) {
+ err := NewInvestigationRunError(runSentinel, proposalSentinel)
+ got := err.Unwrap()
+ if len(got) != 2 {
+ t.Fatalf("Unwrap() length = %d, want 2", len(got))
+ }
+ if got[0] != runSentinel {
+ t.Errorf("Unwrap()[0] = %v, want %v", got[0], runSentinel)
+ }
+ if got[1] != proposalSentinel {
+ t.Errorf("Unwrap()[1] = %v, want %v", got[1], proposalSentinel)
+ }
+ })
+
+ t.Run("run-only slice keeps proposal slot nil", func(t *testing.T) {
+ err := NewInvestigationRunError(runSentinel, nil)
+ got := err.Unwrap()
+ if len(got) != 2 {
+ t.Fatalf("Unwrap() length = %d, want 2", len(got))
+ }
+ if got[0] != runSentinel {
+ t.Errorf("Unwrap()[0] = %v, want %v", got[0], runSentinel)
+ }
+ if got[1] != nil {
+ t.Errorf("Unwrap()[1] = %v, want nil", got[1])
+ }
+ })
+
+ t.Run("proposal-only slice keeps run slot nil", func(t *testing.T) {
+ err := NewInvestigationRunError(nil, proposalSentinel)
+ got := err.Unwrap()
+ if len(got) != 2 {
+ t.Fatalf("Unwrap() length = %d, want 2", len(got))
+ }
+ if got[0] != nil {
+ t.Errorf("Unwrap()[0] = %v, want nil", got[0])
+ }
+ if got[1] != proposalSentinel {
+ t.Errorf("Unwrap()[1] = %v, want %v", got[1], proposalSentinel)
+ }
+ })
+
+ t.Run("errors.Is matches run sentinel", func(t *testing.T) {
+ err := NewInvestigationRunError(runSentinel, proposalSentinel)
+ if !errors.Is(err, runSentinel) {
+ t.Errorf("errors.Is(err, runSentinel) = false, want true")
+ }
+ })
+
+ t.Run("errors.Is matches proposal sentinel", func(t *testing.T) {
+ err := NewInvestigationRunError(runSentinel, proposalSentinel)
+ if !errors.Is(err, proposalSentinel) {
+ t.Errorf("errors.Is(err, proposalSentinel) = false, want true")
+ }
+ })
+
+ t.Run("errors.Is false for unrelated error", func(t *testing.T) {
+ err := NewInvestigationRunError(runSentinel, proposalSentinel)
+ other := errors.New("unrelated")
+ if errors.Is(err, other) {
+ t.Errorf("errors.Is(err, unrelated) = true, want false")
+ }
+ })
+
+ t.Run("errors.Is walks wrapped inner chain via proposal channel", func(t *testing.T) {
+ inner := errors.New("inner-proposal")
+ err := NewInvestigationRunError(nil, &chatinvesterrorWrap{inner: inner})
+ if !errors.Is(err, inner) {
+ t.Errorf("errors.Is(err, inner) = false, want true")
+ }
+ })
+
+ t.Run("errors.As extracts run channel typed error", func(t *testing.T) {
+ err := NewInvestigationRunError(runTyped, nil)
+ var target *chatinvesterrorCustom
+ if !errors.As(err, &target) {
+ t.Fatalf("errors.As for run typed error = false, want true")
+ }
+ if target != runTyped {
+ t.Errorf("errors.As target = %p, want %p", target, runTyped)
+ }
+ })
+
+ t.Run("errors.As extracts proposal channel typed error", func(t *testing.T) {
+ err := NewInvestigationRunError(nil, proposalTyped)
+ var target *chatinvesterrorWrap
+ if !errors.As(err, &target) {
+ t.Fatalf("errors.As for proposal typed error = false, want true")
+ }
+ if target != proposalTyped {
+ t.Errorf("errors.As target = %p, want %p", target, proposalTyped)
+ }
+ })
+
+ t.Run("errors.As does not match unrelated type", func(t *testing.T) {
+ err := NewInvestigationRunError(runTyped, proposalTyped)
+ var target *chatinvesterrorCustom
+ _ = target
+ var wrap *chatinvesterrorWrap
+ if !errors.As(err, &wrap) {
+ t.Errorf("errors.As for wrap type = false, want true")
+ }
+ })
+}
+
+func TestInvestigationRunError_RunFailure(t *testing.T) {
+ runErr := errors.New("runtime failure")
+
+ cases := []struct {
+ name string
+ err *InvestigationRunError
+ want error
+ }{
+ {
+ name: "nil receiver returns nil",
+ err: nil,
+ want: nil,
+ },
+ {
+ name: "run set returns run error",
+ err: NewInvestigationRunError(runErr, nil),
+ want: runErr,
+ },
+ {
+ name: "only proposal set returns nil for run channel",
+ err: NewInvestigationRunError(nil, errors.New("proposal")),
+ want: nil,
+ },
+ {
+ name: "both set returns run error only",
+ err: NewInvestigationRunError(runErr, errors.New("proposal")),
+ want: runErr,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := tc.err.RunFailure()
+ if got != tc.want {
+ t.Errorf("RunFailure() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestInvestigationRunError_ProposalFailure(t *testing.T) {
+ proposalErr := errors.New("proposal failure")
+
+ cases := []struct {
+ name string
+ err *InvestigationRunError
+ want error
+ }{
+ {
+ name: "nil receiver returns nil",
+ err: nil,
+ want: nil,
+ },
+ {
+ name: "proposal set returns proposal error",
+ err: NewInvestigationRunError(nil, proposalErr),
+ want: proposalErr,
+ },
+ {
+ name: "only run set returns nil for proposal channel",
+ err: NewInvestigationRunError(errors.New("run"), nil),
+ want: nil,
+ },
+ {
+ name: "both set returns proposal error only",
+ err: NewInvestigationRunError(errors.New("run"), proposalErr),
+ want: proposalErr,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := tc.err.ProposalFailure()
+ if got != tc.want {
+ t.Errorf("ProposalFailure() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestInvestigationRunError_NilReceiverChainedMethodSafety(t *testing.T) {
+ // A typed-nil *InvestigationRunError must be safe to call every
+ // method on without panicking; this guards the nil-receiver arms of
+ // Error, Unwrap, RunFailure and ProposalFailure.
+ var err *InvestigationRunError
+ ensure := func(name string, fn func()) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("%s panicked on nil receiver: %v", name, r)
+ }
+ }()
+ fn()
+ }
+ ensure("Error", func() {
+ if got := err.Error(); got != "" {
+ t.Errorf("nil Error() = %q, want %q", got, "")
+ }
+ })
+ ensure("Unwrap", func() {
+ if got := err.Unwrap(); got != nil {
+ t.Errorf("nil Unwrap() = %v, want nil", got)
+ }
+ })
+ ensure("RunFailure", func() {
+ if got := err.RunFailure(); got != nil {
+ t.Errorf("nil RunFailure() = %v, want nil", got)
+ }
+ })
+ ensure("ProposalFailure", func() {
+ if got := err.ProposalFailure(); got != nil {
+ t.Errorf("nil ProposalFailure() = %v, want nil", got)
+ }
+ })
+}
+
+func TestInvestigationRunError_FmtFormatting(t *testing.T) {
+ // Guards the implicit fmt-handling: InvestigationRunError has an
+ // Error() method, so %s/%v render its joined message and %v on the
+ // value (not pointer) still resolves through Error().
+ runErr := errors.New("fmt-run")
+ proposalErr := fmt.Errorf("fmt-proposal-%d", 7)
+ err := NewInvestigationRunError(runErr, proposalErr)
+ wantStr := "fmt-run\nfmt-proposal-7"
+ if got := fmt.Sprintf("%v", err); got != wantStr {
+ t.Errorf("fmt %%v = %q, want %q", got, wantStr)
+ }
+ if got := fmt.Sprintf("%s", err); got != wantStr {
+ t.Errorf("fmt %%s = %q, want %q", got, wantStr)
+ }
+}
diff --git a/internal/ai/chat/session_compaction_format_test.go b/internal/ai/chat/session_compaction_format_test.go
new file mode 100644
index 000000000..ee8448cee
--- /dev/null
+++ b/internal/ai/chat/session_compaction_format_test.go
@@ -0,0 +1,187 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestFormatSessionCompactionToolCall exercises every branch of
+// formatSessionCompactionToolCall: empty/nil input maps, the JSON marshal
+// success and failure paths, secret redaction on both Input and Output, name
+// trimming, output sanitization (including whitespace-only and emoji-stripped
+// outputs), and the tool-output truncation boundary at
+// sessionCompactionToolOutputMaxChars.
+func TestFormatSessionCompactionToolCall(t *testing.T) {
+ const toolOutputBudget = sessionCompactionToolOutputMaxChars // 1200
+ longOutput := strings.Repeat("a", toolOutputBudget+100)
+ boundaryOutput := strings.Repeat("a", toolOutputBudget)
+ truncatedTail := "\n[truncated]"
+
+ cases := []struct {
+ name string
+ toolCall ToolCall
+ want string
+ }{
+ {
+ name: "zero value renders empty name and default input object literal",
+ toolCall: ToolCall{},
+ want: "Tool call: {}",
+ },
+ {
+ name: "name is trimmed and default input object literal is kept",
+ toolCall: ToolCall{
+ Name: " list_pods ",
+ },
+ want: "Tool call: list_pods {}",
+ },
+ {
+ name: "nil input leaves default object literal",
+ toolCall: ToolCall{
+ Name: "ping",
+ Input: nil,
+ },
+ want: "Tool call: ping {}",
+ },
+ {
+ name: "empty input map leaves default object literal",
+ toolCall: ToolCall{
+ Name: "ping",
+ Input: map[string]interface{}{},
+ },
+ want: "Tool call: ping {}",
+ },
+ {
+ name: "single key input is marshaled to compact json",
+ toolCall: ToolCall{
+ Name: "read_file",
+ Input: map[string]interface{}{"path": "/etc/hosts"},
+ },
+ want: `Tool call: read_file {"path":"/etc/hosts"}`,
+ },
+ {
+ name: "multiple input keys are emitted in sorted json key order",
+ toolCall: ToolCall{
+ Name: "provision",
+ Input: map[string]interface{}{
+ "zebra": "1",
+ "apple": "2",
+ "mango": "3",
+ },
+ },
+ want: `Tool call: provision {"apple":"2","mango":"3","zebra":"1"}`,
+ },
+ {
+ name: "secret bearing input value is redacted after marshal",
+ toolCall: ToolCall{
+ Name: "login",
+ Input: map[string]interface{}{
+ "api_key": "sk-live-secret-value",
+ },
+ },
+ want: `Tool call: login {"api_key":"[REDACTED]"}`,
+ },
+ {
+ name: "input that fails to marshal falls back to default object literal",
+ toolCall: ToolCall{
+ Name: "broken",
+ Input: map[string]interface{}{"bad": make(chan int)},
+ },
+ want: "Tool call: broken {}",
+ },
+ {
+ name: "output only is sanitized and rendered on its own line",
+ toolCall: ToolCall{
+ Output: "pod-1234 running",
+ },
+ want: "Tool call: {}\nTool output: pod-1234 running",
+ },
+ {
+ name: "whitespace only output produces no output line",
+ toolCall: ToolCall{
+ Name: "noop",
+ Output: " \n\t ",
+ },
+ want: "Tool call: noop {}",
+ },
+ {
+ name: "empty output produces no output line",
+ toolCall: ToolCall{
+ Name: "noop",
+ Output: "",
+ },
+ want: "Tool call: noop {}",
+ },
+ {
+ name: "secret bearing output value is redacted",
+ toolCall: ToolCall{
+ Name: "fetch",
+ Output: "api_key: sk-live-secret-value",
+ },
+ want: "Tool call: fetch {}\nTool output: api_key: [REDACTED]",
+ },
+ {
+ name: "decorative emoji prefix in output is stripped by sanitization",
+ toolCall: ToolCall{
+ Output: "🟢 All good",
+ },
+ want: "Tool call: {}\nTool output: All good",
+ },
+ {
+ name: "output surrounding whitespace is trimmed",
+ toolCall: ToolCall{
+ Output: "\n health: ok \n",
+ },
+ want: "Tool call: {}\nTool output: health: ok",
+ },
+ {
+ name: "output exceeding the char budget is truncated",
+ toolCall: ToolCall{
+ Output: longOutput,
+ },
+ want: "Tool call: {}\nTool output: " + boundaryOutput + truncatedTail,
+ },
+ {
+ name: "output exactly at the char budget is not truncated",
+ toolCall: ToolCall{
+ Output: boundaryOutput,
+ },
+ want: "Tool call: {}\nTool output: " + boundaryOutput,
+ },
+ {
+ name: "name input and output are all rendered together",
+ toolCall: ToolCall{
+ Name: "run_check",
+ Input: map[string]interface{}{"host": "vault.lan"},
+ Output: "ok",
+ },
+ want: `Tool call: run_check {"host":"vault.lan"}` + "\nTool output: ok",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := formatSessionCompactionToolCall(tc.toolCall)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}
+
+// TestFormatSessionCompactionToolCallDeterminism asserts that repeated calls
+// with the same input produce byte-identical output (json.Marshal key sorting
+// and redaction are stable).
+func TestFormatSessionCompactionToolCallDeterminism(t *testing.T) {
+ toolCall := ToolCall{
+ Name: "deploy",
+ Input: map[string]interface{}{"image": "nginx:1.25", "replicas": 3},
+ Output: "deployments/apps Deployment created",
+ }
+ first := formatSessionCompactionToolCall(toolCall)
+ for i := 0; i < 10; i++ {
+ require.Equal(t, first, formatSessionCompactionToolCall(toolCall),
+ "iteration %d produced non-deterministic output", i)
+ }
+ want := `Tool call: deploy {"image":"nginx:1.25","replicas":3}` + "\nTool output: deployments/apps Deployment created"
+ require.Equal(t, want, first)
+}
diff --git a/internal/ai/patrol_findings_json_test.go b/internal/ai/patrol_findings_json_test.go
new file mode 100644
index 000000000..7f163b1ac
--- /dev/null
+++ b/internal/ai/patrol_findings_json_test.go
@@ -0,0 +1,214 @@
+package ai
+
+import "testing"
+
+// TestIsValidJSON exercises every branch of isValidJSON:
+// - the empty-after-trim arm (returns false),
+// - the fast-reject arm where the first rune is neither '{' nor '[' (returns false),
+// - the json.Unmarshal success arm (returns true),
+// - the json.Unmarshal failure arm for inputs that pass the prefix check but
+// are not well-formed JSON (returns false).
+//
+// It also pins the deliberate (but perhaps surprising) scalar-rejection
+// behavior: top-level JSON scalars such as "123", "true", "null" and "\"s\""
+// are syntactically valid JSON, yet isValidJSON returns false for them because
+// the fast-reject only admits object/array roots. See GLM_REPORT.md.
+func TestIsValidJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want bool
+ }{
+ // --- empty-after-trim branch (trimmed == "") ---
+ {
+ name: "empty string yields false",
+ in: "",
+ want: false,
+ },
+ {
+ name: "whitespace only yields false",
+ in: " ",
+ want: false,
+ },
+ {
+ name: "tab only yields false",
+ in: "\t",
+ want: false,
+ },
+ {
+ name: "newline only yields false",
+ in: "\n",
+ want: false,
+ },
+ {
+ name: "mixed whitespace only yields false",
+ in: " \t\n\r ",
+ want: false,
+ },
+
+ // --- fast-reject branch: first rune is neither '{' nor '[' ---
+ {
+ name: "plain text rejected by prefix",
+ in: "hello world",
+ want: false,
+ },
+ {
+ name: "json scalar number rejected by prefix even though valid JSON",
+ in: "123",
+ want: false,
+ },
+ {
+ name: "json scalar true rejected by prefix even though valid JSON",
+ in: "true",
+ want: false,
+ },
+ {
+ name: "json scalar false rejected by prefix even though valid JSON",
+ in: "false",
+ want: false,
+ },
+ {
+ name: "json scalar null rejected by prefix even though valid JSON",
+ in: "null",
+ want: false,
+ },
+ {
+ name: "json scalar string rejected by prefix even though valid JSON",
+ in: `"a string"`,
+ want: false,
+ },
+ {
+ name: "xml-looking content rejected by prefix",
+ in: "not json",
+ want: false,
+ },
+ {
+ name: "leading paren rejected by prefix",
+ in: "(not json)",
+ want: false,
+ },
+ {
+ name: "whitespace then non-json prefix still rejected",
+ in: " not-json-at-all",
+ want: false,
+ },
+
+ // --- json.Unmarshal success branch (valid object/array roots) ---
+ {
+ name: "empty object is valid",
+ in: "{}",
+ want: true,
+ },
+ {
+ name: "empty array is valid",
+ in: "[]",
+ want: true,
+ },
+ {
+ name: "simple object is valid",
+ in: `{"key": "value"}`,
+ want: true,
+ },
+ {
+ name: "simple array is valid",
+ in: "[1, 2, 3]",
+ want: true,
+ },
+ {
+ name: "nested object is valid",
+ in: `{"a": {"b": {"c": [1, 2, {"d": true}]}}}`,
+ want: true,
+ },
+ {
+ name: "object with assorted scalar types is valid",
+ in: `{"n": 42, "s": "hi", "b": true, "z": null, "arr": [1, 2]}`,
+ want: true,
+ },
+ {
+ name: "unicode in object values is valid",
+ in: `{"msg": "héllo wörld 日本語"}`,
+ want: true,
+ },
+ {
+ name: "leading whitespace before object is trimmed and valid",
+ in: " {\"k\": 1}",
+ want: true,
+ },
+ {
+ name: "trailing whitespace after object is trimmed and valid",
+ in: "{\"k\": 1}\n\n",
+ want: true,
+ },
+ {
+ name: "leading and trailing whitespace with newlines and tabs trimmed and valid",
+ in: "\n\t {\"k\": 1}\t\n",
+ want: true,
+ },
+ {
+ name: "leading whitespace before array is trimmed and valid",
+ in: "\t[1, 2]",
+ want: true,
+ },
+ {
+ name: "array of objects is valid",
+ in: `[{"id": 1}, {"id": 2}]`,
+ want: true,
+ },
+
+ // --- json.Unmarshal failure branch: prefix ok but body malformed ---
+ {
+ name: "lone open brace fails unmarshal",
+ in: "{",
+ want: false,
+ },
+ {
+ name: "lone open bracket fails unmarshal",
+ in: "[",
+ want: false,
+ },
+ {
+ name: "object missing closing brace fails unmarshal",
+ in: `{"k": "v"`,
+ want: false,
+ },
+ {
+ name: "array missing closing bracket fails unmarshal",
+ in: `[1, 2, 3`,
+ want: false,
+ },
+ {
+ name: "object with trailing comma fails unmarshal",
+ in: `{"a": 1,}`,
+ want: false,
+ },
+ {
+ name: "unquoted key fails unmarshal",
+ in: `{key: "value"}`,
+ want: false,
+ },
+ {
+ name: "single quotes fail unmarshal",
+ in: `{'key': 'value'}`,
+ want: false,
+ },
+ {
+ name: "trailing garbage after valid object fails unmarshal",
+ in: `{"a": 1} garbage`,
+ want: false,
+ },
+ {
+ name: "leading whitespace then malformed object fails unmarshal",
+ in: " {bad}",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isValidJSON(tt.in)
+ if got != tt.want {
+ t.Errorf("isValidJSON(%q) = %v, want %v", tt.in, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/ai/patrol_run_recency_test.go b/internal/ai/patrol_run_recency_test.go
new file mode 100644
index 000000000..cb4fb2186
--- /dev/null
+++ b/internal/ai/patrol_run_recency_test.go
@@ -0,0 +1,179 @@
+package ai
+
+import (
+ "testing"
+ "time"
+)
+
+// patrolrecencyRun builds a PatrolRunRecord with only the fields that
+// patrolRecencyFromHistory inspects (CompletedAt and Type) populated, so table
+// cases stay readable. The patrolrecency prefix avoids collisions with helpers
+// defined in sibling test files in this package.
+func patrolrecencyRun(runType string, completedAt time.Time) PatrolRunRecord {
+ return PatrolRunRecord{
+ ID: "rec-" + runType,
+ Type: runType,
+ CompletedAt: completedAt,
+ }
+}
+
+// TestPatrolRecencyFromHistory exercises patrolRecencyFromHistory across every
+// branch of its input space: nil/empty history, zero CompletedAt records (the
+// skip branch), each arm of the isFullPatrolRun switch (via normalizePatrolRun
+// Type: "", "full", "patrol", case/whitespace normalization, and the default
+// fallthrough for scoped/verification/unknown types), and the latest-timestamp
+// selection for both lastActivity (any run) and lastFullPatrol (full runs only).
+//
+// Note: patrolRecencyFromHistory uses isFullPatrolRun (not
+// isSuccessfulFullPatrolRun), so a full patrol that finished with errors still
+// counts toward lastFullPatrol. This is asserted below and called out in
+// GLM_REPORT.md as a behavioral observation (it differs from
+// shouldSkipInitialFullPatrol, which does gate on success).
+func TestPatrolRecencyFromHistory(t *testing.T) {
+ t1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
+ t2 := t1.Add(1 * time.Hour)
+ t3 := t1.Add(2 * time.Hour)
+ zero := time.Time{}
+
+ tests := []struct {
+ name string
+ history []PatrolRunRecord
+ wantActivity time.Time
+ wantActivityZero bool
+ wantFullPatrol time.Time
+ wantFullPatrolZero bool
+ }{
+ {
+ name: "nil history returns zero times",
+ history: nil,
+ wantActivityZero: true,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "empty history returns zero times",
+ history: []PatrolRunRecord{},
+ wantActivityZero: true,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "all zero CompletedAt records are skipped",
+ history: []PatrolRunRecord{patrolrecencyRun("patrol", zero), patrolrecencyRun("scoped", zero)},
+ wantActivityZero: true,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "single patrol-type full run populates both",
+ history: []PatrolRunRecord{patrolrecencyRun("patrol", t1)},
+ wantActivity: t1,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "empty Type falls through to full patrol arm",
+ history: []PatrolRunRecord{patrolrecencyRun("", t1)},
+ wantActivity: t1,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "full Type matches full patrol arm",
+ history: []PatrolRunRecord{patrolrecencyRun("full", t1)},
+ wantActivity: t1,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "case-insensitive and whitespace-trimmed Type normalizes to full patrol",
+ history: []PatrolRunRecord{patrolrecencyRun(" PaTrOl ", t1)},
+ wantActivity: t1,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "scoped run populates lastActivity only",
+ history: []PatrolRunRecord{patrolrecencyRun("scoped", t1)},
+ wantActivity: t1,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "verification run populates lastActivity only",
+ history: []PatrolRunRecord{patrolrecencyRun("verification", t1)},
+ wantActivity: t1,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "unknown run Type hits default arm and populates lastActivity only",
+ history: []PatrolRunRecord{patrolrecencyRun("custom-type", t1)},
+ wantActivity: t1,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "mixed scoped older and full newer picks the full run for both timestamps",
+ history: []PatrolRunRecord{patrolrecencyRun("scoped", t1), patrolrecencyRun("patrol", t2)},
+ wantActivity: t2,
+ wantFullPatrol: t2,
+ },
+ {
+ name: "mixed full older and scoped newer splits lastActivity and lastFullPatrol",
+ history: []PatrolRunRecord{patrolrecencyRun("patrol", t1), patrolrecencyRun("scoped", t2)},
+ wantActivity: t2,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "multiple full patrols selects the latest CompletedAt",
+ history: []PatrolRunRecord{patrolrecencyRun("patrol", t1), patrolrecencyRun("patrol", t3), patrolrecencyRun("patrol", t2)},
+ wantActivity: t3,
+ wantFullPatrol: t3,
+ },
+ {
+ name: "unsorted mixed-type history selects correct latest for each timestamp",
+ history: []PatrolRunRecord{patrolrecencyRun("scoped", t3), patrolrecencyRun("patrol", t1), patrolrecencyRun("scoped", t2)},
+ wantActivity: t3,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "zero CompletedAt mixed with a valid scoped record leaves lastFullPatrol zero",
+ history: []PatrolRunRecord{patrolrecencyRun("patrol", zero), patrolrecencyRun("scoped", t1), patrolrecencyRun("patrol", zero)},
+ wantActivity: t1,
+ wantFullPatrolZero: true,
+ },
+ {
+ name: "equal CompletedAt values resolve to that timestamp (After is strict, first-seen wins)",
+ history: []PatrolRunRecord{patrolrecencyRun("patrol", t1), patrolrecencyRun("patrol", t1)},
+ wantActivity: t1,
+ wantFullPatrol: t1,
+ },
+ {
+ name: "full patrol with errors still counts toward lastFullPatrol",
+ history: []PatrolRunRecord{
+ {
+ ID: "errored-full",
+ Type: "patrol",
+ CompletedAt: t1,
+ ErrorCount: 2,
+ Status: "error",
+ },
+ },
+ wantActivity: t1,
+ wantFullPatrol: t1,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ gotActivity, gotFullPatrol := patrolRecencyFromHistory(tc.history)
+
+ if tc.wantActivityZero {
+ if !gotActivity.IsZero() {
+ t.Fatalf("lastActivity: expected zero time, got %v", gotActivity)
+ }
+ } else if !gotActivity.Equal(tc.wantActivity) {
+ t.Fatalf("lastActivity: expected %v, got %v", tc.wantActivity, gotActivity)
+ }
+
+ if tc.wantFullPatrolZero {
+ if !gotFullPatrol.IsZero() {
+ t.Fatalf("lastFullPatrol: expected zero time, got %v", gotFullPatrol)
+ }
+ } else if !gotFullPatrol.Equal(tc.wantFullPatrol) {
+ t.Fatalf("lastFullPatrol: expected %v, got %v", tc.wantFullPatrol, gotFullPatrol)
+ }
+ })
+ }
+}
diff --git a/internal/ai/qualification/lab_predicate_test.go b/internal/ai/qualification/lab_predicate_test.go
new file mode 100644
index 000000000..d884fd69f
--- /dev/null
+++ b/internal/ai/qualification/lab_predicate_test.go
@@ -0,0 +1,280 @@
+package qualification
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestComparePredicate(t *testing.T) {
+ cases := []struct {
+ name string
+ observed any
+ operator string
+ raw json.RawMessage
+ wantBool bool
+ wantErr bool
+ errSubstr string
+ }{
+ // len(raw) == 0 arm: required-value error.
+ {
+ name: "empty raw returns required error",
+ observed: "anything",
+ operator: "eq",
+ raw: json.RawMessage(""),
+ wantErr: true,
+ errSubstr: "predicate value is required",
+ },
+ {
+ name: "nil raw returns required error",
+ observed: 5,
+ operator: "eq",
+ raw: nil,
+ wantErr: true,
+ errSubstr: "predicate value is required",
+ },
+ // json.Unmarshal failure arm.
+ {
+ name: "invalid json returns unmarshal error",
+ observed: 1,
+ operator: "eq",
+ raw: json.RawMessage("{bad"),
+ wantErr: true,
+ },
+ // eq operator: fmt.Sprint equality.
+ {
+ name: "eq matching strings true",
+ observed: "running",
+ operator: "eq",
+ raw: json.RawMessage(`"running"`),
+ wantBool: true,
+ },
+ {
+ name: "eq mismatching strings false",
+ observed: "running",
+ operator: "eq",
+ raw: json.RawMessage(`"stopped"`),
+ wantBool: false,
+ },
+ {
+ name: "eq int and json number sprint equal true",
+ observed: 5,
+ operator: "eq",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "eq float with decimal true",
+ observed: 5.5,
+ operator: "eq",
+ raw: json.RawMessage(`5.5`),
+ wantBool: true,
+ },
+ {
+ name: "eq bool true",
+ observed: true,
+ operator: "eq",
+ raw: json.RawMessage(`true`),
+ wantBool: true,
+ },
+ {
+ name: "eq nil observed and json null sprint equal true",
+ observed: nil,
+ operator: "eq",
+ raw: json.RawMessage(`null`),
+ wantBool: true,
+ },
+ {
+ name: "eq int and numeric string coerce equal true",
+ observed: 5,
+ operator: "eq",
+ raw: json.RawMessage(`"5"`),
+ wantBool: true,
+ },
+ // not_eq operator: fmt.Sprint inequality.
+ {
+ name: "not_eq equal values false",
+ observed: "running",
+ operator: "not_eq",
+ raw: json.RawMessage(`"running"`),
+ wantBool: false,
+ },
+ {
+ name: "not_eq different values true",
+ observed: "running",
+ operator: "not_eq",
+ raw: json.RawMessage(`"stopped"`),
+ wantBool: true,
+ },
+ // numeric operators gte/lte/gt/lt.
+ {
+ name: "gte greater true",
+ observed: 10,
+ operator: "gte",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "gte equal boundary true",
+ observed: 5,
+ operator: "gte",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "gte less false",
+ observed: 1,
+ operator: "gte",
+ raw: json.RawMessage(`5`),
+ wantBool: false,
+ },
+ {
+ name: "lte less true",
+ observed: 1,
+ operator: "lte",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "lte equal boundary true",
+ observed: 5,
+ operator: "lte",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "lte greater false",
+ observed: 10,
+ operator: "lte",
+ raw: json.RawMessage(`5`),
+ wantBool: false,
+ },
+ {
+ name: "gt greater true",
+ observed: 10,
+ operator: "gt",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "gt equal boundary false",
+ observed: 5,
+ operator: "gt",
+ raw: json.RawMessage(`5`),
+ wantBool: false,
+ },
+ {
+ name: "lt less true",
+ observed: 1,
+ operator: "lt",
+ raw: json.RawMessage(`5`),
+ wantBool: true,
+ },
+ {
+ name: "lt equal boundary false",
+ observed: 5,
+ operator: "lt",
+ raw: json.RawMessage(`5`),
+ wantBool: false,
+ },
+ {
+ name: "lt greater false",
+ observed: 10,
+ operator: "lt",
+ raw: json.RawMessage(`5`),
+ wantBool: false,
+ },
+ {
+ name: "numeric op observed non numeric parse error",
+ observed: "not-a-number",
+ operator: "gte",
+ raw: json.RawMessage(`5`),
+ wantErr: true,
+ },
+ {
+ name: "numeric op expected non numeric parse error",
+ observed: 5,
+ operator: "gte",
+ raw: json.RawMessage(`"abc"`),
+ wantErr: true,
+ },
+ // in operator: array membership via fmt.Sprint.
+ {
+ name: "in match true",
+ observed: "running",
+ operator: "in",
+ raw: json.RawMessage(`["running","stopped"]`),
+ wantBool: true,
+ },
+ {
+ name: "in no match false",
+ observed: "paused",
+ operator: "in",
+ raw: json.RawMessage(`["running","stopped"]`),
+ wantBool: false,
+ },
+ {
+ name: "in empty array false",
+ observed: "running",
+ operator: "in",
+ raw: json.RawMessage(`[]`),
+ wantBool: false,
+ },
+ {
+ name: "in numeric coercion match true",
+ observed: 5,
+ operator: "in",
+ raw: json.RawMessage(`[5,"x"]`),
+ wantBool: true,
+ },
+ {
+ name: "in string scalar value error",
+ observed: "running",
+ operator: "in",
+ raw: json.RawMessage(`"running"`),
+ wantErr: true,
+ errSubstr: "in predicate requires array value",
+ },
+ {
+ name: "in object value error",
+ observed: "running",
+ operator: "in",
+ raw: json.RawMessage(`{"a":1}`),
+ wantErr: true,
+ errSubstr: "in predicate requires array value",
+ },
+ // default arm: unsupported operator.
+ {
+ name: "unsupported operator error",
+ observed: "running",
+ operator: "weird",
+ raw: json.RawMessage(`"running"`),
+ wantErr: true,
+ errSubstr: "unsupported predicate operator",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := comparePredicate(tc.observed, tc.operator, tc.raw)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("comparePredicate err = nil, want non-nil")
+ }
+ if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) {
+ t.Fatalf("comparePredicate err = %q, want substring %q", err.Error(), tc.errSubstr)
+ }
+ if got != false {
+ t.Fatalf("comparePredicate bool = %v, want false on error path", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("comparePredicate err = %v, want nil", err)
+ }
+ if got != tc.wantBool {
+ t.Fatalf("comparePredicate bool = %v, want %v", got, tc.wantBool)
+ }
+ })
+ }
+}
diff --git a/internal/ai/qualification/report_coverage_test.go b/internal/ai/qualification/report_coverage_test.go
new file mode 100644
index 000000000..0e07dcf00
--- /dev/null
+++ b/internal/ai/qualification/report_coverage_test.go
@@ -0,0 +1,327 @@
+package qualification
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestPercentileIndexBoundaries(t *testing.T) {
+ cases := []struct {
+ name string
+ length int
+ p float64
+ want int
+ }{
+ {"zero length ninetyfive clamps to zero", 0, 0.95, 0},
+ {"zero length zero p clamps to zero", 0, 0.0, 0},
+ {"zero length huge p clamps to zero", 0, 5.0, 0},
+ {"length one p zero clamps to zero", 1, 0.0, 0},
+ {"length one p midpoint", 1, 0.5, 0},
+ {"length one p ninetyfive", 1, 0.95, 0},
+ {"length one p one", 1, 1.0, 0},
+ {"length one p over one clamps to last", 1, 1.5, 0},
+ {"length two p zero clamps to zero", 2, 0.0, 0},
+ {"length two p midpoint picks lower", 2, 0.5, 0},
+ {"length two p ninetyfive picks upper", 2, 0.95, 1},
+ {"length ten p zero clamps to zero", 10, 0.0, 0},
+ {"length ten p five percent", 10, 0.05, 0},
+ {"length ten p fifteen percent", 10, 0.15, 1},
+ {"length ten p midpoint", 10, 0.5, 4},
+ {"length ten p ninetyfive last index", 10, 0.95, 9},
+ {"length ten p one last index", 10, 1.0, 9},
+ {"length ten p over one clamps to last", 10, 2.0, 9},
+ {"length twenty p ninetyfive", 20, 0.95, 18},
+ {"length four p zero clamps to zero", 4, 0.0, 0},
+ {"length four p ninetyfive", 4, 0.95, 3},
+ {"length five p midpoint", 5, 0.5, 2},
+ {"length three p ninetyfive last index", 3, 0.95, 2},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := percentileIndex(tc.length, tc.p); got != tc.want {
+ t.Fatalf("percentileIndex(%d, %v) = %d, want %d", tc.length, tc.p, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPercentileInt64Aggregation(t *testing.T) {
+ cases := []struct {
+ name string
+ values []int64
+ p float64
+ want int64
+ }{
+ {"nil slice returns zero", nil, 0.95, 0},
+ {"empty slice returns zero", []int64{}, 0.95, 0},
+ {"single element", []int64{42}, 0.95, 42},
+ {"single element p zero", []int64{42}, 0.0, 42},
+ {"single element p one", []int64{42}, 1.0, 42},
+ {"unsorted five p ninetyfive picks max", []int64{50, 10, 40, 20, 30}, 0.95, 50},
+ {"unsorted five p midpoint", []int64{50, 10, 40, 20, 30}, 0.5, 30},
+ {"two elements p ninetyfive picks upper", []int64{100, 1}, 0.95, 100},
+ {"duplicates collapse", []int64{5, 5, 5, 5}, 0.95, 5},
+ {"negatives sort ascending", []int64{-10, 5, -3, 0}, 0.95, 5},
+ {"p zero returns minimum", []int64{9, 3, 7, 1}, 0.0, 1},
+ {"two elements p midpoint picks lower", []int64{8, 2}, 0.5, 2},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := percentileInt64(tc.values, tc.p); got != tc.want {
+ t.Fatalf("percentileInt64(%v, %v) = %d, want %d", tc.values, tc.p, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPercentileIntAggregation(t *testing.T) {
+ cases := []struct {
+ name string
+ values []int
+ p float64
+ want int
+ }{
+ {"nil slice returns zero", nil, 0.95, 0},
+ {"empty slice returns zero", []int{}, 0.95, 0},
+ {"single element", []int{7}, 0.95, 7},
+ {"single element p zero", []int{7}, 0.0, 7},
+ {"unsorted p ninetyfive picks max", []int{4, 2, 5, 1, 3}, 0.95, 5},
+ {"unsorted p midpoint", []int{4, 2, 5, 1, 3}, 0.5, 3},
+ {"two elements p ninetyfive", []int{8, 2}, 0.95, 8},
+ {"p zero returns minimum", []int{9, 1, 5}, 0.0, 1},
+ {"duplicates", []int{2, 2, 2}, 0.95, 2},
+ {"two elements p midpoint picks lower", []int{8, 2}, 0.5, 2},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := percentileInt(tc.values, tc.p); got != tc.want {
+ t.Fatalf("percentileInt(%v, %v) = %d, want %d", tc.values, tc.p, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestPercentileFloatAggregation(t *testing.T) {
+ cases := []struct {
+ name string
+ values []float64
+ p float64
+ want float64
+ }{
+ {"nil slice returns zero", nil, 0.95, 0},
+ {"empty slice returns zero", []float64{}, 0.95, 0},
+ {"single element", []float64{1.5}, 0.95, 1.5},
+ {"single element p zero", []float64{1.5}, 0.0, 1.5},
+ {"unsorted p ninetyfive picks max", []float64{0.1, 3.3, 2.2, 1.1}, 0.95, 3.3},
+ {"unsorted p zero picks min", []float64{0.1, 3.3, 2.2, 1.1}, 0.0, 0.1},
+ {"two elements p ninetyfive", []float64{2.5, 0.5}, 0.95, 2.5},
+ {"duplicates", []float64{1.0, 1.0, 1.0}, 0.95, 1.0},
+ {"negatives sort ascending", []float64{-1.5, 2.0, -0.5}, 0.95, 2.0},
+ {"two elements p midpoint picks lower", []float64{2.5, 0.5}, 0.5, 0.5},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := percentileFloat(tc.values, tc.p); got != tc.want {
+ t.Fatalf("percentileFloat(%v, %v) = %v, want %v", tc.values, tc.p, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSortedMapKeys(t *testing.T) {
+ cases := []struct {
+ name string
+ in map[string]bool
+ want []string
+ }{
+ {"nil map returns empty non-nil", nil, []string{}},
+ {"empty map returns empty non-nil", map[string]bool{}, []string{}},
+ {"single key", map[string]bool{"a": true}, []string{"a"}},
+ {"multiple keys sorted ascending", map[string]bool{"c": true, "a": true, "b": true}, []string{"a", "b", "c"}},
+ {"false values still included", map[string]bool{"a": false, "b": true}, []string{"a", "b"}},
+ {"ascii ordering uppercase before lowercase", map[string]bool{"z": true, "A": true, "a1": true}, []string{"A", "a1", "z"}},
+ {"whitespace keys preserved and sorted by byte", map[string]bool{" b": true, "a": true}, []string{" b", "a"}},
+ {"deduplicates identical keys", map[string]bool{"x": true}, []string{"x"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := sortedMapKeys(tc.in)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("sortedMapKeys(%v) = %#v, want %#v", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSummarizeModelAggregation(t *testing.T) {
+ // makeReport builds a RunReport populated only with the fields summarizeModel
+ // consumes; it is a table-local closure to avoid package-level collisions.
+ makeReport := func(passed bool, gitSHA string, dirty bool, pulseVersion string,
+ truePos, faults, falsePos, inTok, outTok int,
+ collectionMs, e2eMs int64, costKnown bool, costUSD float64, hardFailures []string) RunReport {
+ return RunReport{
+ Passed: passed,
+ Environment: Environment{GitSHA: gitSHA, GitDirty: dirty, PulseVersion: pulseVersion},
+ Score: Score{
+ TruePositives: truePos,
+ Faults: faults,
+ FalsePositives: falsePos,
+ InputTokens: inTok,
+ OutputTokens: outTok,
+ CollectionLatency: time.Duration(collectionMs) * time.Millisecond,
+ EndToEndLatency: time.Duration(e2eMs) * time.Millisecond,
+ Cost: CostEstimate{Known: costKnown, USD: costUSD},
+ HardFailures: hardFailures,
+ },
+ }
+ }
+
+ t.Run("nil reports yields zeroed summary with degenerate intervals", func(t *testing.T) {
+ got := summarizeModel("provider:none", nil)
+ want := ModelSummary{
+ Model: "provider:none",
+ PassRate: WilsonInterval(0, 0),
+ FaultRecall: WilsonInterval(0, 0),
+ GitSHAs: []string{},
+ PulseVersions: []string{},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("summarizeModel(nil) = %+v, want %+v", got, want)
+ }
+ if got.PassRate.Estimate != 1 || got.PassRate.Lower != 0 || got.PassRate.Upper != 1 {
+ t.Fatalf("degenerate PassRate interval = %+v", got.PassRate)
+ }
+ if got.FaultRecall.Estimate != 1 || got.FaultRecall.Lower != 0 || got.FaultRecall.Upper != 1 {
+ t.Fatalf("degenerate FaultRecall interval = %+v", got.FaultRecall)
+ }
+ })
+
+ t.Run("single passed run with known cost", func(t *testing.T) {
+ report := makeReport(true, "abc123", false, "6.0.0", 2, 3, 1, 100, 50, 500, 1000, true, 0.05, nil)
+ got := summarizeModel("provider:m", []RunReport{report})
+ want := ModelSummary{
+ Model: "provider:m",
+ Runs: 1,
+ Passed: 1,
+ PassRate: WilsonInterval(1, 1),
+ FaultRecall: WilsonInterval(2, 3),
+ FalsePositives: 1,
+ P95CollectionLatencyMs: 500,
+ P95LatencyMs: 1000,
+ P95InputTokens: 100,
+ P95OutputTokens: 50,
+ P95CostUSD: 0.05,
+ KnownCostRuns: 1,
+ GitSHAs: []string{"abc123"},
+ PulseVersions: []string{"6.0.0"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("summarizeModel(single) = %+v, want %+v", got, want)
+ }
+ })
+
+ t.Run("multiple runs aggregate pass recall latency tokens and cost", func(t *testing.T) {
+ reports := []RunReport{
+ makeReport(true, "sha1 ", false, "v1", 1, 2, 0, 10, 5, 100, 200, true, 0.01, nil),
+ makeReport(false, "", true, " v2 ", 0, 1, 2, 20, 10, 300, 400, false, 0, []string{"hard"}),
+ makeReport(true, "sha1", false, "", 2, 2, 0, 30, 15, 200, 600, true, 0.03, nil),
+ }
+ got := summarizeModel("provider:multi", reports)
+ want := ModelSummary{
+ Model: "provider:multi",
+ Runs: 3,
+ Passed: 2,
+ PassRate: WilsonInterval(2, 3),
+ FaultRecall: WilsonInterval(3, 5),
+ FalsePositives: 2,
+ P95CollectionLatencyMs: 300,
+ P95LatencyMs: 600,
+ P95InputTokens: 30,
+ P95OutputTokens: 15,
+ P95CostUSD: 0.03,
+ KnownCostRuns: 2,
+ HardFailureRuns: 1,
+ DirtyRuns: 1,
+ GitSHAs: []string{"sha1"},
+ PulseVersions: []string{"v1", "v2"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("summarizeModel(multi) = %+v, want %+v", got, want)
+ }
+ if got.PassRate.Success != 2 || got.PassRate.Total != 3 {
+ t.Fatalf("PassRate inputs = success %d total %d, want 2/3", got.PassRate.Success, got.PassRate.Total)
+ }
+ if got.FaultRecall.Success != 3 || got.FaultRecall.Total != 5 {
+ t.Fatalf("FaultRecall inputs = success %d total %d, want 3/5", got.FaultRecall.Success, got.FaultRecall.Total)
+ }
+ })
+
+ t.Run("all unknown cost yields zero p95 cost", func(t *testing.T) {
+ report := makeReport(true, "abc", false, "v1", 1, 1, 0, 10, 5, 100, 200, false, 0, nil)
+ got := summarizeModel("provider:uncosted", []RunReport{report})
+ want := ModelSummary{
+ Model: "provider:uncosted",
+ Runs: 1,
+ Passed: 1,
+ PassRate: WilsonInterval(1, 1),
+ FaultRecall: WilsonInterval(1, 1),
+ P95CollectionLatencyMs: 100,
+ P95LatencyMs: 200,
+ P95InputTokens: 10,
+ P95OutputTokens: 5,
+ P95CostUSD: 0,
+ GitSHAs: []string{"abc"},
+ PulseVersions: []string{"v1"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("summarizeModel(uncosted) = %+v, want %+v", got, want)
+ }
+ if got.KnownCostRuns != 0 {
+ t.Fatalf("KnownCostRuns = %d, want 0", got.KnownCostRuns)
+ }
+ })
+
+ t.Run("whitespace only sha and version are dropped", func(t *testing.T) {
+ report := makeReport(true, " ", false, " ", 0, 0, 0, 1, 1, 1, 1, false, 0, nil)
+ got := summarizeModel("provider:ws", []RunReport{report})
+ if len(got.GitSHAs) != 0 {
+ t.Fatalf("GitSHAs = %#v, want empty", got.GitSHAs)
+ }
+ if len(got.PulseVersions) != 0 {
+ t.Fatalf("PulseVersions = %#v, want empty", got.PulseVersions)
+ }
+ if got.GitSHAs == nil || got.PulseVersions == nil {
+ t.Fatalf("expected non-nil empty slices; GitSHAs=%#v PulseVersions=%#v", got.GitSHAs, got.PulseVersions)
+ }
+ })
+
+ t.Run("distinct shas and versions deduplicated and sorted", func(t *testing.T) {
+ reports := []RunReport{
+ makeReport(true, "shaB", false, "v2", 0, 0, 0, 1, 1, 1, 1, false, 0, nil),
+ makeReport(true, "shaA", false, "v1", 0, 0, 0, 1, 1, 1, 1, false, 0, nil),
+ makeReport(true, "shaA", false, "v2", 0, 0, 0, 1, 1, 1, 1, false, 0, nil),
+ }
+ got := summarizeModel("provider:dedup", reports)
+ wantSHA := []string{"shaA", "shaB"}
+ wantVer := []string{"v1", "v2"}
+ if !reflect.DeepEqual(got.GitSHAs, wantSHA) {
+ t.Fatalf("GitSHAs = %#v, want %#v", got.GitSHAs, wantSHA)
+ }
+ if !reflect.DeepEqual(got.PulseVersions, wantVer) {
+ t.Fatalf("PulseVersions = %#v, want %#v", got.PulseVersions, wantVer)
+ }
+ })
+
+ t.Run("hard failure runs counted only when slice non-empty", func(t *testing.T) {
+ reports := []RunReport{
+ makeReport(true, "", false, "", 0, 0, 0, 1, 1, 1, 1, false, 0, nil),
+ makeReport(true, "", false, "", 0, 0, 0, 1, 1, 1, 1, false, 0, []string{}),
+ makeReport(true, "", false, "", 0, 0, 0, 1, 1, 1, 1, false, 0, []string{"boom"}),
+ }
+ got := summarizeModel("provider:hard", reports)
+ if got.HardFailureRuns != 1 {
+ t.Fatalf("HardFailureRuns = %d, want 1", got.HardFailureRuns)
+ }
+ })
+}
diff --git a/internal/ai/qualification/runner_helpers_test.go b/internal/ai/qualification/runner_helpers_test.go
new file mode 100644
index 000000000..3b9553695
--- /dev/null
+++ b/internal/ai/qualification/runner_helpers_test.go
@@ -0,0 +1,221 @@
+package qualification
+
+import (
+ "testing"
+ "time"
+)
+
+// This file is a white-box table-test suite for the small pure helpers in
+// runner.go and lab.go: phaseDuration, dockerTargetLabel, and commandSummary.
+// Every helper and shared var introduced here is prefixed with `qualrunner` to
+// avoid collisions with sibling test files in package qualification.
+
+// qualrunnerPhase is a small constructor keeping table rows terse while letting
+// each row spell out its own duration explicitly.
+func qualrunnerPhase(name string, duration time.Duration) PhaseTiming {
+ return PhaseTiming{Name: name, Duration: duration}
+}
+
+func TestPhaseDurationLinearScan(t *testing.T) {
+ cases := []struct {
+ name string
+ phases []PhaseTiming
+ lookup string
+ want time.Duration
+ }{
+ {
+ name: "nil slice returns zero",
+ phases: nil,
+ lookup: "anything",
+ want: 0,
+ },
+ {
+ name: "empty slice returns zero",
+ phases: []PhaseTiming{},
+ lookup: "anything",
+ want: 0,
+ },
+ {
+ name: "name absent returns zero",
+ phases: []PhaseTiming{qualrunnerPhase("preflight", 1*time.Second)},
+ lookup: "does-not-exist",
+ want: 0,
+ },
+ {
+ name: "first and only element matches",
+ phases: []PhaseTiming{qualrunnerPhase("preflight", 250*time.Millisecond)},
+ lookup: "preflight",
+ want: 250 * time.Millisecond,
+ },
+ {
+ name: "match found later in slice exercises linear scan",
+ phases: []PhaseTiming{
+ qualrunnerPhase("preflight", 1*time.Second),
+ qualrunnerPhase("provision_and_baseline", 2*time.Second),
+ qualrunnerPhase("real_model_patrol", 3*time.Second),
+ },
+ lookup: "real_model_patrol",
+ want: 3 * time.Second,
+ },
+ {
+ name: "first occurrence wins when name duplicated",
+ phases: []PhaseTiming{
+ qualrunnerPhase("revert_and_verify", 5*time.Second),
+ qualrunnerPhase("revert_and_verify", 999*time.Second),
+ },
+ lookup: "revert_and_verify",
+ want: 5 * time.Second,
+ },
+ {
+ name: "matching phase with explicit zero duration returns zero",
+ phases: []PhaseTiming{qualrunnerPhase("preflight", 0)},
+ lookup: "preflight",
+ want: 0,
+ },
+ {
+ name: "negative duration is returned verbatim not clamped",
+ phases: []PhaseTiming{qualrunnerPhase("preflight", -7*time.Second)},
+ lookup: "preflight",
+ want: -7 * time.Second,
+ },
+ {
+ name: "empty lookup name never matches a populated phase",
+ phases: []PhaseTiming{qualrunnerPhase("preflight", 1*time.Second)},
+ lookup: "",
+ want: 0,
+ },
+ {
+ name: "empty name phase can be matched by empty lookup",
+ phases: []PhaseTiming{qualrunnerPhase("", 42*time.Millisecond)},
+ lookup: "",
+ want: 42 * time.Millisecond,
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ got := phaseDuration(tc.phases, tc.lookup)
+ if got != tc.want {
+ t.Fatalf("phaseDuration(%v, %q) = %v, want %v", tc.phases, tc.lookup, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestDockerTargetLabelBranches(t *testing.T) {
+ cases := []struct {
+ name string
+ target DockerTarget
+ want string
+ }{
+ {
+ name: "ssh host wins when set",
+ target: DockerTarget{SSHHost: "lab.example.com", Context: "ignored"},
+ want: "ssh:lab.example.com",
+ },
+ {
+ name: "ssh host only",
+ target: DockerTarget{SSHHost: "user@host"},
+ want: "ssh:user@host",
+ },
+ {
+ name: "context used when ssh host empty",
+ target: DockerTarget{Context: "colima"},
+ want: "context:colima",
+ },
+ {
+ name: "empty target yields bare context prefix",
+ target: DockerTarget{},
+ want: "context:",
+ },
+ {
+ name: "context empty but ssh host set still uses ssh",
+ target: DockerTarget{SSHHost: "bridge"},
+ want: "ssh:bridge",
+ },
+ {
+ name: "allow shared host flag does not affect label",
+ target: DockerTarget{Context: "default", AllowSharedHost: true},
+ want: "context:default",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ got := dockerTargetLabel(tc.target)
+ if got != tc.want {
+ t.Fatalf("dockerTargetLabel(%+v) = %q, want %q", tc.target, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCommandSummaryTruncation(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ args []string
+ want string
+ }{
+ {
+ name: "nil args yields name only",
+ in: "docker",
+ args: nil,
+ want: "docker",
+ },
+ {
+ name: "empty args yields name only",
+ in: "docker",
+ args: []string{},
+ want: "docker",
+ },
+ {
+ name: "empty name joined with args",
+ in: "",
+ args: []string{"ps"},
+ want: " ps",
+ },
+ {
+ name: "single arg joined",
+ in: "git",
+ args: []string{"status"},
+ want: "git status",
+ },
+ {
+ name: "exactly six args not truncated",
+ in: "docker",
+ args: []string{"run", "-d", "--name", "svc", "--network", "net"},
+ want: "docker run -d --name svc --network net",
+ },
+ {
+ name: "seven args truncated to first six",
+ in: "docker",
+ args: []string{"run", "-d", "--name", "svc", "--network", "net", "image"},
+ want: "docker run -d --name svc --network net",
+ },
+ {
+ name: "many args truncated to first six preserving order",
+ in: "ssh",
+ args: []string{"-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "host", "docker", "ps", "-aq", "--no-trunc"},
+ want: "ssh -o BatchMode=yes -o ConnectTimeout=15 host docker",
+ },
+ {
+ name: "five args under boundary passed through",
+ in: "docker",
+ args: []string{"network", "create", "--label", "k=v"},
+ want: "docker network create --label k=v",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ got := commandSummary(tc.in, tc.args)
+ if got != tc.want {
+ t.Fatalf("commandSummary(%q, %v) = %q, want %q", tc.in, tc.args, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/ai/tools/tools_discovery_match_test.go b/internal/ai/tools/tools_discovery_match_test.go
new file mode 100644
index 000000000..69efa9e31
--- /dev/null
+++ b/internal/ai/tools/tools_discovery_match_test.go
@@ -0,0 +1,168 @@
+package tools
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestNodeMatchesTargetID exercises nodeMatchesTargetID(nodeName, targetID string) bool
+// across every branch: case-insensitive equality, composite instance-node suffix
+// match, and the default fallthrough to false. Edge cases (empty inputs, case
+// folding, dash-boundary semantics) are asserted against the actual current
+// behavior of the function in tools_discovery.go.
+func TestNodeMatchesTargetID(t *testing.T) {
+ tests := []struct {
+ name string
+ nodeName string
+ targetID string
+ want bool
+ }{
+ // --- Branch 1: case-insensitive equality (strings.EqualFold) -> true ---
+ {
+ name: "exact match lowercase",
+ nodeName: "pve-node",
+ targetID: "pve-node",
+ want: true,
+ },
+ {
+ name: "case-insensitive equality node upper target lower",
+ nodeName: "PVE-NODE",
+ targetID: "pve-node",
+ want: true,
+ },
+ {
+ name: "case-insensitive equality node lower target upper",
+ nodeName: "pve-node",
+ targetID: "PVE-NODE",
+ want: true,
+ },
+ {
+ name: "case-insensitive equality mixed case both sides",
+ nodeName: "HoMeLaB-pVe-NoDe",
+ targetID: "homelab-pve-node",
+ want: true,
+ },
+ {
+ name: "equality single character nodes",
+ nodeName: "a",
+ targetID: "A",
+ want: true,
+ },
+ // Both empty strings are EqualFold-equal, so this short-circuits to true.
+ {
+ name: "both empty equality true",
+ nodeName: "",
+ targetID: "",
+ want: true,
+ },
+
+ // --- Branch 2: composite instance-node suffix match -> true ---
+ // targetID ends with "-"+nodeName (case-insensitive).
+ {
+ name: "composite instance node suffix match",
+ nodeName: "pve-node",
+ targetID: "homelab-pve-node",
+ want: true,
+ },
+ {
+ name: "composite suffix case-insensitive node upper",
+ nodeName: "PVE-NODE",
+ targetID: "homelab-pve-node",
+ want: true,
+ },
+ {
+ name: "composite suffix case-insensitive target upper",
+ nodeName: "pve-node",
+ targetID: "HOMELAB-PVE-NODE",
+ want: true,
+ },
+ {
+ name: "composite suffix with multi-segment instance prefix",
+ nodeName: "node1",
+ targetID: "cluster-a-instance-node1",
+ want: true,
+ },
+ // Note on actual behavior: suffix match requires only that targetID ends
+ // with "-"+nodeName, so any nodeName that is itself the trailing dash
+ // segment of targetID matches, even when nodeName is a generic word.
+ {
+ name: "generic suffix segment matches",
+ nodeName: "node",
+ targetID: "homelab-pve-node",
+ want: true,
+ },
+ // Documented quirk: with nodeName empty, suffix becomes just "-", so any
+ // targetID ending in "-" matches. See GLM_REPORT.md.
+ {
+ name: "empty nodeName matches targetID ending in dash",
+ nodeName: "",
+ targetID: "abc-",
+ want: true,
+ },
+
+ // --- Branch 3: default fallthrough -> false ---
+ {
+ name: "completely unrelated values",
+ nodeName: "pve-node",
+ targetID: "agent-1",
+ want: false,
+ },
+ {
+ name: "substring without dash boundary does not match",
+ nodeName: "pve-node",
+ targetID: "mypve-node",
+ want: false,
+ },
+ {
+ name: "nodeName appears earlier but not as dash suffix",
+ nodeName: "pve-node",
+ targetID: "pve-node-2",
+ want: false,
+ },
+ {
+ name: "nodeName is trailing but no dash prefix",
+ nodeName: "node",
+ targetID: "homelab-node-x",
+ want: false,
+ },
+ {
+ name: "empty nodeName non-dash targetID no match",
+ nodeName: "",
+ targetID: "pve-node",
+ want: false,
+ },
+ {
+ name: "empty targetID non-empty node no match",
+ nodeName: "pve-node",
+ targetID: "",
+ want: false,
+ },
+ {
+ name: "whitespace not trimmed equality miss",
+ nodeName: "pve-node",
+ targetID: "pve-node ",
+ want: false,
+ },
+ {
+ name: "whitespace not trimmed suffix miss",
+ nodeName: "pve-node",
+ targetID: "homelab-pve-node ",
+ want: false,
+ },
+ {
+ name: "single char nodeName not dash suffix of target",
+ nodeName: "x",
+ targetID: "x-foo",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := nodeMatchesTargetID(tt.nodeName, tt.targetID)
+ assert.Equal(t, tt.want, got,
+ "nodeMatchesTargetID(%q, %q)", tt.nodeName, tt.targetID)
+ })
+ }
+}
diff --git a/internal/ai/tools/tools_query_hints_test.go b/internal/ai/tools/tools_query_hints_test.go
new file mode 100644
index 000000000..63873c381
--- /dev/null
+++ b/internal/ai/tools/tools_query_hints_test.go
@@ -0,0 +1,272 @@
+package tools
+
+import "testing"
+
+// This file provides white-box table tests for the pure string-routing hint
+// helpers in tools_query.go:
+// - GetReadOnlyViolationHint (top-level routing)
+// - isPhase1GuardrailFailure (keyword classification)
+// - getPhase1Hint (structural guardrail switch)
+// - getSQLHint (SQL content-inspection switch)
+//
+// All four functions are pure (no I/O) and return exact strings/bools, so every
+// case asserts the exact returned value rather than just "no panic".
+
+func TestGetReadOnlyViolationHint(t *testing.T) {
+ tests := []struct {
+ name string
+ command string
+ result IntentResult
+ want string
+ }{
+ // Empty / boundary inputs.
+ {
+ name: "empty command and empty reason returns empty base hint",
+ command: "",
+ result: IntentResult{},
+ want: "",
+ },
+ {
+ name: "phase1 reason with empty command still routes to phase1 hint",
+ command: "",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "sudo escalates command privileges"},
+ want: "sudo escalates command privileges. Read-only execution does not accept sudo; privileged operations require the governed control path.",
+ },
+ {
+ name: "sql cli command with empty reason routes to sql default hint",
+ command: "sqlite3 db",
+ result: IntentResult{},
+ want: ". For read-only queries, use self-contained SELECT statements without transaction control.",
+ },
+
+ // Phase 1 guardrail routing.
+ {
+ name: "phase1 redirect reason routes to phase1 redirect hint",
+ command: "echo hi > /tmp/x",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "output redirection can overwrite files"},
+ want: "output redirection can overwrite files. Read-only execution does not accept redirects (>, >>, <, <<, <<<).",
+ },
+
+ // Phase 1 precedence over SQL CLI detection.
+ {
+ name: "phase1 takes precedence over sql cli command match",
+ command: "sudo sqlite3 db.db \"SELECT 1\"",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "sudo escalates command privileges"},
+ want: "sudo escalates command privileges. Read-only execution does not accept sudo; privileged operations require the governed control path.",
+ },
+
+ // SQL CLI routing for each recognised CLI binary.
+ {
+ name: "sqlite3 cli with write keyword routes to sql write hint",
+ command: "sqlite3 db.db \"INSERT INTO t VALUES(1)\"",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: SQL contains write/control keyword: insert"},
+ want: "content inspection: SQL contains write/control keyword: insert. Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK.",
+ },
+ {
+ name: "mysql cli with no-inline reason routes to sql external hint",
+ command: "mysql db",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: no inline SQL found; input may be external (piped/interactive)"},
+ want: "content inspection: no inline SQL found; input may be external (piped/interactive). Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\"",
+ },
+ {
+ name: "mariadb cli with neutral reason routes to sql default hint",
+ command: "mariadb db -e \"SELECT 1\"",
+ result: IntentResult{Intent: IntentReadOnlyConditional, Reason: "content inspection: read-only"},
+ want: "content inspection: read-only. For read-only queries, use self-contained SELECT statements without transaction control.",
+ },
+ {
+ name: "psql cli with control keyword routes to sql write hint",
+ command: "psql -c \"BEGIN\"",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: SQL contains write/control keyword: begin"},
+ want: "content inspection: SQL contains write/control keyword: begin. Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK.",
+ },
+
+ // SQL CLI precedence over the unknown-command fallback.
+ {
+ name: "sql cli command beats unknown reason fallback",
+ command: "sqlite3 db",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "unknown command"},
+ want: "unknown command. For read-only queries, use self-contained SELECT statements without transaction control.",
+ },
+
+ // Unknown-command fallback (non-SQL, non-phase1).
+ {
+ name: "non-sql unknown reason routes to self-contained hint",
+ command: "foobar",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "unknown command is not on the read-only allowlist"},
+ want: "unknown command is not on the read-only allowlist. Try a self-contained form: no pipes, no redirects, single statement. If this is a read-only operation, consider using a known read-only command instead.",
+ },
+ {
+ name: "non-sql no-inspector reason routes to self-contained hint",
+ command: "foobar",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "no inspector matched this command"},
+ want: "no inspector matched this command. Try a self-contained form: no pipes, no redirects, single statement. If this is a read-only operation, consider using a known read-only command instead.",
+ },
+
+ // Plain fallback (no routing matches): base hint returned unchanged.
+ {
+ name: "non-sql unmatched reason returns base hint unchanged",
+ command: "foobar",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "random reason"},
+ want: "random reason",
+ },
+
+ // Documents actual (surprising) behaviour for interactive_repl blocks.
+ // The interactive_repl NonInteractiveBlock reason is not in the phase1
+ // keyword list, so routing falls through to SQL/unknown/default paths.
+ {
+ name: "bare mysql interactive_repl block misroutes to sql default hint",
+ command: "mysql",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands"},
+ want: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands. For read-only queries, use self-contained SELECT statements without transaction control.",
+ },
+ {
+ name: "python interactive_repl block returns base hint unchanged",
+ command: "python",
+ result: IntentResult{Intent: IntentWriteOrUnknown, Reason: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands"},
+ want: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := GetReadOnlyViolationHint(tc.command, tc.result)
+ if got != tc.want {
+ t.Fatalf("GetReadOnlyViolationHint(command=%q, reason=%q) =\n %q\nwant\n %q",
+ tc.command, tc.result.Reason, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestIsPhase1GuardrailFailure(t *testing.T) {
+ tests := []struct {
+ name string
+ reason string
+ want bool
+ }{
+ // Phase 1 structural keywords (case-sensitive substring match).
+ {name: "sudo keyword", reason: "sudo escalates command privileges", want: true},
+ {name: "redirect substring inside redirection", reason: "output redirection can overwrite files", want: true},
+ {name: "redirect exact substring", reason: "has redirect here", want: true},
+ {name: "tee keyword", reason: "tee can write to files", want: true},
+ {name: "substitution keyword", reason: "command substitution can execute arbitrary commands", want: true},
+ {name: "chaining keyword", reason: "shell chaining detected outside quotes", want: true},
+ {name: "piped input keyword", reason: "piped input to dual-use tool prevents content inspection", want: true},
+
+ // NonInteractiveOnly keywords.
+ {name: "TTY uppercase keyword", reason: "[tty_flag] interactive/TTY flags require terminal", want: true},
+ {name: "terminal keyword alone", reason: "requires terminal interaction", want: true},
+ {name: "pager keyword", reason: "[pager] pager/editor tools require terminal", want: true},
+ {name: "editor keyword alone", reason: "editor tools need a terminal", want: true},
+ {name: "indefinitely keyword", reason: "live monitoring tools run indefinitely", want: true},
+ {name: "unbounded keyword", reason: "[unbounded_stream] follow mode without bound", want: true},
+ {name: "streaming keyword alone", reason: "unbounded streaming detected", want: true},
+
+ // Negative cases (case-sensitive, spacing-sensitive, unmatched).
+ {name: "empty reason returns false", reason: "", want: false},
+ {name: "tty lowercase does not match (case sensitive)", reason: "tty flag", want: false},
+ {name: "SUDO uppercase does not match (case sensitive)", reason: "SUDO escalation", want: false},
+ {name: "pipedinput without space does not match", reason: "pipedinput detected", want: false},
+ {name: "unrelated reason returns false", reason: "unknown command is not on the read-only allowlist", want: false},
+ {name: "interactive_repl real reason not classified as phase1", reason: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands", want: false},
+ {name: "content inspection read-only reason not classified as phase1", reason: "content inspection: read-only", want: false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := isPhase1GuardrailFailure(tc.reason)
+ if got != tc.want {
+ t.Fatalf("isPhase1GuardrailFailure(%q) = %v, want %v", tc.reason, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestGetPhase1Hint(t *testing.T) {
+ const base = "REASON" // arbitrary non-empty base hint to verify concatenation
+ tests := []struct {
+ name string
+ reason string
+ baseHint string
+ want string
+ }{
+ // One case per switch arm (in source order).
+ {name: "sudo arm", reason: "sudo escalates", baseHint: base, want: base + ". Read-only execution does not accept sudo; privileged operations require the governed control path."},
+ {name: "redirect arm via redirection substring", reason: "output redirection detected", baseHint: base, want: base + ". Read-only execution does not accept redirects (>, >>, <, <<, <<<)."},
+ {name: "tee arm", reason: "tee can write to files", baseHint: base, want: base + ". Read-only execution does not accept tee because tee writes to files."},
+ {name: "substitution arm", reason: "command substitution detected", baseHint: base, want: base + ". Read-only execution does not accept $() or backticks."},
+ {name: "chaining arm", reason: "shell chaining detected", baseHint: base, want: base + ". Run commands separately instead of chaining with ; && ||."},
+ {name: "piped input arm", reason: "piped input to dual-use tool", baseHint: base, want: base + ". For dual-use tools, include content directly instead of piping. Example: sqlite3 db.db \"SELECT ...\" instead of cat file | sqlite3 db.db"},
+ {name: "TTY arm", reason: "interactive/TTY flags", baseHint: base, want: base + ". Remove -it/--tty/--interactive flags. Use non-interactive form: docker exec container cmd (not docker exec -it)."},
+ {name: "terminal arm without TTY", reason: "requires terminal interaction", baseHint: base, want: base + ". Remove -it/--tty/--interactive flags. Use non-interactive form: docker exec container cmd (not docker exec -it)."},
+ {name: "pager arm", reason: "pager tools", baseHint: base, want: base + ". Use cat, head -n, or tail -n instead of interactive tools."},
+ {name: "editor arm without pager", reason: "editor tools", baseHint: base, want: base + ". Use cat, head -n, or tail -n instead of interactive tools."},
+ {name: "indefinitely arm", reason: "runs indefinitely", baseHint: base, want: base + ". Use bounded alternatives: ps aux (not top), journalctl -n 100 (not watch)."},
+ {name: "unbounded arm", reason: "unbounded stream", baseHint: base, want: base + ". Add line limit: journalctl -n 100 -f or tail -n 50 -f, or wrap with timeout."},
+ {name: "streaming arm without unbounded", reason: "streaming follow mode", baseHint: base, want: base + ". Add line limit: journalctl -n 100 -f or tail -n 50 -f, or wrap with timeout."},
+
+ // default / fallthrough arm.
+ {name: "default arm with unmatched reason", reason: "unknown phase1 failure", baseHint: base, want: base + ". Read-only execution does not accept redirects, chaining, sudo, or subshells."},
+ {name: "default arm with empty reason", reason: "", baseHint: base, want: base + ". Read-only execution does not accept redirects, chaining, sudo, or subshells."},
+
+ // Switch-order precedence (first matching case wins).
+ {name: "precedence sudo before redirect", reason: "sudo and redirect", baseHint: base, want: base + ". Read-only execution does not accept sudo; privileged operations require the governed control path."},
+ {name: "precedence redirect before tee", reason: "redirect and tee", baseHint: base, want: base + ". Read-only execution does not accept redirects (>, >>, <, <<, <<<)."},
+ {name: "precedence piped input before TTY", reason: "piped input and TTY flags", baseHint: base, want: base + ". For dual-use tools, include content directly instead of piping. Example: sqlite3 db.db \"SELECT ...\" instead of cat file | sqlite3 db.db"},
+ {name: "precedence indefinitely before unbounded", reason: "indefinitely and unbounded", baseHint: base, want: base + ". Use bounded alternatives: ps aux (not top), journalctl -n 100 (not watch)."},
+
+ // Empty base hint (still gets the suffix).
+ {name: "empty base hint sudo arm", reason: "sudo", baseHint: "", want: ". Read-only execution does not accept sudo; privileged operations require the governed control path."},
+ {name: "empty base hint default arm", reason: "unmatched", baseHint: "", want: ". Read-only execution does not accept redirects, chaining, sudo, or subshells."},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := getPhase1Hint(tc.reason, tc.baseHint)
+ if got != tc.want {
+ t.Fatalf("getPhase1Hint(%q, %q) =\n %q\nwant\n %q",
+ tc.reason, tc.baseHint, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestGetSQLHint(t *testing.T) {
+ const base = "REASON" // arbitrary non-empty base hint to verify concatenation
+ tests := []struct {
+ name string
+ reason string
+ baseHint string
+ want string
+ }{
+ // external / no-inline arm (first case).
+ {name: "external arm", reason: "input may be external", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""},
+ {name: "no inline arm", reason: "no inline SQL found", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""},
+
+ // write / control arm (second case).
+ {name: "write arm", reason: "contains write keyword", baseHint: base, want: base + ". Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK."},
+ {name: "control arm", reason: "transaction control keyword", baseHint: base, want: base + ". Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK."},
+
+ // default / fallthrough arm.
+ {name: "default arm with unmatched reason", reason: "read-only content", baseHint: base, want: base + ". For read-only queries, use self-contained SELECT statements without transaction control."},
+ {name: "default arm with empty reason", reason: "", baseHint: base, want: base + ". For read-only queries, use self-contained SELECT statements without transaction control."},
+
+ // Switch-order precedence (external/no-inline before write/control).
+ {name: "precedence external before write", reason: "external write detected", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""},
+ {name: "precedence no inline before control", reason: "no inline control found", baseHint: base, want: base + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""},
+
+ // Empty base hint (still gets the suffix).
+ {name: "empty base hint external arm", reason: "external", baseHint: "", want: ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := getSQLHint(tc.reason, tc.baseHint)
+ if got != tc.want {
+ t.Fatalf("getSQLHint(%q, %q) =\n %q\nwant\n %q",
+ tc.reason, tc.baseHint, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/ai/tools/tools_query_vmconfig_test.go b/internal/ai/tools/tools_query_vmconfig_test.go
new file mode 100644
index 000000000..6310cff82
--- /dev/null
+++ b/internal/ai/tools/tools_query_vmconfig_test.go
@@ -0,0 +1,290 @@
+package tools
+
+import (
+ "testing"
+)
+
+func toolsvmconfigBoolPtr(b bool) *bool { return &b }
+
+func toolsvmconfigBoolEqual(got, want *bool) bool {
+ if got == nil || want == nil {
+ return got == nil && want == nil
+ }
+ return *got == *want
+}
+
+func toolsvmconfigDisksEqual(got, want []GuestDiskConfig) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func TestParseVMConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ config map[string]interface{}
+ wantOS string
+ wantBoot *bool
+ wantDisks []GuestDiskConfig
+ }{
+ {
+ name: "nil map returns zero values",
+ config: nil,
+ wantOS: "",
+ wantBoot: nil,
+ wantDisks: nil,
+ },
+ {
+ name: "empty map returns zero values",
+ config: map[string]interface{}{},
+ wantOS: "",
+ wantBoot: nil,
+ wantDisks: nil,
+ },
+ {
+ name: "ostype only",
+ config: map[string]interface{}{"ostype": "l26"},
+ wantOS: "l26",
+ wantBoot: nil,
+ },
+ {
+ name: "onboot true via one string",
+ config: map[string]interface{}{"onboot": "1"},
+ wantOS: "",
+ wantBoot: toolsvmconfigBoolPtr(true),
+ },
+ {
+ name: "onboot false via zero string",
+ config: map[string]interface{}{"onboot": "0"},
+ wantOS: "",
+ wantBoot: toolsvmconfigBoolPtr(false),
+ },
+ {
+ name: "single scsi disk",
+ config: map[string]interface{}{"scsi0": "local-lvm:vm-100-disk-0,size=32G"},
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi0", Value: "local-lvm:vm-100-disk-0,size=32G"},
+ },
+ },
+ {
+ name: "multiple disks sorted by key ascending",
+ config: map[string]interface{}{
+ "virtio0": "storage:vm-100-virtio-0",
+ "scsi1": "storage:vm-100-scsi-1",
+ "ide0": "storage:vm-100-ide-0",
+ "sata0": "storage:vm-100-sata-0",
+ },
+ wantDisks: []GuestDiskConfig{
+ {Key: "ide0", Value: "storage:vm-100-ide-0"},
+ {Key: "sata0", Value: "storage:vm-100-sata-0"},
+ {Key: "scsi1", Value: "storage:vm-100-scsi-1"},
+ {Key: "virtio0", Value: "storage:vm-100-virtio-0"},
+ },
+ },
+ {
+ name: "all disk prefixes collected and sorted",
+ config: map[string]interface{}{
+ "scsi0": "a",
+ "virtio0": "b",
+ "sata0": "c",
+ "ide0": "d",
+ "unused0": "e",
+ "efidisk0": "f",
+ "tpmstate0": "g",
+ },
+ wantDisks: []GuestDiskConfig{
+ {Key: "efidisk0", Value: "f"},
+ {Key: "ide0", Value: "d"},
+ {Key: "sata0", Value: "c"},
+ {Key: "scsi0", Value: "a"},
+ {Key: "tpmstate0", Value: "g"},
+ {Key: "unused0", Value: "e"},
+ {Key: "virtio0", Value: "b"},
+ },
+ },
+ {
+ name: "mixed ostype onboot and disk",
+ config: map[string]interface{}{"ostype": "win11", "onboot": "yes", "scsi0": "local:disk-0"},
+ wantOS: "win11",
+ wantBoot: toolsvmconfigBoolPtr(true),
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi0", Value: "local:disk-0"},
+ },
+ },
+ {
+ name: "disk with empty value skipped",
+ config: map[string]interface{}{"scsi0": "", "scsi1": "real"},
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi1", Value: "real"},
+ },
+ },
+ {
+ name: "disk key casing normalized to lower",
+ config: map[string]interface{}{"SCSI0": "local:disk-0"},
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi0", Value: "local:disk-0"},
+ },
+ },
+ {
+ name: "ostype key matched case insensitively",
+ config: map[string]interface{}{"OSTYPE": "l26"},
+ wantOS: "l26",
+ wantBoot: nil,
+ },
+ {
+ name: "key surrounding whitespace trimmed",
+ config: map[string]interface{}{" ostype ": "win10", " scsi0 ": "local:disk"},
+ wantOS: "win10",
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi0", Value: "local:disk"},
+ },
+ },
+ {
+ name: "disk value whitespace trimmed",
+ config: map[string]interface{}{"scsi0": " local:disk-0 "},
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi0", Value: "local:disk-0"},
+ },
+ },
+ {
+ name: "non-disk non-special keys ignored",
+ config: map[string]interface{}{"net0": "virtio,bridge=vmbr0", "memory": 4096, "cores": 4},
+ wantOS: "",
+ wantBoot: nil,
+ wantDisks: nil,
+ },
+ {
+ name: "onboot uncoercible value yields nil",
+ config: map[string]interface{}{"onboot": "maybe"},
+ wantOS: "",
+ wantBoot: nil,
+ },
+ {
+ name: "disk nil interface value rendered as literal string",
+ config: map[string]interface{}{"scsi0": nil},
+ wantDisks: []GuestDiskConfig{
+ {Key: "scsi0", Value: ""},
+ },
+ },
+ {
+ name: "onboot integer coerced via sprint",
+ config: map[string]interface{}{"onboot": 1},
+ wantOS: "",
+ wantBoot: toolsvmconfigBoolPtr(true),
+ },
+ {
+ name: "single disk not sorted when only one",
+ config: map[string]interface{}{"virtio0": "v"},
+ wantDisks: []GuestDiskConfig{
+ {Key: "virtio0", Value: "v"},
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ gotOS, gotBoot, gotDisks := parseVMConfig(tc.config)
+ if gotOS != tc.wantOS {
+ t.Errorf("osType = %q, want %q", gotOS, tc.wantOS)
+ }
+ if !toolsvmconfigBoolEqual(gotBoot, tc.wantBoot) {
+ t.Errorf("onboot mismatch: got=%v, want=%v", gotBoot, tc.wantBoot)
+ }
+ if !toolsvmconfigDisksEqual(gotDisks, tc.wantDisks) {
+ t.Errorf("disks = %+v, want %+v", gotDisks, tc.wantDisks)
+ }
+ })
+ }
+}
+
+func TestIsVMConfigDiskKey(t *testing.T) {
+ tests := []struct {
+ name string
+ key string
+ want bool
+ }{
+ {"scsi prefix", "scsi0", true},
+ {"scsi exact", "scsi", true},
+ {"virtio prefix", "virtio0", true},
+ {"virtio exact", "virtio", true},
+ {"sata prefix", "sata2", true},
+ {"sata exact", "sata", true},
+ {"ide prefix", "ide0", true},
+ {"ide exact", "ide", true},
+ {"unused prefix", "unused0", true},
+ {"unused exact", "unused", true},
+ {"efidisk prefix", "efidisk0", true},
+ {"efidisk exact", "efidisk", true},
+ {"tpmstate prefix", "tpmstate0", true},
+ {"tpmstate exact", "tpmstate", true},
+ {"net is not a disk key", "net0", false},
+ {"memory is not a disk key", "memory", false},
+ {"cores is not a disk key", "cores", false},
+ {"ostype is not a disk key", "ostype", false},
+ {"onboot is not a disk key", "onboot", false},
+ {"rootfs is not a disk key", "rootfs", false},
+ {"disk0 is not a recognized disk key", "disk0", false},
+ {"empty string is not a disk key", "", false},
+ {"uppercase scsi not matched case sensitive", "SCSI0", false},
+ {"uppercase ide not matched case sensitive", "IDE0", false},
+ {"prefix match has no word boundary ide in ideology", "ideology", true},
+ {"prefix match has no word boundary scsi in scsiabc", "scsiabc", true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isVMConfigDiskKey(tc.key); got != tc.want {
+ t.Errorf("isVMConfigDiskKey(%q) = %v, want %v", tc.key, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestParseOnbootValue(t *testing.T) {
+ tests := []struct {
+ name string
+ value interface{}
+ want *bool
+ }{
+ {"empty string", "", nil},
+ {"string one", "1", toolsvmconfigBoolPtr(true)},
+ {"string yes lowercase", "yes", toolsvmconfigBoolPtr(true)},
+ {"string yes uppercase", "YES", toolsvmconfigBoolPtr(true)},
+ {"string yes mixed case", "Yes", toolsvmconfigBoolPtr(true)},
+ {"string true lowercase", "true", toolsvmconfigBoolPtr(true)},
+ {"string true uppercase", "TRUE", toolsvmconfigBoolPtr(true)},
+ {"string true mixed case", "True", toolsvmconfigBoolPtr(true)},
+ {"string zero", "0", toolsvmconfigBoolPtr(false)},
+ {"string no lowercase", "no", toolsvmconfigBoolPtr(false)},
+ {"string no uppercase", "NO", toolsvmconfigBoolPtr(false)},
+ {"string false lowercase", "false", toolsvmconfigBoolPtr(false)},
+ {"string false uppercase", "FALSE", toolsvmconfigBoolPtr(false)},
+ {"whitespace padded one", " 1 ", toolsvmconfigBoolPtr(true)},
+ {"whitespace padded yes", " yes ", toolsvmconfigBoolPtr(true)},
+ {"whitespace padded false", " false ", toolsvmconfigBoolPtr(false)},
+ {"string two unrecognized", "2", nil},
+ {"string maybe unrecognized", "maybe", nil},
+ {"string on not recognized actual behavior", "on", nil},
+ {"integer one coerced to true", 1, toolsvmconfigBoolPtr(true)},
+ {"integer zero coerced to false", 0, toolsvmconfigBoolPtr(false)},
+ {"integer two unrecognized", 2, nil},
+ {"boolean true coerced", true, toolsvmconfigBoolPtr(true)},
+ {"boolean false coerced", false, toolsvmconfigBoolPtr(false)},
+ {"nil interface returns nil", nil, nil},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := parseOnbootValue(tc.value)
+ if !toolsvmconfigBoolEqual(got, tc.want) {
+ t.Errorf("parseOnbootValue(%v) = %v, want %v", tc.value, got, tc.want)
+ }
+ })
+ }
+}