Files
Ed Zynda 9fbbab05f6 pkg/: simplify code without altering public API
events.go
- Delete subagentListenerSet (verbatim duplicate of eventBus); reuse
  *eventBus in SubscribeSubagent and getSubagentListenerSet

hooks.go
- Add early-exit in run() when hooks slice is empty, making all
  hasHooks() guard call sites in kit.go and compaction.go redundant

kit.go
- Remove four if m.X.hasHooks() { m.X.run(...) } outer guards
  (beforeTurn, contextPrepare, afterTurn x2); run() now short-circuits
- Replace goto drained with an idiomatic return inside default: branch
- Replace stdlib log.Printf with charmlog.Debug (charmbracelet/log),
  consistent with the rest of the codebase; remove "log" import

config.go
- Collapse single-element configNames := []string{".kit"} loop into a
  direct viper.SetConfigName call (removes slice, for, break, flag)

auth.go
- Fix GetOpenAIAPIKey: it documented OPENAI_API_KEY env var fallback but
  never called os.Getenv; now it does

compaction.go
- Extract persistAndEmitCompaction helper; eliminates duplicated
  AppendCompaction + events.emit block in compactInternal and
  applyCustomCompaction
- Replace fmt.Errorf("%s", reason) with errors.New(reason)
- Name the 16384 magic number as const defaultReserveTokens

skills.go
- Fix broken double-checked lock in DiscoverSkillsForExtension: the
  read-unlock -> write-lock gap had a TOCTOU race; replaced with a
  single write-lock covering the check and load
- Remove dead nil guard in convertSkills (convertSkill never returns nil)
- Rename convertSkills parameter skills->skillList to avoid shadowing
  the skills package import

extensions_bridge.go
- Delete taskMutex struct (sync.Mutex wrapper with map passed as param);
  replace with inline var taskMu sync.Mutex at the use site
- Simplify AgentEnd double-if into a single combined := declaration

template_bridge.go
- Fix RenderTemplate: use varRegex.ReplaceAllStringFunc instead of
  two-pass strings.ReplaceAll; handles arbitrary whitespace in {{var}}
- Remove dead isFlag function and simplify ParseArguments guard
  (the outer !HasPrefix guard made isFlag always return false)
- Cache matchModelPattern compiled regexps in a sync.Map to avoid
  repeated regexp.Compile on hot streaming paths

pkg/extensions/test/mock.go
- Remove dead local StatusBarEntry type (duplicate of extensions type,
  never referenced)
- Change make([]T, 0) to nil for nine slice fields in NewMockContext

pkg/extensions/test/harness.go
- Remove MustLoad (no callers outside the package)
- Remove extPath field (assigned but never read)
- Remove redundant os.Stat in LoadFile (os.ReadFile already errors)

events_test.go
- Add five missing event types to TestEventTypes table
  (Compaction, ReasoningDelta, ToolOutput, StepUsage, SteerConsumed)
- Expand TestEventOrdering from 11 to 16 events with the same types
- Add a got < 0 assertion to TestEventBusConcurrentSubscribeEmit so the
  test can actually fail rather than only logging
2026-03-29 12:39:19 +03:00

444 lines
11 KiB
Go

package test
import (
"sync"
"github.com/mark3labs/kit/internal/extensions"
)
// MockContext records all interactions with the extension context.
// It provides a Context object that captures Print calls, widget settings,
// and other context operations for verification in tests.
type MockContext struct {
mu sync.RWMutex
// Recorded calls
Prints []string
PrintInfos []string
PrintErrors []string
PrintBlocks []extensions.PrintBlockOpts
Messages []string
CancelSends []string
// Widget state
Widgets map[string]extensions.WidgetConfig
RemovedIDs []string
Header *extensions.HeaderFooterConfig
Footer *extensions.HeaderFooterConfig
HeaderRemoved bool
FooterRemoved bool
// Context properties
SessionID string
CWD string
Model string
Interactive bool
// UI visibility
UIVisibility *extensions.UIVisibility
// Status entries
StatusEntries map[string]extensions.StatusBarEntry
RemovedStatus []string
// Editor
EditorConfig *extensions.EditorConfig
EditorReset bool
EditorTexts []string
// Options
Options map[string]string
// Prompt results (configurable for testing)
PromptSelectResult extensions.PromptSelectResult
PromptConfirmResult extensions.PromptConfirmResult
PromptInputResult extensions.PromptInputResult
PromptMultiSelectResult extensions.PromptMultiSelectResult
// Overlay
Overlays []extensions.OverlayConfig
}
// NewMockContext creates a new mock context with default values.
func NewMockContext() *MockContext {
return &MockContext{
Widgets: make(map[string]extensions.WidgetConfig),
StatusEntries: make(map[string]extensions.StatusBarEntry),
Options: make(map[string]string),
Interactive: true,
SessionID: "test-session",
CWD: "/test",
Model: "test-model",
}
}
// ToContext returns a extensions.Context wired to record all interactions.
func (m *MockContext) ToContext() extensions.Context {
return extensions.Context{
SessionID: m.SessionID,
CWD: m.CWD,
Model: m.Model,
Interactive: m.Interactive,
Print: m.recordPrint,
PrintInfo: m.recordPrintInfo,
PrintError: m.recordPrintError,
PrintBlock: m.recordPrintBlock,
SendMessage: m.recordSendMessage,
CancelAndSend: m.recordCancelAndSend,
SetWidget: m.recordSetWidget,
RemoveWidget: m.recordRemoveWidget,
SetHeader: m.recordSetHeader,
RemoveHeader: m.recordRemoveHeader,
SetFooter: m.recordSetFooter,
RemoveFooter: m.recordRemoveFooter,
PromptSelect: m.recordPromptSelect,
PromptConfirm: m.recordPromptConfirm,
PromptInput: m.recordPromptInput,
PromptMultiSelect: m.recordPromptMultiSelect,
SetEditor: m.recordSetEditor,
ResetEditor: m.recordResetEditor,
SetEditorText: m.recordSetEditorText,
SetUIVisibility: m.recordUIVisibility,
GetContextStats: m.getContextStats,
GetMessages: m.getMessages,
GetSessionPath: m.getSessionPath,
AppendEntry: m.appendEntry,
GetEntries: m.getEntries,
SetStatus: m.recordSetStatus,
RemoveStatus: m.recordRemoveStatus,
GetOption: m.getOption,
SetOption: m.setOption,
SetModel: m.setModel,
GetAllTools: m.getAllTools,
SetActiveTools: m.setActiveTools,
Exit: m.exit,
Complete: m.complete,
SuspendTUI: m.suspendTUI,
RenderMessage: m.renderMessage,
RegisterTheme: m.registerTheme,
SetTheme: m.setTheme,
ListThemes: m.listThemes,
ReloadExtensions: m.reloadExtensions,
SpawnSubagent: m.spawnSubagent,
ShowOverlay: m.showOverlay,
}
}
// Record methods
func (m *MockContext) recordPrint(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Prints = append(m.Prints, text)
}
func (m *MockContext) recordPrintInfo(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.PrintInfos = append(m.PrintInfos, text)
}
func (m *MockContext) recordPrintError(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.PrintErrors = append(m.PrintErrors, text)
}
func (m *MockContext) recordPrintBlock(opts extensions.PrintBlockOpts) {
m.mu.Lock()
defer m.mu.Unlock()
m.PrintBlocks = append(m.PrintBlocks, opts)
}
func (m *MockContext) recordSendMessage(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Messages = append(m.Messages, text)
}
func (m *MockContext) recordCancelAndSend(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.CancelSends = append(m.CancelSends, text)
}
func (m *MockContext) recordSetWidget(config extensions.WidgetConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.Widgets[config.ID] = config
}
func (m *MockContext) recordRemoveWidget(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.Widgets, id)
m.RemovedIDs = append(m.RemovedIDs, id)
}
func (m *MockContext) recordSetHeader(config extensions.HeaderFooterConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.Header = &config
}
func (m *MockContext) recordRemoveHeader() {
m.mu.Lock()
defer m.mu.Unlock()
m.Header = nil
m.HeaderRemoved = true
}
func (m *MockContext) recordSetFooter(config extensions.HeaderFooterConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.Footer = &config
}
func (m *MockContext) recordRemoveFooter() {
m.mu.Lock()
defer m.mu.Unlock()
m.Footer = nil
m.FooterRemoved = true
}
func (m *MockContext) recordSetStatus(key string, text string, priority int) {
m.mu.Lock()
defer m.mu.Unlock()
m.StatusEntries[key] = extensions.StatusBarEntry{
Key: key,
Text: text,
Priority: priority,
}
}
func (m *MockContext) recordRemoveStatus(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.StatusEntries, key)
m.RemovedStatus = append(m.RemovedStatus, key)
}
func (m *MockContext) recordSetEditor(config extensions.EditorConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.EditorConfig = &config
}
func (m *MockContext) recordResetEditor() {
m.mu.Lock()
defer m.mu.Unlock()
m.EditorReset = true
m.EditorConfig = nil
}
func (m *MockContext) recordSetEditorText(text string) {
m.mu.Lock()
defer m.mu.Unlock()
m.EditorTexts = append(m.EditorTexts, text)
}
func (m *MockContext) recordUIVisibility(vis extensions.UIVisibility) {
m.mu.Lock()
defer m.mu.Unlock()
m.UIVisibility = &vis
}
func (m *MockContext) recordPromptSelect(config extensions.PromptSelectConfig) extensions.PromptSelectResult {
// Return the configured result (tests can set this)
return m.PromptSelectResult
}
func (m *MockContext) recordPromptConfirm(config extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
return m.PromptConfirmResult
}
func (m *MockContext) recordPromptInput(config extensions.PromptInputConfig) extensions.PromptInputResult {
return m.PromptInputResult
}
func (m *MockContext) recordPromptMultiSelect(config extensions.PromptMultiSelectConfig) extensions.PromptMultiSelectResult {
return m.PromptMultiSelectResult
}
func (m *MockContext) showOverlay(config extensions.OverlayConfig) extensions.OverlayResult {
m.mu.Lock()
defer m.mu.Unlock()
m.Overlays = append(m.Overlays, config)
return extensions.OverlayResult{Cancelled: true} // Default to cancelled for tests
}
// Stub methods that do nothing or return defaults
func (m *MockContext) getContextStats() extensions.ContextStats {
return extensions.ContextStats{
EstimatedTokens: 1000,
ContextLimit: 200000,
UsagePercent: 0.5,
MessageCount: 10,
}
}
func (m *MockContext) getMessages() []extensions.SessionMessage {
return nil
}
func (m *MockContext) getSessionPath() string {
return ""
}
func (m *MockContext) appendEntry(entryType string, data string) (string, error) {
return "", nil
}
func (m *MockContext) getEntries(entryType string) []extensions.ExtensionEntry {
return nil
}
func (m *MockContext) getOption(name string) string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.Options[name]
}
func (m *MockContext) setOption(name string, value string) {
m.mu.Lock()
defer m.mu.Unlock()
m.Options[name] = value
}
func (m *MockContext) setModel(modelString string) error {
return nil
}
func (m *MockContext) getAllTools() []extensions.ToolInfo {
return nil
}
func (m *MockContext) setActiveTools(names []string) {}
func (m *MockContext) exit() {}
func (m *MockContext) complete(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
return extensions.CompleteResponse{}, nil
}
func (m *MockContext) suspendTUI(callback func()) error {
callback()
return nil
}
func (m *MockContext) renderMessage(rendererName string, content string) {}
func (m *MockContext) registerTheme(name string, config extensions.ThemeColorConfig) {}
func (m *MockContext) setTheme(name string) error {
return nil
}
func (m *MockContext) listThemes() []string {
return nil
}
func (m *MockContext) reloadExtensions() error {
return nil
}
func (m *MockContext) spawnSubagent(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
return nil, nil, nil
}
// Accessor methods for verification
// GetPrints returns all recorded Print calls.
func (m *MockContext) GetPrints() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.Prints))
copy(result, m.Prints)
return result
}
// GetPrintInfos returns all recorded PrintInfo calls.
func (m *MockContext) GetPrintInfos() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.PrintInfos))
copy(result, m.PrintInfos)
return result
}
// GetPrintErrors returns all recorded PrintError calls.
func (m *MockContext) GetPrintErrors() []string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]string, len(m.PrintErrors))
copy(result, m.PrintErrors)
return result
}
// GetWidget returns a recorded widget by ID.
func (m *MockContext) GetWidget(id string) (extensions.WidgetConfig, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
w, ok := m.Widgets[id]
return w, ok
}
// HasWidget reports whether a widget with the given ID was set.
func (m *MockContext) HasWidget(id string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.Widgets[id]
return ok
}
// GetHeader returns the recorded header configuration.
func (m *MockContext) GetHeader() *extensions.HeaderFooterConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return m.Header
}
// GetFooter returns the recorded footer configuration.
func (m *MockContext) GetFooter() *extensions.HeaderFooterConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return m.Footer
}
// GetStatus returns a recorded status entry by key.
func (m *MockContext) GetStatus(key string) (extensions.StatusBarEntry, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.StatusEntries[key]
return s, ok
}
// SetPromptSelectResult configures the result returned by PromptSelect.
func (m *MockContext) SetPromptSelectResult(result extensions.PromptSelectResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptSelectResult = result
}
// SetPromptConfirmResult configures the result returned by PromptConfirm.
func (m *MockContext) SetPromptConfirmResult(result extensions.PromptConfirmResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptConfirmResult = result
}
// SetPromptInputResult configures the result returned by PromptInput.
func (m *MockContext) SetPromptInputResult(result extensions.PromptInputResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptInputResult = result
}
// SetPromptMultiSelectResult configures the result returned by PromptMultiSelect.
func (m *MockContext) SetPromptMultiSelectResult(result extensions.PromptMultiSelectResult) {
m.mu.Lock()
defer m.mu.Unlock()
m.PromptMultiSelectResult = result
}