Files
kit/pkg/kit/kit_test.go
T
Ed Zynda 7a04bdfeba feat(kit): isolate viper config per Kit instance + add NewAgent (#42)
* feat(kit): isolate viper config per Kit instance + add NewAgent (#40)

- Give each kit.New()/NewAgent() call an isolated *viper.Viper store so
  multiple Kit instances in one process no longer clobber each other's
  config; runtime mutators (SetModel, SetThinkingLevel) touch only the
  owning instance, making subagent spawning and multi-Kit embedding
  race-free
- Thread the per-instance store through internal/config, internal/models
  (ProviderConfig.ConfigStore), internal/kitsetup, and the extension
  runner, with a nil -> process-global fallback so the CLI is unaffected
- Share the global store when Options.CLI != nil to preserve cobra flag
  bindings (also opted in for internal/acpserver)
- Remove viperInitMu; preserve the tri-state IsSet precedence contract
  and sdkDefaultMaxTokens floor
- Add ergonomic NewAgent + functional options (WithModel, WithStreaming,
  Ephemeral, etc.); NewAgent defaults streaming on, opt out via
  WithStreaming(false). New(ctx, *Options) behavior is unchanged
- Add config-isolation regression test and NewAgent/option coverage;
  document NewAgent and per-instance isolation in README

Fixes #40

* docs(sdk): document NewAgent options and per-instance config isolation

- Add "Functional options (NewAgent)" and "Per-instance config isolation"
  sections to the docs site SDK overview, with an options table and a
  "when to use which" constructor comparison
- Cross-reference NewAgent from the SDK options page and correct the now
  per-instance ProviderAPIKey precedence wording
- Document NewAgent + With* helpers and config isolation in pkg/kit/README
  and list NewAgent/Option in the API reference
- Show the NewAgent constructor in the SDK examples getting-started snippet

* fix(kit): correct config loading and isolate ACP sessions

- Isolate each ACP session's config store instead of sharing the global
  viper, preventing per-session SetModel/SetThinkingLevel races; seed the
  root-command flag values (model, thinking-level, provider URL/key) so
  `kit acp -m <model>` is still honored
- Run initConfig for isolated SDK stores by gating on opts.CLI instead of
  v.GetString("model"), which setSDKDefaults always populates and thus
  skipped .kit.yml / KIT_* loading for SDK callers
- Configure KIT_* env overrides unconditionally in initConfig so passing an
  explicit config file no longer disables environment variable support
- Wrap config unmarshal/validate errors with %w to preserve the error chain

* fix(kit): make Options.Streaming a *bool to honor unset

- Change Options.Streaming from bool to *bool so a zero-valued Options no
  longer forces stream=false; New only sets the key when non-nil, letting
  streaming resolve through the precedence chain (env -> config -> default
  true). This also fixes the CLI path, which never set the field
- Mirror the existing sampling-parameter pointer pattern instead of adding
  a separate StreamingSet sentinel, keeping Options internally consistent
- Update WithStreaming/NewAgent, subagent, and ACP callers to the pointer
  form; add regression tests for the nil-default and explicit opt-out paths
- Update SDK docs (README, pkg/kit/README, options page) with the ptrBool
  helper and *bool semantics

* fix(kit): inherit parent provider config in subagents

- Copy the parent's effective provider/runtime config (API key, URL,
  TLS, thinking level, max-tokens, samplers) onto child Options in
  Kit.Subagent. After the per-instance viper isolation, the child's
  isolated store only re-loaded .kit.yml / KIT_*, silently dropping
  config the parent set via programmatic Options or runtime setters
  like SetThinkingLevel
- Preserve the IsSet tri-state for max-tokens and samplers so per-model
  defaults still apply on the child when the parent left them unset
- Add TestInheritProviderConfig covering propagation, unset keys, and
  nil-safety
2026-06-02 14:41:35 +03:00

400 lines
12 KiB
Go

package kit_test
import (
"context"
"os"
"strings"
"testing"
"github.com/spf13/viper"
kit "github.com/mark3labs/kit/pkg/kit"
)
func TestNew(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
// Test default initialization
opts := &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
}
host, err := kit.New(ctx, opts)
if err != nil {
t.Fatalf("Failed to create Kit with defaults: %v", err)
}
defer func() { _ = host.Close() }()
if host.GetModelString() == "" {
t.Error("Model string should not be empty")
}
}
func TestNewWithOptions(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
opts := &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
MaxSteps: 5,
Quiet: true,
}
host, err := kit.New(ctx, opts)
if err != nil {
t.Fatalf("Failed to create Kit with options: %v", err)
}
defer func() { _ = host.Close() }()
if host.GetModelString() != opts.Model {
t.Errorf("Expected model %s, got %s", opts.Model, host.GetModelString())
}
}
// TestNewWithGenerationOptions verifies that the SDK-only generation
// parameter overrides on Options propagate all the way through to the
// agent without requiring any viper.Set workarounds in caller code.
func TestNewWithGenerationOptions(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
// MaxTokens override — keep ThinkingLevel off so Anthropic's thinking
// budget doesn't auto-bump MaxTokens above what we configured.
t.Run("MaxTokens", func(t *testing.T) {
defer resetViper()
const want = 12345
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
MaxTokens: want,
})
if err != nil {
t.Fatalf("Failed to create Kit: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.MaxTokens(); got != want {
t.Errorf("Options.MaxTokens=%d did not propagate; Kit.MaxTokens()=%d", want, got)
}
if !host.ConfigValueIsSetForTest("max-tokens") {
t.Error("max-tokens should be marked explicitly set on the instance store after MaxTokens override")
}
})
// ThinkingLevel override — verified via the public getter, which
// reads back the configured (not provider-derived) level.
t.Run("ThinkingLevel", func(t *testing.T) {
defer resetViper()
const want = "high"
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
ThinkingLevel: want,
})
if err != nil {
t.Fatalf("Failed to create Kit: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.GetThinkingLevel(); got != want {
t.Errorf("Options.ThinkingLevel=%q did not propagate; Kit.GetThinkingLevel()=%q", want, got)
}
})
// Temperature override — pointer semantics let callers distinguish
// "explicitly 0.0" from "unset", which we assert by pushing a distinct
// value and reading it back off viper's merged state.
t.Run("Temperature", func(t *testing.T) {
defer resetViper()
want := float32(0.12345)
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
Temperature: &want,
})
if err != nil {
t.Fatalf("Failed to create Kit: %v", err)
}
defer func() { _ = host.Close() }()
if !host.ConfigValueIsSetForTest("temperature") {
t.Fatal("temperature should be marked explicitly set on the instance store after Temperature override")
}
if got := float32(host.ConfigFloatForTest("temperature")); got != want {
t.Errorf("Options.Temperature=%v did not propagate; instance store=%v", want, got)
}
})
}
// TestNewPreservesIsSetSemantics verifies that creating a Kit WITHOUT
// populating the generation-param Options fields does NOT mark those
// keys as explicitly set in viper. This is the precedence contract
// that per-model defaults (ApplyModelSettings) and right-sizing
// (rightSizeMaxTokens) rely on.
//
// Previously setSDKDefaults() used viper.SetDefault() for every param,
// which caused viper.IsSet() to return true for all of them — silently
// suppressing per-model defaults and pinning max-tokens at 4096 even
// on models with much larger output limits.
func TestNewPreservesIsSetSemantics(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
defer resetViper()
ctx := context.Background()
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
SkipConfig: true, // isolate from any ~/.kit.yml values
})
if err != nil {
t.Fatalf("Failed to create Kit: %v", err)
}
defer func() { _ = host.Close() }()
// These keys must remain "unset" from viper's perspective so the
// downstream isExplicitlySet() checks allow per-model defaults to
// take effect.
checkKeys := []string{
"max-tokens",
"temperature",
"top-p",
"top-k",
"frequency-penalty",
"presence-penalty",
"thinking-level",
}
// With SkipConfig: true, InitConfig() is not invoked, so viper has
// no env-var bindings registered. Any IsSet() here would come purely
// from SDK-side SetDefault/Set calls — which is exactly what this
// test is guarding against.
for _, k := range checkKeys {
if host.ConfigValueIsSetForTest(k) {
t.Errorf("instance store reports %q explicitly set when no Options field set it "+
"(SDK defaults must not corrupt IsSet semantics)", k)
}
}
}
// TestNewWithProviderOptions verifies that programmatic provider overrides
// (API key, URL) take effect without env vars or config files, and that
// Options.ProviderAPIKey *wins* over any pre-existing viper state.
func TestNewWithProviderOptions(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
t.Run("succeeds with API key from Options", func(t *testing.T) {
defer resetViper()
apiKey := os.Getenv("ANTHROPIC_API_KEY")
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ProviderAPIKey: apiKey,
})
if err != nil {
t.Fatalf("Failed to create Kit with ProviderAPIKey option: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.ConfigStringForTest("provider-api-key"); got != apiKey {
t.Errorf("Options.ProviderAPIKey did not propagate to the instance store; got %q (len=%d)", got, len(got))
}
})
// Override precedence: even when the process-global store already holds a
// different provider-api-key value, Options.ProviderAPIKey must win on the
// Kit's isolated store.
t.Run("Options override beats pre-existing viper state", func(t *testing.T) {
defer resetViper()
viper.Set("provider-api-key", "sk-config-file-placeholder")
want := "sk-from-options-override"
// Use an OpenAI-flavored model so the validation path accepts
// the placeholder without attempting a real Anthropic handshake.
host, err := kit.New(ctx, &kit.Options{
Model: "openai/gpt-4o-mini",
Quiet: true,
NoSession: true,
NoExtensions: true,
DisableCoreTools: true,
ProviderAPIKey: want,
})
// Creation may still fail if the model registry is strict, but
// we only care that the override reached the instance store before
// any provider handshake happened.
if host == nil {
t.Fatalf("expected a Kit instance to inspect; got nil (err=%v)", err)
}
defer func() { _ = host.Close() }()
_ = err
if got := host.ConfigStringForTest("provider-api-key"); got != want {
t.Errorf("Options.ProviderAPIKey did not override pre-existing value on the instance store; got %q, want %q", got, want)
}
})
// ProviderURL override must also reach viper.
t.Run("ProviderURL propagates", func(t *testing.T) {
defer resetViper()
const want = "https://custom.example.com/v1"
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ProviderURL: want,
})
if err != nil {
t.Fatalf("Failed to create Kit with ProviderURL option: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.ConfigStringForTest("provider-url"); got != want {
t.Errorf("Options.ProviderURL did not propagate; got %q, want %q", got, want)
}
})
}
func TestSessionManagement(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
host, err := kit.New(ctx, &kit.Options{Quiet: true, NoSession: true})
if err != nil {
t.Fatalf("Failed to create Kit: %v", err)
}
defer func() { _ = host.Close() }()
// Tree session should be configured.
ts := host.GetTreeSession()
if ts == nil {
t.Fatal("Expected tree session to be configured")
}
// Test clear session resets leaf.
host.ClearSession()
// Verify session info accessors.
if id := host.GetSessionID(); id == "" {
t.Error("Expected non-empty session ID")
}
}
// resetViper wipes viper's global state so a test case doesn't leak
// viper.Set() calls into the next one. Used via defer in subtests.
func resetViper() { viper.Reset() }
// TestNewSystemPromptFilePath is a regression test for issue #25.
//
// When Options.SystemPrompt (or the --system-prompt flag / config entry) is a
// file path, Kit must resolve the path to its file contents *before* the
// PromptBuilder composes the runtime context. Previously the path string
// itself was used verbatim as the base prompt, so the LLM received the path —
// not the prompt — as its system message.
func TestNewSystemPromptFilePath(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
defer resetViper()
const promptContent = "You are a strict regression-test persona. Marker: KIT-25-OK"
tmpFile, err := os.CreateTemp(t.TempDir(), "kit-system-prompt-*.md")
if err != nil {
t.Fatalf("failed to create temp prompt file: %v", err)
}
if _, err := tmpFile.WriteString(promptContent); err != nil {
t.Fatalf("failed to write temp prompt file: %v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("failed to close temp prompt file: %v", err)
}
ctx := context.Background()
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
SystemPrompt: tmpFile.Name(),
Quiet: true,
NoSession: true,
})
if err != nil {
t.Fatalf("Failed to create Kit with system-prompt file: %v", err)
}
defer func() { _ = host.Close() }()
if !host.HasCustomSystemPrompt() {
t.Error("HasCustomSystemPrompt() = false; want true when --system-prompt is set")
}
if got, want := host.GetSystemPromptSource(), tmpFile.Name(); got != want {
t.Errorf("GetSystemPromptSource() = %q; want %q", got, want)
}
// The composed system prompt is written back to the instance store after
// PromptBuilder runs. It must contain the file's contents, not the file path.
composed := host.ConfigStringForTest("system-prompt")
if !strings.Contains(composed, promptContent) {
t.Errorf("composed system-prompt does not contain file contents\n composed = %q\n want substring = %q", composed, promptContent)
}
if strings.TrimSpace(composed) == tmpFile.Name() {
t.Errorf("composed system-prompt is the file path verbatim (%q); LoadSystemPrompt was not applied before PromptBuilder", composed)
}
}
// TestNewSystemPromptInline confirms that inline system-prompt strings still
// flow through unchanged after the file-path resolution change.
func TestNewSystemPromptInline(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
defer resetViper()
const inline = "You are a concise inline-prompt persona."
ctx := context.Background()
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
SystemPrompt: inline,
Quiet: true,
NoSession: true,
})
if err != nil {
t.Fatalf("Failed to create Kit with inline system-prompt: %v", err)
}
defer func() { _ = host.Close() }()
if !host.HasCustomSystemPrompt() {
t.Error("HasCustomSystemPrompt() = false; want true for inline prompt")
}
if got := host.GetSystemPromptSource(); got != inline {
t.Errorf("GetSystemPromptSource() = %q; want %q", got, inline)
}
if composed := host.ConfigStringForTest("system-prompt"); !strings.Contains(composed, inline) {
t.Errorf("composed system-prompt missing inline content; got %q", composed)
}
}