Compare commits

...
15 Commits
45 changed files with 15049 additions and 115 deletions
+3
View File
@@ -266,6 +266,9 @@ archives:
- src: plugin/hooks/hooks.json
dst: hooks
strip_parent: true
- src: plugin/commands/*
dst: commands
strip_parent: true
format_overrides:
- goos: windows
formats:
+9 -5
View File
@@ -138,10 +138,13 @@ install: build stop-worker
@# Install to marketplaces directory (for direct installs)
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
@# Copy slash commands if they exist
@if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
@@ -157,14 +160,15 @@ uninstall: stop-worker
rm -rf $(HOME)/.claude/plugins/marketplaces/claude-mnemonic
@echo "Uninstallation complete!"
# Run tests
# Run tests (with FTS5 support)
test: setup-libs
go test -v -race ./...
go test $(BUILD_TAGS) -v -race ./...
# Run tests with coverage
test-coverage:
go test -v -race -coverprofile=coverage.out ./...
# Run tests with coverage (with FTS5 support)
test-coverage: setup-libs
go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@go tool cover -func=coverage.out | tail -1
# Run benchmarks
bench:
+45 -1
View File
@@ -12,6 +12,15 @@ Claude Code forgets everything when your session ends. Claude Mnemonic fixes tha
It captures what Claude learns during your coding sessions - bug fixes, architecture decisions, patterns that work - and brings that knowledge back in future conversations. No more re-explaining your codebase.
## What's New in v0.6
- **Auto-Updates** - Automatically stays up-to-date with the latest version
- **Slash Command: `/restart`** - Restart the worker directly from Claude Code
- **Local Embeddings** - All semantic search runs locally via ONNX Runtime (no external API calls)
- **Async Queue Processing** - Non-blocking observation capture for faster sessions
- **Smarter Storage** - Filters out system/agent summaries to keep knowledge relevant
- **Improved Reliability** - Better handling of connectivity issues and dead connections
## Requirements
| Dependency | Required | Purpose |
@@ -73,9 +82,11 @@ cosign verify-blob \
| **Persistent Memory** | Observations survive across sessions and restarts |
| **Project Isolation** | Each project has its own knowledge base |
| **Global Patterns** | Best practices are shared across all projects |
| **Semantic Search** | Find relevant context with natural language |
| **Semantic Search** | Find relevant context with natural language (local embeddings) |
| **Live Statusline** | Real-time metrics in Claude Code: `[mnemonic] ● served:42 | project:28 memories` |
| **Web Dashboard** | Browse and manage memories at `localhost:37777` |
| **Auto-Updates** | Automatically downloads and applies new versions |
| **Slash Commands** | Control the worker directly from Claude Code |
### How knowledge flows
@@ -131,6 +142,25 @@ These search tools are available via MCP:
- `changes` - find code modifications
- `how_it_works` - system understanding queries
## Slash Commands
Available commands within Claude Code:
| Command | Description |
|---------|-------------|
| `/restart` | Restart the worker process when experiencing issues |
## Auto-Updates
Claude Mnemonic automatically checks for updates and applies them. Updates are downloaded in the background and applied on restart.
- Automatic update checks on startup
- Background downloads (up to 250MB)
- Seamless restart after update
- Manual trigger: `curl -X POST http://127.0.0.1:37777/api/update/apply`
Check update status: `curl http://127.0.0.1:37777/api/update/status`
## Troubleshooting
**Worker won't start?**
@@ -144,6 +174,20 @@ cat /tmp/claude-mnemonic-worker.log # view logs
rm -f ~/.claude-mnemonic/*.db-wal ~/.claude-mnemonic/*.db-shm
```
**Worker unresponsive?**
```bash
# Restart via API
curl -X POST http://127.0.0.1:37777/api/restart
# Or use the slash command in Claude Code
/restart
```
**Check health status:**
```bash
curl http://127.0.0.1:37777/api/selfcheck
```
## Uninstall
```bash
+39
View File
@@ -17,11 +17,50 @@ type Input struct {
ToolUseID string `json:"tool_use_id"`
}
// skipTools lists tools that never produce useful observations.
// Skip the HTTP call entirely for these to reduce overhead during heavy tool usage.
var skipTools = map[string]bool{
// Internal tracking tools (but NOT TodoWrite - it captures planned work)
"Task": true,
"TaskOutput": true,
// File discovery tools (just listings, no insights)
"Glob": true,
"ListDir": true,
"LS": true,
"KillShell": true,
// Question/interaction tools (no code insights)
"AskUserQuestion": true,
// Plan mode tools (planning, not execution)
"EnterPlanMode": true,
"ExitPlanMode": true,
// Skill/command execution (meta-operations)
"Skill": true,
"SlashCommand": true,
// Read is high-volume and rarely produces insights worth the overhead
// The processor would skip most reads anyway after filtering
"Read": true,
// Search tools are for finding, not modifying
"Grep": true,
"WebSearch": true,
}
func main() {
hooks.RunHook("PostToolUse", handlePostToolUse)
}
func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
// Skip HTTP call entirely for tools that never produce useful observations.
// This significantly reduces overhead during heavy tool usage.
if skipTools[input.ToolName] {
return "", nil
}
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
// Send observation to worker
+2 -2
View File
@@ -13,7 +13,7 @@ require (
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.21.0
github.com/yalue/onnxruntime_go v1.25.0
)
require (
@@ -28,6 +28,6 @@ require (
github.com/schollz/progressbar/v2 v2.15.0 // indirect
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/text v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+4 -4
View File
@@ -44,15 +44,15 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
github.com/yalue/onnxruntime_go v1.21.0 h1:DdtvfY7OP5gR8mwPDqAOAQckf+KcI30hPNJL8hQaYWI=
github.com/yalue/onnxruntime_go v1.21.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yalue/onnxruntime_go v1.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g=
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+436
View File
@@ -0,0 +1,436 @@
// Package config provides configuration management for claude-mnemonic.
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// ConfigSuite is a test suite for config operations.
type ConfigSuite struct {
suite.Suite
tempDir string
origHomeDir string
}
func (s *ConfigSuite) SetupTest() {
var err error
s.tempDir, err = os.MkdirTemp("", "config-test-*")
s.Require().NoError(err)
// Save and override HOME
s.origHomeDir = os.Getenv("HOME")
os.Setenv("HOME", s.tempDir)
}
func (s *ConfigSuite) TearDownTest() {
os.Setenv("HOME", s.origHomeDir)
os.RemoveAll(s.tempDir)
}
func TestConfigSuite(t *testing.T) {
suite.Run(t, new(ConfigSuite))
}
// TestDefault tests default configuration values.
func (s *ConfigSuite) TestDefault() {
cfg := Default()
s.Equal(DefaultWorkerPort, cfg.WorkerPort)
s.Equal(DefaultModel, cfg.Model)
s.Equal(4, cfg.MaxConns)
s.Equal(100, cfg.ContextObservations)
s.Equal(25, cfg.ContextFullCount)
s.Equal(10, cfg.ContextSessionCount)
s.True(cfg.ContextShowReadTokens)
s.True(cfg.ContextShowWorkTokens)
s.Equal("narrative", cfg.ContextFullField)
s.True(cfg.ContextShowLastSummary)
s.Equal(DefaultObservationTypes, cfg.ContextObsTypes)
s.Equal(DefaultObservationConcepts, cfg.ContextObsConcepts)
}
// TestDataDir tests data directory path.
func (s *ConfigSuite) TestDataDir() {
dir := DataDir()
s.Contains(dir, ".claude-mnemonic")
}
// TestDBPath tests database path.
func (s *ConfigSuite) TestDBPath() {
path := DBPath()
s.Contains(path, "claude-mnemonic.db")
}
// TestSettingsPath tests settings file path.
func (s *ConfigSuite) TestSettingsPath() {
path := SettingsPath()
s.Contains(path, "settings.json")
}
// TestEnsureDataDir tests data directory creation.
func (s *ConfigSuite) TestEnsureDataDir() {
err := EnsureDataDir()
s.NoError(err)
dir := DataDir()
info, err := os.Stat(dir)
s.NoError(err)
s.True(info.IsDir())
}
// TestEnsureSettings tests settings file creation.
func (s *ConfigSuite) TestEnsureSettings() {
// First ensure data dir exists
err := EnsureDataDir()
s.NoError(err)
// Ensure settings creates default file
err = EnsureSettings()
s.NoError(err)
path := SettingsPath()
info, err := os.Stat(path)
s.NoError(err)
s.False(info.IsDir())
// Second call should not error (file exists)
err = EnsureSettings()
s.NoError(err)
}
// TestEnsureAll tests full initialization.
func (s *ConfigSuite) TestEnsureAll() {
err := EnsureAll()
s.NoError(err)
// Verify dir and settings exist
_, err = os.Stat(DataDir())
s.NoError(err)
_, err = os.Stat(SettingsPath())
s.NoError(err)
}
// TestLoad_TableDriven tests configuration loading with various scenarios.
func (s *ConfigSuite) TestLoad_TableDriven() {
tests := []struct {
name string
settingsJSON string
expectedPort int
expectedModel string
expectedObsObs int
}{
{
name: "no settings file",
settingsJSON: "",
expectedPort: DefaultWorkerPort,
expectedModel: DefaultModel,
expectedObsObs: 100,
},
{
name: "custom port",
settingsJSON: `{"CLAUDE_MNEMONIC_WORKER_PORT": 38888}`,
expectedPort: 38888,
expectedModel: DefaultModel,
expectedObsObs: 100,
},
{
name: "custom model",
settingsJSON: `{"CLAUDE_MNEMONIC_MODEL": "sonnet"}`,
expectedPort: DefaultWorkerPort,
expectedModel: "sonnet",
expectedObsObs: 100,
},
{
name: "custom observations",
settingsJSON: `{"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS": 200}`,
expectedPort: DefaultWorkerPort,
expectedModel: DefaultModel,
expectedObsObs: 200,
},
{
name: "multiple settings",
settingsJSON: `{"CLAUDE_MNEMONIC_WORKER_PORT": 39999, "CLAUDE_MNEMONIC_MODEL": "opus", "CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS": 50}`,
expectedPort: 39999,
expectedModel: "opus",
expectedObsObs: 50,
},
{
name: "invalid JSON returns defaults",
settingsJSON: `{invalid}`,
expectedPort: DefaultWorkerPort,
expectedModel: DefaultModel,
expectedObsObs: 100,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Create fresh temp dir
tempDir, err := os.MkdirTemp("", "config-test-*")
s.Require().NoError(err)
defer os.RemoveAll(tempDir)
os.Setenv("HOME", tempDir)
// Create data dir
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
s.Require().NoError(err)
if tt.settingsJSON != "" {
err := os.WriteFile(
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
[]byte(tt.settingsJSON),
0600,
)
s.Require().NoError(err)
}
cfg, err := Load()
s.NoError(err)
s.NotNil(cfg)
s.Equal(tt.expectedPort, cfg.WorkerPort)
s.Equal(tt.expectedModel, cfg.Model)
s.Equal(tt.expectedObsObs, cfg.ContextObservations)
})
}
}
// TestGetWorkerPort_TableDriven tests worker port retrieval with various scenarios.
func TestGetWorkerPort_TableDriven(t *testing.T) {
tests := []struct {
name string
envValue string
wantPort int
setEnv bool
}{
{
name: "no env, use default",
envValue: "",
wantPort: DefaultWorkerPort,
setEnv: false,
},
{
name: "env set to valid port",
envValue: "38888",
wantPort: 38888,
setEnv: true,
},
{
name: "env set to invalid value",
envValue: "invalid",
wantPort: DefaultWorkerPort,
setEnv: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save original env
origEnv := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT")
defer os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", origEnv)
if tt.setEnv {
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", tt.envValue)
} else {
os.Unsetenv("CLAUDE_MNEMONIC_WORKER_PORT")
}
// We can't easily test GetWorkerPort since it uses Get() which caches
// So we test the env parsing logic directly
if tt.setEnv && tt.envValue != "" {
if tt.wantPort != DefaultWorkerPort {
assert.Equal(t, tt.envValue, os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT"))
}
}
})
}
}
// TestSplitTrim tests the splitTrim helper function.
func TestSplitTrim(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "empty string",
input: "",
expected: []string{},
},
{
name: "single value",
input: "bugfix",
expected: []string{"bugfix"},
},
{
name: "multiple values",
input: "bugfix,feature,refactor",
expected: []string{"bugfix", "feature", "refactor"},
},
{
name: "values with spaces",
input: " bugfix , feature , refactor ",
expected: []string{"bugfix", "feature", "refactor"},
},
{
name: "empty values filtered",
input: "bugfix,,feature,,",
expected: []string{"bugfix", "feature"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitTrim(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestDefaultObservationTypes tests default observation types.
func TestDefaultObservationTypes(t *testing.T) {
expected := []string{
"bugfix", "feature", "refactor", "change", "discovery", "decision",
}
assert.Equal(t, expected, DefaultObservationTypes)
}
// TestDefaultObservationConcepts tests default observation concepts.
func TestDefaultObservationConcepts(t *testing.T) {
expected := []string{
"how-it-works", "why-it-exists", "what-changed",
"problem-solution", "gotcha", "pattern", "trade-off",
}
assert.Equal(t, expected, DefaultObservationConcepts)
}
// TestCriticalConcepts tests critical concepts list.
func TestCriticalConcepts(t *testing.T) {
expected := []string{
"gotcha", "pattern", "problem-solution", "trade-off",
}
assert.Equal(t, expected, CriticalConcepts)
}
// TestLoad_ClaudeCodePath tests claude code path loading.
func TestLoad_ClaudeCodePath(t *testing.T) {
// Create temp dir
tempDir, err := os.MkdirTemp("", "config-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
origHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", origHome)
// Create data dir and settings
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
require.NoError(t, err)
settingsJSON := `{"CLAUDE_CODE_PATH": "/usr/local/bin/claude"}`
err = os.WriteFile(
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
[]byte(settingsJSON),
0600,
)
require.NoError(t, err)
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/claude", cfg.ClaudeCodePath)
}
// TestGet tests the global config getter.
func TestGet(t *testing.T) {
// Save and restore HOME
origHome := os.Getenv("HOME")
tempDir, err := os.MkdirTemp("", "config-get-test-*")
require.NoError(t, err)
defer func() {
os.Setenv("HOME", origHome)
os.RemoveAll(tempDir)
}()
os.Setenv("HOME", tempDir)
// Create data dir
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
require.NoError(t, err)
// Get() should return a valid config
cfg := Get()
require.NotNil(t, cfg)
assert.Greater(t, cfg.WorkerPort, 0)
assert.NotEmpty(t, cfg.Model)
}
// TestGetWorkerPort_WithEnv tests GetWorkerPort with environment variable.
func TestGetWorkerPort_WithEnv(t *testing.T) {
// Save original env
origEnv := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT")
defer os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", origEnv)
// Test with valid port in env
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "45678")
port := GetWorkerPort()
assert.Equal(t, 45678, port)
// Test with invalid port (should fall back to config)
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "not-a-number")
port = GetWorkerPort()
// Should return from Get().WorkerPort, which is default
assert.Greater(t, port, 0)
// Test with zero port (should fall back to config)
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "0")
port = GetWorkerPort()
// Zero is invalid, so should use default
assert.Greater(t, port, 0)
// Test with no env (should use config)
os.Unsetenv("CLAUDE_MNEMONIC_WORKER_PORT")
port = GetWorkerPort()
assert.Greater(t, port, 0)
}
// TestLoad_ContextSettings tests context-related settings loading.
func TestLoad_ContextSettings(t *testing.T) {
// Create temp dir
tempDir, err := os.MkdirTemp("", "config-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
origHome := os.Getenv("HOME")
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", origHome)
// Create data dir and settings
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
require.NoError(t, err)
settingsJSON := `{
"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT": 50,
"CLAUDE_MNEMONIC_CONTEXT_SESSION_COUNT": 20,
"CLAUDE_MNEMONIC_CONTEXT_OBS_TYPES": "bugfix,feature",
"CLAUDE_MNEMONIC_CONTEXT_OBS_CONCEPTS": "security,performance"
}`
err = os.WriteFile(
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
[]byte(settingsJSON),
0600,
)
require.NoError(t, err)
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, 50, cfg.ContextFullCount)
assert.Equal(t, 20, cfg.ContextSessionCount)
assert.Equal(t, []string{"bugfix", "feature"}, cfg.ContextObsTypes)
assert.Equal(t, []string{"security", "performance"}, cfg.ContextObsConcepts)
}
+254
View File
@@ -0,0 +1,254 @@
package sqlite
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNullString(t *testing.T) {
tests := []struct {
name string
input string
expected string
valid bool
}{
{"empty_string", "", "", false},
{"non_empty_string", "hello", "hello", true},
{"whitespace", " ", " ", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := nullString(tt.input)
assert.Equal(t, tt.expected, result.String)
assert.Equal(t, tt.valid, result.Valid)
})
}
}
func TestNullInt(t *testing.T) {
tests := []struct {
name string
input int
expected int64
valid bool
}{
{"zero", 0, 0, false},
{"positive", 42, 42, true},
{"negative", -1, -1, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := nullInt(tt.input)
assert.Equal(t, tt.expected, result.Int64)
assert.Equal(t, tt.valid, result.Valid)
})
}
}
func TestRepeatPlaceholders(t *testing.T) {
tests := []struct {
name string
n int
expected string
}{
{"zero", 0, ""},
{"negative", -1, ""},
{"one", 1, ", ?"},
{"two", 2, ", ?, ?"},
{"three", 3, ", ?, ?, ?"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := repeatPlaceholders(tt.n)
assert.Equal(t, tt.expected, result)
})
}
}
func TestInt64SliceToInterface(t *testing.T) {
tests := []struct {
name string
input []int64
expected []interface{}
}{
{"empty", []int64{}, []interface{}{}},
{"single", []int64{42}, []interface{}{int64(42)}},
{"multiple", []int64{1, 2, 3}, []interface{}{int64(1), int64(2), int64(3)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := int64SliceToInterface(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseLimitParam(t *testing.T) {
tests := []struct {
name string
query string
defaultLimit int
expected int
}{
{"no_param_uses_default", "", 10, 10},
{"valid_limit", "limit=20", 10, 20},
{"invalid_limit_uses_default", "limit=abc", 10, 10},
{"zero_limit_uses_default", "limit=0", 10, 10},
{"negative_limit_uses_default", "limit=-5", 10, 10},
{"large_limit", "limit=1000", 10, 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/?"+tt.query, nil)
result := ParseLimitParam(req, tt.defaultLimit)
assert.Equal(t, tt.expected, result)
})
}
}
func TestScanSummary(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
createBaseTables(t, db)
seedSession(t, db, "claude-123", "sdk-123", "test-project")
// Insert a test summary
_, err := db.Exec(`
INSERT INTO session_summaries (sdk_session_id, project, request, investigated, learned, completed, next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch)
VALUES ('sdk-123', 'test-project', 'test request', 'test investigated', 'test learned', 'test completed', 'test next steps', 'test notes', 1, 100, '2025-01-01T00:00:00Z', 1704067200000)
`)
require.NoError(t, err)
// Query and scan
row := db.QueryRow(`
SELECT id, sdk_session_id, project, request, investigated, learned, completed, next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
FROM session_summaries WHERE sdk_session_id = ?
`, "sdk-123")
summary, err := scanSummary(row)
require.NoError(t, err)
assert.NotNil(t, summary)
assert.Equal(t, "sdk-123", summary.SDKSessionID)
assert.Equal(t, "test-project", summary.Project)
assert.Equal(t, "test request", summary.Request.String)
assert.Equal(t, "test investigated", summary.Investigated.String)
assert.Equal(t, "test learned", summary.Learned.String)
assert.Equal(t, "test completed", summary.Completed.String)
assert.Equal(t, "test next steps", summary.NextSteps.String)
assert.Equal(t, "test notes", summary.Notes.String)
}
func TestScanSummaryRows(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
createBaseTables(t, db)
seedSession(t, db, "claude-123", "sdk-123", "test-project")
// Insert multiple summaries
_, err := db.Exec(`
INSERT INTO session_summaries (sdk_session_id, project, request, investigated, learned, completed, next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch)
VALUES
('sdk-123', 'test-project', 'request 1', '', '', '', '', '', 1, 0, '2025-01-01T00:00:00Z', 1704067200000),
('sdk-123', 'test-project', 'request 2', '', '', '', '', '', 2, 0, '2025-01-02T00:00:00Z', 1704153600000)
`)
require.NoError(t, err)
rows, err := db.Query(`
SELECT id, sdk_session_id, project, request, investigated, learned, completed, next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
FROM session_summaries WHERE sdk_session_id = ? ORDER BY id
`, "sdk-123")
require.NoError(t, err)
defer rows.Close()
summaries, err := scanSummaryRows(rows)
require.NoError(t, err)
assert.Len(t, summaries, 2)
assert.Equal(t, "request 1", summaries[0].Request.String)
assert.Equal(t, "request 2", summaries[1].Request.String)
}
func TestScanPromptWithSession(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
createBaseTables(t, db)
seedSession(t, db, "claude-123", "sdk-123", "test-project")
// Insert a test prompt
_, err := db.Exec(`
INSERT INTO user_prompts (claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch)
VALUES ('claude-123', 1, 'test prompt', 5, '2025-01-01T00:00:00Z', 1704067200000)
`)
require.NoError(t, err)
// Query with session join
row := db.QueryRow(`
SELECT p.id, p.claude_session_id, p.prompt_number, p.prompt_text, p.matched_observations, p.created_at, p.created_at_epoch, s.project, s.sdk_session_id
FROM user_prompts p
JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
WHERE p.claude_session_id = ?
`, "claude-123")
prompt, err := scanPromptWithSession(row)
require.NoError(t, err)
assert.NotNil(t, prompt)
assert.Equal(t, "claude-123", prompt.ClaudeSessionID)
assert.Equal(t, 1, prompt.PromptNumber)
assert.Equal(t, "test prompt", prompt.PromptText)
assert.Equal(t, 5, prompt.MatchedObservations)
assert.Equal(t, "test-project", prompt.Project)
assert.Equal(t, "sdk-123", prompt.SDKSessionID)
}
func TestScanPromptWithSessionRows(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
createBaseTables(t, db)
seedSession(t, db, "claude-123", "sdk-123", "test-project")
// Insert multiple prompts
_, err := db.Exec(`
INSERT INTO user_prompts (claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch)
VALUES
('claude-123', 1, 'prompt one', 3, '2025-01-01T00:00:00Z', 1704067200000),
('claude-123', 2, 'prompt two', 5, '2025-01-02T00:00:00Z', 1704153600000)
`)
require.NoError(t, err)
rows, err := db.Query(`
SELECT p.id, p.claude_session_id, p.prompt_number, p.prompt_text, p.matched_observations, p.created_at, p.created_at_epoch, s.project, s.sdk_session_id
FROM user_prompts p
JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
WHERE p.claude_session_id = ? ORDER BY p.id
`, "claude-123")
require.NoError(t, err)
defer rows.Close()
prompts, err := scanPromptWithSessionRows(rows)
require.NoError(t, err)
assert.Len(t, prompts, 2)
assert.Equal(t, "prompt one", prompts[0].PromptText)
assert.Equal(t, "prompt two", prompts[1].PromptText)
}
func TestParseLimitParam_HTTPRequest(t *testing.T) {
// Test with an actual HTTP request
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := ParseLimitParam(r, 25)
if limit != 50 {
t.Errorf("Expected limit 50, got %d", limit)
}
})
req := httptest.NewRequest("GET", "http://example.com/api?limit=50", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
}
+11
View File
@@ -272,6 +272,17 @@ var Migrations = []Migration{
);
`,
},
{
Version: 18,
Name: "user_prompts_unique_constraint",
SQL: `
-- Add unique constraint to prevent duplicate prompts
-- This fixes a bug where the user-prompt hook could fire multiple times
-- creating duplicate prompt records with incrementing numbers
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_prompts_session_number_unique
ON user_prompts(claude_session_id, prompt_number);
`,
},
}
// MigrationManager handles database schema migrations.
+196
View File
@@ -0,0 +1,196 @@
package sqlite
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewMigrationManager(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
require.NotNil(t, manager)
assert.Equal(t, db, manager.db)
}
func TestMigrationManager_EnsureSchemaVersionsTable(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
// Should create table without error
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
// Table should exist
var count int
err = db.QueryRow("SELECT COUNT(*) FROM schema_versions").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count) // Empty table
// Calling again should not error (IF NOT EXISTS)
err = manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
}
func TestMigrationManager_GetAppliedVersions_Empty(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
versions, err := manager.GetAppliedVersions()
require.NoError(t, err)
assert.Empty(t, versions)
}
func TestMigrationManager_GetAppliedVersions_WithVersions(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
// Insert some versions
_, err = db.Exec("INSERT INTO schema_versions (version, applied_at) VALUES (1, '2025-01-01T00:00:00Z')")
require.NoError(t, err)
_, err = db.Exec("INSERT INTO schema_versions (version, applied_at) VALUES (2, '2025-01-02T00:00:00Z')")
require.NoError(t, err)
versions, err := manager.GetAppliedVersions()
require.NoError(t, err)
assert.Len(t, versions, 2)
assert.True(t, versions[1])
assert.True(t, versions[2])
assert.False(t, versions[3]) // Not applied
}
func TestMigrationManager_ApplyMigration(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
// Apply a simple migration
migration := Migration{
Version: 100,
Name: "test_migration",
SQL: "CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)",
}
err = manager.ApplyMigration(migration)
require.NoError(t, err)
// Verify table was created
var count int
err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='test_table'").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 1, count)
// Verify migration was recorded
var version int
err = db.QueryRow("SELECT version FROM schema_versions WHERE version = 100").Scan(&version)
require.NoError(t, err)
assert.Equal(t, 100, version)
}
func TestMigrationManager_ApplyMigration_InvalidSQL(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
// Try to apply invalid migration
migration := Migration{
Version: 100,
Name: "invalid_migration",
SQL: "INVALID SQL SYNTAX",
}
err = manager.ApplyMigration(migration)
assert.Error(t, err)
assert.Contains(t, err.Error(), "execute migration 100")
}
func TestMigrationManager_RunMigrations_SingleMigration(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
// Create a test migration manager with a subset of migrations
manager := NewMigrationManager(db)
// First ensure schema versions table exists
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
// Apply first migration manually
err = manager.ApplyMigration(Migrations[0])
require.NoError(t, err)
// Verify the first migration version was recorded
versions, err := manager.GetAppliedVersions()
require.NoError(t, err)
assert.True(t, versions[Migrations[0].Version])
}
func TestMigrationManager_RunMigrations_SkipsApplied(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
manager := NewMigrationManager(db)
err := manager.EnsureSchemaVersionsTable()
require.NoError(t, err)
// Mark some migrations as already applied
_, err = db.Exec("INSERT INTO schema_versions (version, applied_at) VALUES (4, '2025-01-01T00:00:00Z')")
require.NoError(t, err)
// Get applied versions
versions, err := manager.GetAppliedVersions()
require.NoError(t, err)
assert.True(t, versions[4])
}
func TestMigration_Struct(t *testing.T) {
m := Migration{
Version: 1,
Name: "test",
SQL: "SELECT 1",
}
assert.Equal(t, 1, m.Version)
assert.Equal(t, "test", m.Name)
assert.Equal(t, "SELECT 1", m.SQL)
}
func TestMigrations_List(t *testing.T) {
// Verify migrations are ordered correctly
assert.NotEmpty(t, Migrations)
// Verify all migrations have required fields
for i, m := range Migrations {
assert.Greater(t, m.Version, 0, "Migration %d has invalid version", i)
assert.NotEmpty(t, m.Name, "Migration %d has empty name", i)
assert.NotEmpty(t, m.SQL, "Migration %d has empty SQL", i)
}
// Verify key migrations exist
versionSet := make(map[int]bool)
for _, m := range Migrations {
versionSet[m.Version] = true
}
assert.True(t, versionSet[4], "Should have sdk_agent_architecture migration")
assert.True(t, versionSet[17], "Should have sqlite_vec_vectors migration")
}
+573
View File
@@ -9,8 +9,22 @@ import (
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// testObservationStoreBasic creates an ObservationStore with base tables (no FTS5).
func testObservationStoreBasic(t *testing.T) (*ObservationStore, *Store, func()) {
t.Helper()
db, _, cleanup := testDB(t)
createBaseTables(t, db)
store := newStoreFromDB(db)
obsStore := NewObservationStore(store)
return obsStore, store, cleanup
}
// testObservationStore creates an ObservationStore with a test database including FTS5.
func testObservationStore(t *testing.T) (*ObservationStore, *Store, func()) {
t.Helper()
@@ -24,6 +38,420 @@ func testObservationStore(t *testing.T) (*ObservationStore, *Store, func()) {
return obsStore, store, cleanup
}
// ObservationStoreSuite is a test suite for ObservationStore operations.
type ObservationStoreSuite struct {
suite.Suite
obsStore *ObservationStore
store *Store
cleanup func()
}
func (s *ObservationStoreSuite) SetupTest() {
s.obsStore, s.store, s.cleanup = testObservationStoreBasic(s.T())
}
func (s *ObservationStoreSuite) TearDownTest() {
if s.cleanup != nil {
s.cleanup()
}
}
func TestObservationStoreSuite(t *testing.T) {
suite.Run(t, new(ObservationStoreSuite))
}
// TestStoreObservation_TableDriven tests observation storage with various scenarios.
func (s *ObservationStoreSuite) TestStoreObservation_TableDriven() {
ctx := context.Background()
tests := []struct {
name string
sdkSessionID string
project string
obs *models.ParsedObservation
promptNum int
tokens int64
wantErr bool
}{
{
name: "basic discovery observation",
sdkSessionID: "session-basic",
project: "project-a",
obs: &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Test Title",
Subtitle: "Test Subtitle",
Narrative: "Test narrative content",
Facts: []string{"Fact 1", "Fact 2"},
Concepts: []string{"testing", "golang"},
},
promptNum: 1,
tokens: 100,
wantErr: false,
},
{
name: "bugfix observation",
sdkSessionID: "session-bugfix",
project: "project-b",
obs: &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Title: "Fixed null pointer",
Narrative: "Fixed null pointer exception in handler",
FilesModified: []string{"handler.go"},
},
promptNum: 2,
tokens: 50,
wantErr: false,
},
{
name: "global scope observation",
sdkSessionID: "session-global",
project: "project-c",
obs: &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Security best practice",
Narrative: "Always validate user input",
Concepts: []string{"security", "best-practice"},
},
promptNum: 1,
tokens: 75,
wantErr: false,
},
{
name: "observation with files",
sdkSessionID: "session-files",
project: "project-d",
obs: &models.ParsedObservation{
Type: models.ObsTypeFeature,
Title: "Added authentication",
Narrative: "Implemented JWT authentication",
FilesRead: []string{"config.go", "auth.go"},
FilesModified: []string{"handler.go", "middleware.go"},
FileMtimes: map[string]int64{"handler.go": 1234567890, "middleware.go": 1234567891},
},
promptNum: 3,
tokens: 200,
wantErr: false,
},
{
name: "minimal observation",
sdkSessionID: "session-minimal",
project: "project-e",
obs: &models.ParsedObservation{
Type: models.ObsTypeChange,
},
promptNum: 0,
tokens: 0,
wantErr: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
id, epoch, err := s.obsStore.StoreObservation(ctx, tt.sdkSessionID, tt.project, tt.obs, tt.promptNum, tt.tokens)
if tt.wantErr {
s.Error(err)
return
}
s.NoError(err)
s.Greater(id, int64(0))
s.Greater(epoch, int64(0))
// Retrieve and verify
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.NoError(err)
s.NotNil(retrieved)
s.Equal(id, retrieved.ID)
s.Equal(tt.project, retrieved.Project)
s.Equal(tt.obs.Type, retrieved.Type)
})
}
}
// TestGetObservationByID_NotFound tests retrieval of non-existent observation.
func (s *ObservationStoreSuite) TestGetObservationByID_NotFound() {
ctx := context.Background()
obs, err := s.obsStore.GetObservationByID(ctx, 99999)
s.NoError(err)
s.Nil(obs)
}
// TestGetRecentObservations_TableDriven tests recent observations retrieval.
func (s *ObservationStoreSuite) TestGetRecentObservations_TableDriven() {
ctx := context.Background()
// Create 15 observations
for i := 0; i < 15; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Observation " + string(rune('A'+i)),
}
_, _, err := s.obsStore.StoreObservation(ctx, "session-"+string(rune('0'+i)), "project-a", obs, i, 10)
s.NoError(err)
time.Sleep(time.Millisecond) // Ensure different timestamps
}
tests := []struct {
name string
project string
limit int
wantCount int
}{
{
name: "limit 5",
project: "project-a",
limit: 5,
wantCount: 5,
},
{
name: "limit 10",
project: "project-a",
limit: 10,
wantCount: 10,
},
{
name: "limit higher than count",
project: "project-a",
limit: 50,
wantCount: 15,
},
{
name: "different project (no results)",
project: "project-b",
limit: 10,
wantCount: 0,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
observations, err := s.obsStore.GetRecentObservations(ctx, tt.project, tt.limit)
s.NoError(err)
s.Len(observations, tt.wantCount)
})
}
}
// TestDeleteObservations_TableDriven tests observation deletion.
func (s *ObservationStoreSuite) TestDeleteObservations_TableDriven() {
ctx := context.Background()
// Create observations
var ids []int64
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "To delete " + string(rune('A'+i)),
}
id, _, err := s.obsStore.StoreObservation(ctx, "session-del", "project-del", obs, i, 10)
s.NoError(err)
ids = append(ids, id)
}
tests := []struct {
name string
toDelete []int64
wantDeleted int64
wantRemain int
}{
{
name: "delete none",
toDelete: []int64{},
wantDeleted: 0,
wantRemain: 5,
},
{
name: "delete one",
toDelete: ids[0:1],
wantDeleted: 1,
wantRemain: 4,
},
{
name: "delete remaining",
toDelete: ids[1:],
wantDeleted: 4,
wantRemain: 0,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
deleted, err := s.obsStore.DeleteObservations(ctx, tt.toDelete)
s.NoError(err)
s.Equal(tt.wantDeleted, deleted)
remaining, err := s.obsStore.GetAllRecentObservations(ctx, 100)
s.NoError(err)
s.Len(remaining, tt.wantRemain)
})
}
}
// TestGetObservationsByIDs tests retrieval by multiple IDs.
func (s *ObservationStoreSuite) TestGetObservationsByIDs() {
ctx := context.Background()
// Create observations
var ids []int64
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "By ID " + string(rune('A'+i)),
}
id, _, err := s.obsStore.StoreObservation(ctx, "session-byid", "project-byid", obs, i, 10)
s.NoError(err)
ids = append(ids, id)
time.Sleep(time.Millisecond)
}
tests := []struct {
name string
queryIDs []int64
orderBy string
limit int
wantCount int
}{
{
name: "empty IDs",
queryIDs: []int64{},
orderBy: "date_desc",
limit: 10,
wantCount: 0,
},
{
name: "single ID",
queryIDs: ids[0:1],
orderBy: "date_desc",
limit: 10,
wantCount: 1,
},
{
name: "all IDs",
queryIDs: ids,
orderBy: "date_desc",
limit: 10,
wantCount: 5,
},
{
name: "with limit less than IDs",
queryIDs: ids,
orderBy: "date_desc",
limit: 3,
wantCount: 3,
},
{
name: "ascending order",
queryIDs: ids,
orderBy: "date_asc",
limit: 10,
wantCount: 5,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
observations, err := s.obsStore.GetObservationsByIDs(ctx, tt.queryIDs, tt.orderBy, tt.limit)
if tt.wantCount == 0 {
s.NoError(err)
s.Nil(observations)
} else {
s.NoError(err)
s.Len(observations, tt.wantCount)
}
})
}
}
// TestGlobalScope tests global vs project scope.
func (s *ObservationStoreSuite) TestGlobalScope() {
ctx := context.Background()
// Create project-scoped observation
projectObs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Project specific",
Concepts: []string{"project-specific"},
}
_, _, err := s.obsStore.StoreObservation(ctx, "session-scope", "project-a", projectObs, 1, 10)
s.NoError(err)
// Create global-scoped observation (security concept triggers global)
globalObs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Global security",
Concepts: []string{"security"},
}
_, _, err = s.obsStore.StoreObservation(ctx, "session-scope", "project-a", globalObs, 2, 10)
s.NoError(err)
// Project-a should see both
resultsA, err := s.obsStore.GetRecentObservations(ctx, "project-a", 10)
s.NoError(err)
s.Len(resultsA, 2)
// Project-b should only see global
resultsB, err := s.obsStore.GetRecentObservations(ctx, "project-b", 10)
s.NoError(err)
s.Len(resultsB, 1)
s.Equal("Global security", resultsB[0].Title.String)
s.Equal(models.ScopeGlobal, resultsB[0].Scope)
}
// TestSetCleanupFunc tests the cleanup function callback.
func (s *ObservationStoreSuite) TestSetCleanupFunc() {
ctx := context.Background()
var calledWith []int64
s.obsStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
calledWith = deletedIDs
})
// Store an observation
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Test cleanup",
}
_, _, err := s.obsStore.StoreObservation(ctx, "session-cleanup", "project-cleanup", obs, 1, 10)
s.NoError(err)
// Cleanup should not have been called since nothing was deleted
s.Empty(calledWith)
}
// TestGetObservationCount tests observation counting.
func (s *ObservationStoreSuite) TestGetObservationCount() {
ctx := context.Background()
// Create observations for project-a
for i := 0; i < 5; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
}
_, _, err := s.obsStore.StoreObservation(ctx, "session-count", "project-a", obs, i, 10)
s.NoError(err)
}
// Create global observation
globalObs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Concepts: []string{"security"},
}
_, _, err := s.obsStore.StoreObservation(ctx, "session-count", "project-a", globalObs, 6, 10)
s.NoError(err)
// Project-a should count 6 (5 project + 1 global)
count, err := s.obsStore.GetObservationCount(ctx, "project-a")
s.NoError(err)
s.Equal(6, count)
// Project-b should count 1 (only global)
count, err = s.obsStore.GetObservationCount(ctx, "project-b")
s.NoError(err)
s.Equal(1, count)
}
func TestObservationStore_StoreAndRetrieve(t *testing.T) {
obsStore, _, cleanup := testObservationStore(t)
defer cleanup()
@@ -372,3 +800,148 @@ func TestExtractKeywords(t *testing.T) {
})
}
}
func TestObservationStore_GetObservationsByProjectStrict(t *testing.T) {
obsStore, _, cleanup := testObservationStore(t)
defer cleanup()
ctx := context.Background()
// Create project-scoped observation for project-a
projectObs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Project A specific",
Narrative: "Only for project-a",
Concepts: []string{"local-concept"},
}
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", projectObs, 1, 100)
require.NoError(t, err)
// Create global observation from project-a
globalObs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Global security practice",
Narrative: "Best practice for all",
Concepts: []string{"security", "best-practice"},
}
_, _, err = obsStore.StoreObservation(ctx, "session-1", "project-a", globalObs, 2, 100)
require.NoError(t, err)
// Create observation for project-b
projectBObs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Project B specific",
Narrative: "Only for project-b",
}
_, _, err = obsStore.StoreObservation(ctx, "session-1", "project-b", projectBObs, 1, 100)
require.NoError(t, err)
// GetObservationsByProjectStrict for project-a should only return project-a observations
// This is different from GetRecentObservations which includes globals from other projects
results, err := obsStore.GetObservationsByProjectStrict(ctx, "project-a", 10)
require.NoError(t, err)
assert.Len(t, results, 2) // Only observations created in project-a
// Verify both are from project-a
for _, obs := range results {
assert.Equal(t, "project-a", obs.Project)
}
// GetObservationsByProjectStrict for project-b should only return project-b observations
results, err = obsStore.GetObservationsByProjectStrict(ctx, "project-b", 10)
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, "Project B specific", results[0].Title.String)
}
func TestObservationStore_SearchObservationsFTS_EmptyQuery(t *testing.T) {
obsStore, _, cleanup := testObservationStore(t)
defer cleanup()
ctx := context.Background()
// Create an observation
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Test observation",
Narrative: "Some content here",
}
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
require.NoError(t, err)
// Search with only stop words (should return nil)
results, err := obsStore.SearchObservationsFTS(ctx, "the a an is are", "project-a", 10)
require.NoError(t, err)
assert.Nil(t, results)
// Search with empty query
results, err = obsStore.SearchObservationsFTS(ctx, "", "project-a", 10)
require.NoError(t, err)
assert.Nil(t, results)
}
func TestObservationStore_SearchObservationsFTS_DefaultLimit(t *testing.T) {
obsStore, _, cleanup := testObservationStore(t)
defer cleanup()
ctx := context.Background()
// Create observations
for i := 0; i < 15; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "Authentication test " + string(rune('A'+i)),
Narrative: "Auth related content",
}
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, i+1, 100)
require.NoError(t, err)
time.Sleep(time.Millisecond)
}
// Search with limit 0 (should default to 10)
results, err := obsStore.SearchObservationsFTS(ctx, "authentication", "project-a", 0)
require.NoError(t, err)
assert.LessOrEqual(t, len(results), 10)
// Search with negative limit (should default to 10)
results, err = obsStore.SearchObservationsFTS(ctx, "authentication", "project-a", -5)
require.NoError(t, err)
assert.LessOrEqual(t, len(results), 10)
}
func TestObservationStore_GetAllRecentObservations(t *testing.T) {
obsStore, _, cleanup := testObservationStore(t)
defer cleanup()
ctx := context.Background()
// Create observations across different projects
projects := []string{"project-a", "project-b", "project-c"}
for _, proj := range projects {
for i := 0; i < 3; i++ {
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: proj + " observation " + string(rune('A'+i)),
Narrative: "Content for " + proj,
}
_, _, err := obsStore.StoreObservation(ctx, "session-1", proj, obs, i+1, 100)
require.NoError(t, err)
time.Sleep(time.Millisecond)
}
}
// Get all recent observations
results, err := obsStore.GetAllRecentObservations(ctx, 100)
require.NoError(t, err)
assert.Len(t, results, 9) // 3 projects * 3 observations
// Verify they are in descending order by epoch
for i := 1; i < len(results); i++ {
assert.GreaterOrEqual(t, results[i-1].CreatedAtEpoch, results[i].CreatedAtEpoch)
}
// Test with limit
results, err = obsStore.GetAllRecentObservations(ctx, 5)
require.NoError(t, err)
assert.Len(t, results, 5)
}
+41 -1
View File
@@ -32,11 +32,15 @@ func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
}
// SaveUserPromptWithMatches saves a user prompt with matched observation count.
// Uses INSERT OR IGNORE to be idempotent - duplicate (session, prompt_number) pairs are silently ignored.
// This prevents duplicate prompts when the user-prompt hook fires multiple times.
func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) {
now := time.Now()
// Use INSERT OR IGNORE for idempotency - if (claude_session_id, prompt_number) already exists,
// the insert is silently ignored. This handles concurrent/duplicate hook invocations.
const query = `
INSERT INTO user_prompts
INSERT OR IGNORE INTO user_prompts
(claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?)
`
@@ -51,6 +55,17 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
id, _ := result.LastInsertId()
// If id is 0, the insert was ignored (duplicate) - fetch the existing ID
if id == 0 {
const selectQuery = `SELECT id FROM user_prompts WHERE claude_session_id = ? AND prompt_number = ?`
row := s.store.QueryRowContext(ctx, selectQuery, claudeSessionID, promptNumber)
if err := row.Scan(&id); err != nil {
return 0, err
}
// Return existing ID without triggering cleanup (already handled when first inserted)
return id, nil
}
// Cleanup old prompts beyond the global limit (async to not block handler)
go func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -184,6 +199,31 @@ func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([
return scanPromptWithSessionRows(rows)
}
// FindRecentPromptByText finds a prompt with the same text for a session within the last few seconds.
// This is used to detect duplicate hook invocations.
// Returns (promptID, promptNumber, found).
func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool) {
// Look for an existing prompt with the same text within the time window
// This catches duplicate hook invocations that happen in quick succession
const query = `
SELECT id, prompt_number FROM user_prompts
WHERE claude_session_id = ? AND prompt_text = ?
AND created_at_epoch > ?
ORDER BY created_at_epoch DESC
LIMIT 1
`
cutoff := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli()
var id int64
var promptNumber int
err := s.store.QueryRowContext(ctx, query, claudeSessionID, promptText, cutoff).Scan(&id, &promptNumber)
if err != nil {
return 0, 0, false
}
return id, promptNumber, true
}
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
const query = `
+93
View File
@@ -194,3 +194,96 @@ func TestPromptStore_GetPromptsByIDs_EmptyInput(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, prompts)
}
func TestPromptStore_FindRecentPromptByText(t *testing.T) {
promptStore, store, cleanup := testPromptStore(t)
defer cleanup()
ctx := context.Background()
// Create a session
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
// Save a prompt
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Help me fix this bug in the code", 0)
require.NoError(t, err)
// Find the prompt by text - returns (id, promptNumber, found)
id, promptNum, found := promptStore.FindRecentPromptByText(ctx, "claude-1", "Help me fix this bug in the code", 60)
assert.True(t, found, "should find the exact prompt text")
assert.Greater(t, id, int64(0))
assert.Equal(t, 1, promptNum)
// Try to find non-existent prompt
_, _, found = promptStore.FindRecentPromptByText(ctx, "claude-1", "This prompt does not exist", 60)
assert.False(t, found, "should not find non-existent prompt")
// Try with different session
_, _, found = promptStore.FindRecentPromptByText(ctx, "claude-2", "Help me fix this bug in the code", 60)
assert.False(t, found, "should not find prompt for different session")
}
func TestPromptStore_FindRecentPromptByText_WindowSeconds(t *testing.T) {
promptStore, store, cleanup := testPromptStore(t)
defer cleanup()
ctx := context.Background()
// Create a session
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
// Save a prompt with an old timestamp
oldEpoch := time.Now().Add(-2 * time.Hour).UnixMilli()
_, err := storeDB(store).Exec(`
INSERT INTO user_prompts (claude_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES (?, ?, ?, datetime('now'), ?)
`, "claude-1", 1, "Old prompt text", oldEpoch)
require.NoError(t, err)
// Search within last hour - should not find old prompt
_, _, found := promptStore.FindRecentPromptByText(ctx, "claude-1", "Old prompt text", 3600)
assert.False(t, found, "should not find prompt outside window")
// Search within last 3 hours - should find old prompt
_, _, found = promptStore.FindRecentPromptByText(ctx, "claude-1", "Old prompt text", 3*3600)
assert.True(t, found, "should find prompt within extended window")
}
func TestPromptStore_SaveMultiplePrompts(t *testing.T) {
promptStore, store, cleanup := testPromptStore(t)
defer cleanup()
ctx := context.Background()
// Create sessions
seedSession(t, storeDB(store), "claude-1", "sdk-1", "project-x")
seedSession(t, storeDB(store), "claude-2", "sdk-2", "project-y")
tests := []struct {
claudeSessionID string
promptNum int
text string
matches int
}{
{"claude-1", 1, "First prompt", 5},
{"claude-1", 2, "Second prompt", 3},
{"claude-2", 1, "Third prompt", 0},
{"claude-1", 3, "Fourth prompt", 10},
}
for _, tt := range tests {
id, err := promptStore.SaveUserPromptWithMatches(ctx, tt.claudeSessionID, tt.promptNum, tt.text, tt.matches)
require.NoError(t, err)
assert.Greater(t, id, int64(0))
}
// Verify counts
var count int
err := storeDB(store).QueryRow("SELECT COUNT(*) FROM user_prompts WHERE claude_session_id = 'claude-1'").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 3, count)
err = storeDB(store).QueryRow("SELECT COUNT(*) FROM user_prompts WHERE claude_session_id = 'claude-2'").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
+232 -1
View File
@@ -8,13 +8,14 @@ import (
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
func testSessionStore(t *testing.T) (*SessionStore, *Store, func()) {
t.Helper()
db, _, cleanup := testDB(t)
createAllTables(t, db)
createBaseTables(t, db) // Use base tables without FTS5 for session tests
store := newStoreFromDB(db)
sessionStore := NewSessionStore(store)
@@ -22,6 +23,236 @@ func testSessionStore(t *testing.T) (*SessionStore, *Store, func()) {
return sessionStore, store, cleanup
}
// SessionStoreSuite is a test suite for SessionStore operations.
type SessionStoreSuite struct {
suite.Suite
sessionStore *SessionStore
store *Store
cleanup func()
}
func (s *SessionStoreSuite) SetupTest() {
s.sessionStore, s.store, s.cleanup = testSessionStore(s.T())
}
func (s *SessionStoreSuite) TearDownTest() {
if s.cleanup != nil {
s.cleanup()
}
}
func TestSessionStoreSuite(t *testing.T) {
suite.Run(t, new(SessionStoreSuite))
}
// TestCreateSDKSession_TableDriven tests session creation with various scenarios.
func (s *SessionStoreSuite) TestCreateSDKSession_TableDriven() {
ctx := context.Background()
tests := []struct {
name string
claudeSessionID string
project string
userPrompt string
wantErr bool
wantID bool
}{
{
name: "basic session creation",
claudeSessionID: "claude-basic",
project: "project-a",
userPrompt: "hello world",
wantErr: false,
wantID: true,
},
{
name: "empty user prompt",
claudeSessionID: "claude-noprompt",
project: "project-b",
userPrompt: "",
wantErr: false,
wantID: true,
},
{
name: "long project name",
claudeSessionID: "claude-longproj",
project: "/Users/test/Documents/very/long/path/to/some/project/directory",
userPrompt: "test",
wantErr: false,
wantID: true,
},
{
name: "unicode project name",
claudeSessionID: "claude-unicode",
project: "项目名称-プロジェクト",
userPrompt: "测试 テスト",
wantErr: false,
wantID: true,
},
{
name: "special characters in prompt",
claudeSessionID: "claude-special",
project: "project-special",
userPrompt: "Fix the bug in file.go:123 with \"quotes\" and 'apostrophes'",
wantErr: false,
wantID: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
id, err := s.sessionStore.CreateSDKSession(ctx, tt.claudeSessionID, tt.project, tt.userPrompt)
if tt.wantErr {
s.Error(err)
} else {
s.NoError(err)
if tt.wantID {
s.Greater(id, int64(0))
}
// Verify created session
sess, err := s.sessionStore.GetSessionByID(ctx, id)
s.NoError(err)
s.NotNil(sess)
s.Equal(tt.claudeSessionID, sess.ClaudeSessionID)
s.Equal(tt.project, sess.Project)
s.Equal(models.SessionStatusActive, sess.Status)
}
})
}
}
// TestIdempotentSession tests that session creation is idempotent.
func (s *SessionStoreSuite) TestIdempotentSession() {
ctx := context.Background()
// Create initial session
id1, err := s.sessionStore.CreateSDKSession(ctx, "claude-idem", "project-1", "prompt-1")
s.NoError(err)
s.Greater(id1, int64(0))
// Create with same claude_session_id - should return same ID
id2, err := s.sessionStore.CreateSDKSession(ctx, "claude-idem", "project-2", "prompt-2")
s.NoError(err)
s.Equal(id1, id2)
// Verify project was updated
sess, err := s.sessionStore.GetSessionByID(ctx, id1)
s.NoError(err)
s.Equal("project-2", sess.Project)
}
// TestPromptCounterOperations tests prompt counter increment and retrieval.
func (s *SessionStoreSuite) TestPromptCounterOperations() {
ctx := context.Background()
tests := []struct {
name string
increments int
expectedCount int
}{
{
name: "no increments",
increments: 0,
expectedCount: 0,
},
{
name: "single increment",
increments: 1,
expectedCount: 1,
},
{
name: "multiple increments",
increments: 5,
expectedCount: 5,
},
{
name: "many increments",
increments: 100,
expectedCount: 100,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Create fresh session for each test
id, err := s.sessionStore.CreateSDKSession(ctx, "claude-counter-"+tt.name, "project", "")
s.NoError(err)
// Increment specified number of times
var lastCount int
for i := 0; i < tt.increments; i++ {
lastCount, err = s.sessionStore.IncrementPromptCounter(ctx, id)
s.NoError(err)
}
// Get final count
finalCount, err := s.sessionStore.GetPromptCounter(ctx, id)
s.NoError(err)
s.Equal(tt.expectedCount, finalCount)
if tt.increments > 0 {
s.Equal(tt.expectedCount, lastCount)
}
})
}
}
// TestFindAnySDKSession tests session lookup scenarios.
func (s *SessionStoreSuite) TestFindAnySDKSession_Scenarios() {
ctx := context.Background()
// Create test sessions
_, err := s.sessionStore.CreateSDKSession(ctx, "session-find-1", "project-a", "")
s.NoError(err)
_, err = s.sessionStore.CreateSDKSession(ctx, "session-find-2", "project-b", "")
s.NoError(err)
tests := []struct {
name string
claudeSessionID string
wantFound bool
wantProject string
}{
{
name: "find existing session 1",
claudeSessionID: "session-find-1",
wantFound: true,
wantProject: "project-a",
},
{
name: "find existing session 2",
claudeSessionID: "session-find-2",
wantFound: true,
wantProject: "project-b",
},
{
name: "find non-existent session",
claudeSessionID: "session-nonexistent",
wantFound: false,
},
{
name: "find with empty string",
claudeSessionID: "",
wantFound: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
sess, err := s.sessionStore.FindAnySDKSession(ctx, tt.claudeSessionID)
s.NoError(err) // FindAnySDKSession returns nil,nil for not found
if tt.wantFound {
s.NotNil(sess)
s.Equal(tt.wantProject, sess.Project)
} else {
s.Nil(sess)
}
})
}
}
func TestSessionStore_CreateSDKSession(t *testing.T) {
sessionStore, _, cleanup := testSessionStore(t)
defer cleanup()
+529
View File
@@ -0,0 +1,529 @@
// Package sqlite provides SQLite database operations for claude-mnemonic.
package sqlite
import (
"context"
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// StoreSuite is a test suite for Store operations.
type StoreSuite struct {
suite.Suite
db *sql.DB
store *Store
cleanup func()
}
// SetupTest creates a fresh database before each test.
func (s *StoreSuite) SetupTest() {
s.db, _, s.cleanup = testDB(s.T())
createBaseTables(s.T(), s.db)
s.store = newStoreFromDB(s.db)
}
// TearDownTest cleans up after each test.
func (s *StoreSuite) TearDownTest() {
if s.cleanup != nil {
s.cleanup()
}
}
func TestStoreSuite(t *testing.T) {
suite.Run(t, new(StoreSuite))
}
// TestGetStmt tests prepared statement caching.
func (s *StoreSuite) TestGetStmt() {
tests := []struct {
name string
query string
wantErr bool
}{
{
name: "valid simple query",
query: "SELECT 1",
wantErr: false,
},
{
name: "valid query with parameter",
query: "SELECT * FROM sdk_sessions WHERE id = ?",
wantErr: false,
},
{
name: "invalid query syntax",
query: "SELECT * FROM nonexistent_table WHERE",
wantErr: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
stmt, err := s.store.GetStmt(tt.query)
if tt.wantErr {
s.Error(err)
s.Nil(stmt)
} else {
s.NoError(err)
s.NotNil(stmt)
// Second call should return cached statement
stmt2, err := s.store.GetStmt(tt.query)
s.NoError(err)
s.Same(stmt, stmt2)
}
})
}
}
// TestExecContext tests query execution.
func (s *StoreSuite) TestExecContext() {
ctx := context.Background()
tests := []struct {
name string
query string
args []interface{}
wantErr bool
wantAffected int64
}{
{
name: "insert session",
query: `INSERT INTO sdk_sessions (claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status)
VALUES (?, ?, ?, datetime('now'), strftime('%s', 'now') * 1000, 'active')`,
args: []interface{}{"claude-1", "sdk-1", "test-project"},
wantErr: false,
wantAffected: 1,
},
{
name: "invalid query",
query: "INSERT INTO nonexistent_table VALUES (?)",
args: []interface{}{"test"},
wantErr: true,
wantAffected: 0,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result, err := s.store.ExecContext(ctx, tt.query, tt.args...)
if tt.wantErr {
s.Error(err)
} else {
s.NoError(err)
affected, _ := result.RowsAffected()
s.Equal(tt.wantAffected, affected)
}
})
}
}
// TestQueryContext tests query execution that returns rows.
func (s *StoreSuite) TestQueryContext() {
ctx := context.Background()
// Seed data
seedSession(s.T(), s.db, "claude-1", "sdk-1", "project-a")
tests := []struct {
name string
query string
args []interface{}
wantErr bool
wantRows int
setupFunc func()
assertFunc func(rows *sql.Rows)
}{
{
name: "query existing session",
query: "SELECT id, project FROM sdk_sessions WHERE claude_session_id = ?",
args: []interface{}{"claude-1"},
wantErr: false,
wantRows: 1,
},
{
name: "query non-existent session",
query: "SELECT id, project FROM sdk_sessions WHERE claude_session_id = ?",
args: []interface{}{"nonexistent"},
wantErr: false,
wantRows: 0,
},
{
name: "query all sessions",
query: "SELECT id, project FROM sdk_sessions",
args: nil,
wantErr: false,
wantRows: 1,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
rows, err := s.store.QueryContext(ctx, tt.query, tt.args...)
if tt.wantErr {
s.Error(err)
return
}
s.NoError(err)
defer rows.Close()
count := 0
for rows.Next() {
count++
}
s.Equal(tt.wantRows, count)
})
}
}
// TestQueryRowContext tests single row query execution.
func (s *StoreSuite) TestQueryRowContext() {
ctx := context.Background()
// Seed data
seedSession(s.T(), s.db, "claude-1", "sdk-1", "project-a")
tests := []struct {
name string
query string
args []interface{}
wantErr bool
}{
{
name: "query existing session",
query: "SELECT id FROM sdk_sessions WHERE claude_session_id = ?",
args: []interface{}{"claude-1"},
wantErr: false,
},
{
name: "query non-existent session",
query: "SELECT id FROM sdk_sessions WHERE claude_session_id = ?",
args: []interface{}{"nonexistent"},
wantErr: true, // sql.ErrNoRows
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
row := s.store.QueryRowContext(ctx, tt.query, tt.args...)
var id int64
err := row.Scan(&id)
if tt.wantErr {
s.Error(err)
} else {
s.NoError(err)
s.Greater(id, int64(0))
}
})
}
}
// TestPing tests database connection health check.
func (s *StoreSuite) TestPing() {
err := s.store.Ping()
s.NoError(err)
}
// TestDB tests getting the underlying database connection.
func (s *StoreSuite) TestDB() {
db := s.store.DB()
s.NotNil(db)
s.Same(s.db, db)
}
// TestClose tests closing the store.
func (s *StoreSuite) TestClose() {
// Create a separate store for close test
db, _, cleanup := testDB(s.T())
defer cleanup()
store := newStoreFromDB(db)
// Cache a statement first
_, err := store.GetStmt("SELECT 1")
s.NoError(err)
// Close should not error
err = store.Close()
s.NoError(err)
// Operations after close should fail
err = store.Ping()
s.Error(err)
}
// TestConcurrentStmtCache tests concurrent access to statement cache.
func (s *StoreSuite) TestConcurrentStmtCache() {
ctx := context.Background()
queries := []string{
"SELECT 1",
"SELECT 2",
"SELECT id FROM sdk_sessions",
"SELECT project FROM sdk_sessions",
}
done := make(chan struct{})
for i := 0; i < 10; i++ {
go func(i int) {
query := queries[i%len(queries)]
_, _ = s.store.GetStmt(query)
_, _ = s.store.ExecContext(ctx, "SELECT 1")
done <- struct{}{}
}(i)
}
for i := 0; i < 10; i++ {
<-done
}
}
// HelpersSuite tests helper functions.
type HelpersSuite struct {
suite.Suite
}
func TestHelpersSuite(t *testing.T) {
suite.Run(t, new(HelpersSuite))
}
func (s *HelpersSuite) TestNullString() {
tests := []struct {
name string
input string
wantStr string
wantBool bool
}{
{
name: "empty string",
input: "",
wantStr: "",
wantBool: false,
},
{
name: "non-empty string",
input: "test",
wantStr: "test",
wantBool: true,
},
{
name: "whitespace string",
input: " ",
wantStr: " ",
wantBool: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := nullString(tt.input)
s.Equal(tt.wantStr, result.String)
s.Equal(tt.wantBool, result.Valid)
})
}
}
func (s *HelpersSuite) TestNullInt() {
tests := []struct {
name string
input int
wantInt int64
wantBool bool
}{
{
name: "zero",
input: 0,
wantInt: 0,
wantBool: false,
},
{
name: "negative",
input: -1,
wantInt: -1,
wantBool: false,
},
{
name: "positive",
input: 42,
wantInt: 42,
wantBool: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := nullInt(tt.input)
s.Equal(tt.wantInt, result.Int64)
s.Equal(tt.wantBool, result.Valid)
})
}
}
func (s *HelpersSuite) TestRepeatPlaceholders() {
tests := []struct {
name string
input int
expected string
}{
{
name: "zero",
input: 0,
expected: "",
},
{
name: "negative",
input: -1,
expected: "",
},
{
name: "one",
input: 1,
expected: ", ?",
},
{
name: "three",
input: 3,
expected: ", ?, ?, ?",
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := repeatPlaceholders(tt.input)
s.Equal(tt.expected, result)
})
}
}
func (s *HelpersSuite) TestInt64SliceToInterface() {
tests := []struct {
name string
input []int64
expected int
}{
{
name: "empty slice",
input: []int64{},
expected: 0,
},
{
name: "single element",
input: []int64{42},
expected: 1,
},
{
name: "multiple elements",
input: []int64{1, 2, 3, 4, 5},
expected: 5,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := int64SliceToInterface(tt.input)
s.Len(result, tt.expected)
for i, v := range result {
s.Equal(tt.input[i], v)
}
})
}
}
// TestBuildGetByIDsQuery tests the shared query builder.
func TestBuildGetByIDsQuery(t *testing.T) {
tests := []struct {
name string
baseQuery string
ids []int64
orderBy string
limit int
wantQuery string
wantArgs int
}{
{
name: "single id, no limit, desc order",
baseQuery: "SELECT * FROM test",
ids: []int64{1},
orderBy: "date_desc",
limit: 0,
wantQuery: "SELECT * FROM test WHERE id IN (?)\n\t\tORDER BY created_at_epoch DESC",
wantArgs: 1,
},
{
name: "multiple ids with limit and asc order",
baseQuery: "SELECT * FROM test",
ids: []int64{1, 2, 3},
orderBy: "date_asc",
limit: 10,
wantQuery: "SELECT * FROM test WHERE id IN (?, ?, ?)\n\t\tORDER BY created_at_epoch ASC LIMIT ?",
wantArgs: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query, args := BuildGetByIDsQuery(tt.baseQuery, tt.ids, tt.orderBy, tt.limit)
assert.Contains(t, query, "WHERE id IN")
assert.Len(t, args, tt.wantArgs)
})
}
}
// TestEnsureSessionExists tests session auto-creation.
func TestEnsureSessionExists(t *testing.T) {
db, _, cleanup := testDB(t)
defer cleanup()
createBaseTables(t, db)
store := newStoreFromDB(db)
ctx := context.Background()
tests := []struct {
name string
sdkSessionID string
project string
setup func()
wantErr bool
}{
{
name: "create new session",
sdkSessionID: "sdk-new",
project: "project-a",
wantErr: false,
},
{
name: "session already exists",
sdkSessionID: "sdk-existing",
project: "project-b",
setup: func() {
seedSession(t, db, "sdk-existing", "sdk-existing", "project-b")
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setup != nil {
tt.setup()
}
err := EnsureSessionExists(ctx, store, tt.sdkSessionID, tt.project)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
// Verify session exists
var id int64
err := db.QueryRow("SELECT id FROM sdk_sessions WHERE sdk_session_id = ?", tt.sdkSessionID).Scan(&id)
require.NoError(t, err)
assert.Greater(t, id, int64(0))
}
})
}
}
+332
View File
@@ -0,0 +1,332 @@
package embedding
import (
"math"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestEmbeddingDim verifies the embedding dimension constant.
func TestEmbeddingDim(t *testing.T) {
assert.Equal(t, 384, EmbeddingDim)
}
// TestNewService tests creating a new embedding service.
func TestNewService(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
require.NotNil(t, svc)
defer svc.Close()
assert.NotNil(t, svc.tk)
assert.NotNil(t, svc.session)
}
// TestEmbed_SingleText tests embedding a single text.
func TestEmbed_SingleText(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
embedding, err := svc.Embed("Hello, world!")
require.NoError(t, err)
assert.Len(t, embedding, EmbeddingDim)
// Verify non-zero embedding
var sum float32
for _, v := range embedding {
sum += v * v
}
assert.Greater(t, sum, float32(0), "Embedding should not be all zeros")
}
// TestEmbed_EmptyText tests embedding an empty string.
func TestEmbed_EmptyText(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
embedding, err := svc.Embed("")
require.NoError(t, err)
assert.Len(t, embedding, EmbeddingDim)
// Empty text should return zero vector
for _, v := range embedding {
assert.Equal(t, float32(0), v)
}
}
// TestEmbed_SimilarTexts tests that similar texts produce similar embeddings.
func TestEmbed_SimilarTexts(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
emb1, err := svc.Embed("The quick brown fox jumps over the lazy dog.")
require.NoError(t, err)
emb2, err := svc.Embed("A fast brown fox leaps over a sleepy dog.")
require.NoError(t, err)
emb3, err := svc.Embed("Go programming language concurrency patterns.")
require.NoError(t, err)
// Calculate cosine similarity
sim12 := cosineSimilarity(emb1, emb2)
sim13 := cosineSimilarity(emb1, emb3)
// Similar texts should have higher similarity
assert.Greater(t, sim12, sim13, "Similar sentences should have higher similarity than dissimilar ones")
assert.Greater(t, sim12, float64(0.7), "Similar sentences should have high similarity")
}
// TestEmbedBatch_MultipleTexts tests batch embedding.
func TestEmbedBatch_MultipleTexts(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
texts := []string{
"First text about programming.",
"Second text about databases.",
"Third text about machine learning.",
}
embeddings, err := svc.EmbedBatch(texts)
require.NoError(t, err)
assert.Len(t, embeddings, len(texts))
for i, emb := range embeddings {
assert.Len(t, emb, EmbeddingDim, "Embedding %d should have correct dimension", i)
}
}
// TestEmbedBatch_EmptySlice tests batch embedding with empty slice.
func TestEmbedBatch_EmptySlice(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
embeddings, err := svc.EmbedBatch([]string{})
require.NoError(t, err)
assert.Nil(t, embeddings)
}
// TestEmbedBatch_WithEmptyTexts tests batch embedding with some empty texts.
func TestEmbedBatch_WithEmptyTexts(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
texts := []string{
"Valid text one.",
"",
"Valid text two.",
"",
}
embeddings, err := svc.EmbedBatch(texts)
require.NoError(t, err)
assert.Len(t, embeddings, 4)
// Non-empty texts should have non-zero embeddings
var sum0 float32
for _, v := range embeddings[0] {
sum0 += v * v
}
assert.Greater(t, sum0, float32(0))
// Empty texts should have zero embeddings
for _, v := range embeddings[1] {
assert.Equal(t, float32(0), v)
}
var sum2 float32
for _, v := range embeddings[2] {
sum2 += v * v
}
assert.Greater(t, sum2, float32(0))
for _, v := range embeddings[3] {
assert.Equal(t, float32(0), v)
}
}
// TestEmbedBatch_AllEmpty tests batch embedding with all empty texts.
func TestEmbedBatch_AllEmpty(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
texts := []string{"", "", ""}
embeddings, err := svc.EmbedBatch(texts)
require.NoError(t, err)
assert.Len(t, embeddings, 3)
for i, emb := range embeddings {
assert.Len(t, emb, EmbeddingDim, "Embedding %d should have correct dimension", i)
for j, v := range emb {
assert.Equal(t, float32(0), v, "Embedding %d[%d] should be zero", i, j)
}
}
}
// TestEmbed_Concurrent tests concurrent embedding calls.
func TestEmbed_Concurrent(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
var wg sync.WaitGroup
numGoroutines := 10
errors := make(chan error, numGoroutines)
embeddings := make([][]float32, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
text := "Test text for concurrent embedding test"
emb, err := svc.Embed(text)
if err != nil {
errors <- err
return
}
embeddings[idx] = emb
}(i)
}
wg.Wait()
close(errors)
for err := range errors {
t.Errorf("Concurrent embedding error: %v", err)
}
// All embeddings should be valid
for i, emb := range embeddings {
if emb != nil {
assert.Len(t, emb, EmbeddingDim, "Embedding %d should have correct dimension", i)
}
}
}
// TestEmbed_SpecialCharacters tests embedding text with special characters.
func TestEmbed_SpecialCharacters(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
texts := []string{
"Text with unicode: 你好世界 🎉",
"Text with newlines:\nLine 1\nLine 2",
"Text with tabs:\tColumn1\tColumn2",
"Text with quotes: \"quoted\" and 'single'",
"Text with code: func main() { fmt.Println(\"hello\") }",
}
for _, text := range texts {
t.Run(text[:20], func(t *testing.T) {
emb, err := svc.Embed(text)
require.NoError(t, err)
assert.Len(t, emb, EmbeddingDim)
})
}
}
// TestEmbed_LongText tests embedding long text.
func TestEmbed_LongText(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
// Create a long text (tokenizer should truncate appropriately)
longText := ""
for i := 0; i < 100; i++ {
longText += "This is a sentence to make the text very long. "
}
emb, err := svc.Embed(longText)
require.NoError(t, err)
assert.Len(t, emb, EmbeddingDim)
}
// TestClose tests closing the service.
func TestClose(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
err = svc.Close()
require.NoError(t, err)
// Session should be nil after close
assert.Nil(t, svc.session)
}
// TestEmbedBatch_SingleItem tests batch embedding with single item.
func TestEmbedBatch_SingleItem(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
texts := []string{"Single text for batch embedding."}
embeddings, err := svc.EmbedBatch(texts)
require.NoError(t, err)
assert.Len(t, embeddings, 1)
assert.Len(t, embeddings[0], EmbeddingDim)
}
// TestEmbed_Deterministic tests that embedding is deterministic.
func TestEmbed_Deterministic(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
text := "Test text for deterministic embedding."
emb1, err := svc.Embed(text)
require.NoError(t, err)
emb2, err := svc.Embed(text)
require.NoError(t, err)
// Same text should produce same embedding
for i := 0; i < EmbeddingDim; i++ {
assert.Equal(t, emb1[i], emb2[i], "Embedding should be deterministic at index %d", i)
}
}
// Helper function to calculate cosine similarity
func cosineSimilarity(a, b []float32) float64 {
if len(a) != len(b) {
return 0
}
var dotProduct float64
var normA float64
var normB float64
for i := range a {
dotProduct += float64(a[i]) * float64(b[i])
normA += float64(a[i]) * float64(a[i])
normB += float64(b[i]) * float64(b[i])
}
if normA == 0 || normB == 0 {
return 0
}
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
}
File diff suppressed because it is too large Load Diff
+646
View File
@@ -0,0 +1,646 @@
// Package search provides unified search capabilities for claude-mnemonic.
package search
import (
"context"
"database/sql"
"os"
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
// Import sqlite driver
_ "github.com/mattn/go-sqlite3"
)
// Ensure context is used (for later tests)
var _ = context.Background
// hasFTS5 checks if FTS5 is available in the SQLite build.
func hasFTS5(t *testing.T) bool {
t.Helper()
tmpDir, err := os.MkdirTemp("", "fts5-check-*")
if err != nil {
return false
}
defer func() { _ = os.RemoveAll(tmpDir) }()
dbPath := tmpDir + "/check.db"
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return false
}
defer func() { _ = db.Close() }()
_, err = db.Exec("CREATE VIRTUAL TABLE IF NOT EXISTS fts5_test USING fts5(content)")
if err != nil {
return false
}
_, _ = db.Exec("DROP TABLE IF EXISTS fts5_test")
return true
}
// testStore creates a sqlite.Store with a temporary database for testing.
func testStore(t *testing.T) (*sqlite.Store, func()) {
t.Helper()
if !hasFTS5(t) {
t.Skip("FTS5 not available in this SQLite build")
}
tmpDir, err := os.MkdirTemp("", "search-integration-test-*")
require.NoError(t, err)
dbPath := tmpDir + "/test.db"
store, err := sqlite.NewStore(sqlite.StoreConfig{
Path: dbPath,
MaxConns: 1,
WALMode: true,
})
require.NoError(t, err)
cleanup := func() {
_ = store.Close()
_ = os.RemoveAll(tmpDir)
}
return store, cleanup
}
// SearchIntegrationSuite tests search with real SQLite stores.
type SearchIntegrationSuite struct {
suite.Suite
store *sqlite.Store
cleanup func()
manager *Manager
obsStore *sqlite.ObservationStore
sumStore *sqlite.SummaryStore
prmStore *sqlite.PromptStore
}
func (s *SearchIntegrationSuite) SetupTest() {
if !hasFTS5(s.T()) {
s.T().Skip("FTS5 not available in this SQLite build")
}
s.store, s.cleanup = testStore(s.T())
// Create real stores backed by SQLite
s.obsStore = sqlite.NewObservationStore(s.store)
s.sumStore = sqlite.NewSummaryStore(s.store)
s.prmStore = sqlite.NewPromptStore(s.store)
// Create search manager with real stores (no vector client for now)
s.manager = NewManager(s.obsStore, s.sumStore, s.prmStore, nil)
}
func (s *SearchIntegrationSuite) TearDownTest() {
if s.cleanup != nil {
s.cleanup()
}
}
func TestSearchIntegrationSuite(t *testing.T) {
suite.Run(t, new(SearchIntegrationSuite))
}
// seedObservations inserts test observations into the database.
func (s *SearchIntegrationSuite) seedObservations(ctx context.Context) []int64 {
var ids []int64
// Observation 1: Authentication bug fix
obs1 := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Scope: models.ScopeProject,
Title: "Fixed Authentication Bug",
Narrative: "Resolved JWT token validation issue that caused intermittent login failures",
Concepts: []string{"authentication", "jwt", "security"},
FilesRead: []string{"auth/handler.go", "auth/jwt.go"},
}
id1, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-1", "test-project", obs1, 1, 100)
s.Require().NoError(err)
ids = append(ids, id1)
// Observation 2: Database optimization decision
obs2 := &models.ParsedObservation{
Type: models.ObsTypeDecision,
Scope: models.ScopeProject,
Title: "Database Query Optimization Decision",
Narrative: "Decided to add indexes on user_id and created_at columns for better performance",
Concepts: []string{"database", "performance", "decision"},
FilesRead: []string{"db/migrations/001.sql"},
}
id2, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-1", "test-project", obs2, 2, 150)
s.Require().NoError(err)
ids = append(ids, id2)
// Observation 3: Global best practice
obs3 := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Scope: models.ScopeGlobal,
Title: "Error Handling Best Practice",
Narrative: "Use wrapped errors with context for better debugging: errors.Wrap(err, context)",
Concepts: []string{"best-practice", "errors", "patterns"},
FilesRead: []string{"pkg/errors/errors.go"},
}
id3, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-2", "other-project", obs3, 1, 80)
s.Require().NoError(err)
ids = append(ids, id3)
// Observation 4: Code change/refactoring
obs4 := &models.ParsedObservation{
Type: models.ObsTypeChange,
Scope: models.ScopeProject,
Title: "Refactored User Service",
Narrative: "Changed user service to use repository pattern, modified interfaces for better testability",
Concepts: []string{"refactoring", "architecture"},
FilesModified: []string{"services/user.go", "services/user_test.go"},
}
id4, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-1", "test-project", obs4, 3, 200)
s.Require().NoError(err)
ids = append(ids, id4)
return ids
}
// seedSummaries inserts test session summaries into the database.
func (s *SearchIntegrationSuite) seedSummaries(ctx context.Context) []int64 {
var ids []int64
// Summary 1
sum1 := &models.ParsedSummary{
Request: "Fix authentication bug",
Investigated: "JWT token validation and session handling",
Learned: "JWT validation requires algorithm check to prevent alg:none attacks",
Completed: "Fixed JWT validation, added tests",
NextSteps: "Review other security endpoints",
}
id1, _, err := s.sumStore.StoreSummary(ctx, "sdk-sess-1", "test-project", sum1, 1, 100)
s.Require().NoError(err)
ids = append(ids, id1)
// Summary 2
sum2 := &models.ParsedSummary{
Request: "Optimize database queries",
Investigated: "Query execution plans and index usage",
Learned: "Composite indexes work better for range queries",
Completed: "Added indexes, verified performance improvement",
NextSteps: "Monitor query times in production",
}
id2, _, err := s.sumStore.StoreSummary(ctx, "sdk-sess-1", "test-project", sum2, 2, 150)
s.Require().NoError(err)
ids = append(ids, id2)
return ids
}
// TestFilterSearch_WithRealStores tests filterSearch with seeded data.
func (s *SearchIntegrationSuite) TestFilterSearch_WithRealStores() {
ctx := context.Background()
// Seed test data
obsIDs := s.seedObservations(ctx)
sumIDs := s.seedSummaries(ctx)
s.Require().Len(obsIDs, 4)
s.Require().Len(sumIDs, 2)
// Test filter search for observations only
result, err := s.manager.filterSearch(ctx, SearchParams{
Project: "test-project",
Type: "observations",
Limit: 10,
Format: "full",
})
s.Require().NoError(err)
s.NotNil(result)
// Should return project observations + global observation (4 total: 3 project + 1 global)
s.GreaterOrEqual(len(result.Results), 3)
// Verify result types
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestFilterSearch_SessionsOnly tests filterSearch for sessions.
func (s *SearchIntegrationSuite) TestFilterSearch_SessionsOnly() {
ctx := context.Background()
// Seed test data
_ = s.seedObservations(ctx)
sumIDs := s.seedSummaries(ctx)
s.Require().Len(sumIDs, 2)
// Test filter search for sessions only
result, err := s.manager.filterSearch(ctx, SearchParams{
Project: "test-project",
Type: "sessions",
Limit: 10,
Format: "full",
})
s.Require().NoError(err)
s.NotNil(result)
// Should return 2 summaries
s.Len(result.Results, 2)
// Verify result types
for _, r := range result.Results {
s.Equal("session", r.Type)
s.NotEmpty(r.Title) // Title should be populated from Request
}
}
// TestFilterSearch_AllTypes tests filterSearch for all types.
func (s *SearchIntegrationSuite) TestFilterSearch_AllTypes() {
ctx := context.Background()
// Seed test data
obsIDs := s.seedObservations(ctx)
sumIDs := s.seedSummaries(ctx)
s.Require().Len(obsIDs, 4)
s.Require().Len(sumIDs, 2)
// Test filter search for all types (Type = "")
result, err := s.manager.filterSearch(ctx, SearchParams{
Project: "test-project",
Type: "", // All types
Limit: 20,
Format: "full",
})
s.Require().NoError(err)
s.NotNil(result)
// Should return both observations and sessions
hasObservations := false
hasSessions := false
for _, r := range result.Results {
if r.Type == "observation" {
hasObservations = true
}
if r.Type == "session" {
hasSessions = true
}
}
s.True(hasObservations, "Should have observation results")
s.True(hasSessions, "Should have session results")
}
// TestUnifiedSearch_DefaultLimit tests UnifiedSearch with default limit.
func (s *SearchIntegrationSuite) TestUnifiedSearch_DefaultLimit() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
s.seedSummaries(ctx)
// Test with no limit specified (should default to 20)
result, err := s.manager.UnifiedSearch(ctx, SearchParams{
Project: "test-project",
})
s.Require().NoError(err)
s.NotNil(result)
s.LessOrEqual(len(result.Results), 20)
}
// TestUnifiedSearch_LimitCapping tests UnifiedSearch limit capping.
func (s *SearchIntegrationSuite) TestUnifiedSearch_LimitCapping() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
s.seedSummaries(ctx)
// Test with limit > 100 (should be capped to 100)
result, err := s.manager.UnifiedSearch(ctx, SearchParams{
Project: "test-project",
Limit: 500,
})
s.Require().NoError(err)
s.NotNil(result)
s.LessOrEqual(len(result.Results), 100)
}
// TestDecisions_WithRealStores tests the Decisions method falls back to filterSearch.
func (s *SearchIntegrationSuite) TestDecisions_WithRealStores() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
// Test Decisions search (without vector client, falls back to filterSearch)
result, err := s.manager.Decisions(ctx, SearchParams{
Project: "test-project",
Query: "database",
Limit: 10,
})
s.Require().NoError(err)
s.NotNil(result)
// Without vector client, falls back to filterSearch which returns observations
// All results should be observations (type is forced to "observations" in Decisions)
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestChanges_WithRealStores tests the Changes method falls back to filterSearch.
func (s *SearchIntegrationSuite) TestChanges_WithRealStores() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
// Test Changes search (without vector client, falls back to filterSearch)
result, err := s.manager.Changes(ctx, SearchParams{
Project: "test-project",
Query: "user service",
Limit: 10,
})
s.Require().NoError(err)
s.NotNil(result)
// Without vector client, falls back to filterSearch which returns observations
// All results should be observations (type is forced to "observations" in Changes)
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestHowItWorks_WithRealStores tests the HowItWorks method falls back to filterSearch.
func (s *SearchIntegrationSuite) TestHowItWorks_WithRealStores() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
// Test HowItWorks search (without vector client, falls back to filterSearch)
result, err := s.manager.HowItWorks(ctx, SearchParams{
Project: "test-project",
Query: "authentication",
Limit: 10,
})
s.Require().NoError(err)
s.NotNil(result)
// Without vector client, falls back to filterSearch which returns observations
// All results should be observations (type is forced to "observations" in HowItWorks)
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestObservationToResult tests observation to result conversion with full format.
func (s *SearchIntegrationSuite) TestObservationToResult_FullFormat() {
ctx := context.Background()
// Insert single observation
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Scope: models.ScopeProject,
Title: "Test Title",
Narrative: "Detailed narrative content for testing",
Concepts: []string{"testing", "content"},
}
id, _, err := s.obsStore.StoreObservation(ctx, "sdk-test", "test-project", obs, 1, 50)
s.Require().NoError(err)
// Retrieve and convert
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.Require().NoError(err)
s.Require().NotNil(retrieved)
result := s.manager.observationToResult(retrieved, "full")
s.Equal("observation", result.Type)
s.Equal(id, result.ID)
s.Equal("Test Title", result.Title)
s.Equal("Detailed narrative content for testing", result.Content)
s.Equal("test-project", result.Project)
s.Equal("project", result.Scope)
s.NotNil(result.Metadata)
s.Equal("discovery", result.Metadata["obs_type"])
}
// TestObservationToResult_IndexFormat tests index format (no content).
func (s *SearchIntegrationSuite) TestObservationToResult_IndexFormat() {
ctx := context.Background()
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Scope: models.ScopeGlobal,
Title: "Bug Fix Title",
Narrative: "This should not appear in index format",
}
id, _, err := s.obsStore.StoreObservation(ctx, "sdk-test", "test-project", obs, 1, 50)
s.Require().NoError(err)
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.Require().NoError(err)
result := s.manager.observationToResult(retrieved, "index")
s.Equal("observation", result.Type)
s.Equal("Bug Fix Title", result.Title)
s.Empty(result.Content, "Index format should not include content")
s.Equal("global", result.Scope)
}
// TestSummaryToResult_FullFormat tests summary to result conversion.
func (s *SearchIntegrationSuite) TestSummaryToResult_FullFormat() {
ctx := context.Background()
sum := &models.ParsedSummary{
Request: "Implement new feature",
Learned: "Learned important lessons about testing",
}
id, _, err := s.sumStore.StoreSummary(ctx, "sdk-test", "test-project", sum, 1, 50)
s.Require().NoError(err)
// Retrieve via GetRecentSummaries since there's no GetByID
summaries, err := s.sumStore.GetRecentSummaries(ctx, "test-project", 10)
s.Require().NoError(err)
s.Require().NotEmpty(summaries)
var retrieved *models.SessionSummary
for _, s := range summaries {
if s.ID == id {
retrieved = s
break
}
}
s.Require().NotNil(retrieved)
result := s.manager.summaryToResult(retrieved, "full")
s.Equal("session", result.Type)
s.Equal(id, result.ID)
s.Contains(result.Title, "Implement new feature")
s.Equal("Learned important lessons about testing", result.Content)
s.Equal("test-project", result.Project)
}
// TestPromptToResult_FullFormat tests prompt to result conversion.
func (s *SearchIntegrationSuite) TestPromptToResult_FullFormat() {
// First create a session
ctx := context.Background()
sessionStore := sqlite.NewSessionStore(s.store)
_, err := sessionStore.CreateSDKSession(ctx, "sdk-prompt-test", "test-project", "initial prompt")
s.Require().NoError(err)
// Save a user prompt
promptID, err := s.prmStore.SaveUserPromptWithMatches(ctx, "sdk-prompt-test", 1, "Help me fix this authentication bug", 3)
s.Require().NoError(err)
// Retrieve prompts
prompts, err := s.prmStore.GetPromptsByIDs(ctx, []int64{promptID}, "date_desc", 10)
s.Require().NoError(err)
s.Require().NotEmpty(prompts)
result := s.manager.promptToResult(prompts[0], "full")
s.Equal("prompt", result.Type)
s.Equal(promptID, result.ID)
s.Contains(result.Title, "Help me fix")
s.Equal("Help me fix this authentication bug", result.Content)
}
// TestTruncate_TableDriven tests truncation with various inputs.
func TestTruncate_TableDriven(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
}{
{"short_string", "hello", 10, "hello"},
{"exact_length", "hello", 5, "hello"},
{"long_string", "hello world", 5, "hello..."},
{"empty_string", "", 10, ""},
{"whitespace_only", " ", 10, ""},
{"with_leading_space", " hello ", 10, "hello"},
{"very_long", "this is a very long string that should be truncated", 20, "this is a very long ..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncate(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
// TestManagerWithNilStores tests that Manager handles nil stores gracefully.
func TestManagerWithNilStores(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
assert.NotNil(t, m)
assert.Nil(t, m.observationStore)
assert.Nil(t, m.summaryStore)
assert.Nil(t, m.promptStore)
assert.Nil(t, m.vectorClient)
}
// TestSearchResultMetadataFields tests all metadata fields with real data.
func (s *SearchIntegrationSuite) TestSearchResultMetadataFields() {
ctx := context.Background()
obs := &models.ParsedObservation{
Type: models.ObsTypeDecision,
Scope: models.ScopeGlobal,
Title: "Architecture Decision",
Concepts: []string{"auth", "security"},
FilesRead: []string{"handler.go", "auth.go"},
}
id, _, err := s.obsStore.StoreObservation(ctx, "sdk-meta-test", "test-project", obs, 1, 50)
s.Require().NoError(err)
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.Require().NoError(err)
result := s.manager.observationToResult(retrieved, "full")
// Check metadata fields
s.NotNil(result.Metadata)
s.Equal("decision", result.Metadata["obs_type"])
s.Equal("global", result.Metadata["scope"])
s.Equal("global", result.Scope)
}
// TestObservationToResult_AllFormats tests different format options.
func TestObservationToResult_AllFormats(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
obs := &models.Observation{
ID: 1,
Project: "test",
Type: models.ObsTypeBugfix,
Scope: models.ScopeProject,
Title: sql.NullString{String: "Bug Fix Title", Valid: true},
Narrative: sql.NullString{String: "Detailed bug fix narrative", Valid: true},
CreatedAtEpoch: 1704067200000,
}
tests := []struct {
name string
format string
expectContent bool
}{
{"full_format", "full", true},
{"index_format", "index", false},
{"empty_format", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.observationToResult(obs, tt.format)
assert.Equal(t, "observation", result.Type)
assert.Equal(t, int64(1), result.ID)
if tt.expectContent {
assert.NotEmpty(t, result.Content)
}
})
}
}
// TestSummaryToResult_AllFormats tests different format options for summaries.
func TestSummaryToResult_AllFormats(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
summary := &models.SessionSummary{
ID: 1,
Project: "test",
Request: sql.NullString{String: "Test request", Valid: true},
Learned: sql.NullString{String: "Test learned", Valid: true},
Completed: sql.NullString{String: "Test completed", Valid: true},
NextSteps: sql.NullString{String: "Test next steps", Valid: true},
CreatedAtEpoch: 1704067200000,
}
tests := []struct {
name string
format string
expectContent bool
}{
{"full_format", "full", true},
{"index_format", "index", false},
{"empty_format", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.summaryToResult(summary, tt.format)
assert.Equal(t, "session", result.Type)
assert.Equal(t, int64(1), result.ID)
if tt.expectContent {
assert.NotEmpty(t, result.Content)
}
})
}
}
File diff suppressed because it is too large Load Diff
+114 -53
View File
@@ -27,7 +27,7 @@ const (
GitHubRepo = "lukaszraczylo/claude-mnemonic"
ReleasesAPI = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest"
CheckInterval = 24 * time.Hour
MaxExtractedSize = 100 * 1024 * 1024 // 100MB max per extracted file
MaxExtractedSize = 250 * 1024 * 1024 // 250MB max per extracted file
RestartDelay = 500 * time.Millisecond
)
@@ -49,22 +49,36 @@ type Asset struct {
// UpdateInfo contains information about an available update.
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
ChecksumsURL string `json:"checksums_url,omitempty"`
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
Available bool `json:"available"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
ChecksumsURL string `json:"checksums_url,omitempty"`
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
}
// InstallScriptURL is the URL to the remote installation script.
const InstallScriptURL = "https://raw.githubusercontent.com/" + GitHubRepo + "/main/scripts/install.sh"
// GetManualUpdateCommand returns the curl command for manual update.
// If version is empty, it installs the latest version.
func GetManualUpdateCommand(version string) string {
if version == "" {
return fmt.Sprintf("curl -sSL %s | bash", InstallScriptURL)
}
return fmt.Sprintf("curl -sSL %s | bash -s -- %s", InstallScriptURL, version)
}
// UpdateStatus represents the current update status.
type UpdateStatus struct {
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
Progress float64 `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
Progress float64 `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
ManualUpdateCommand string `json:"manual_update_command,omitempty"` // Shown when update fails
}
// Updater handles self-updates.
@@ -112,9 +126,10 @@ func (u *Updater) setError(err error) {
u.mu.Lock()
defer u.mu.Unlock()
u.status = UpdateStatus{
State: "error",
Message: "Update failed",
Error: err.Error(),
State: "error",
Message: "Update failed",
Error: err.Error(),
ManualUpdateCommand: GetManualUpdateCommand(""), // Always provide fallback command
}
}
@@ -169,6 +184,9 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) {
// Compare versions
info.Available = isNewerVersion(info.LatestVersion, u.currentVersion)
// Always include manual update command as an alternative option
info.ManualUpdateCommand = GetManualUpdateCommand("v" + info.LatestVersion)
if info.Available {
// Find download URLs for current platform
platform := getPlatform()
@@ -451,57 +469,100 @@ func (u *Updater) extractTarGz(archivePath, destDir string) error {
}
func (u *Updater) replaceBinaries(extractDir string) error {
// Files to replace
binaries := []struct {
src string
dest string
}{
{"worker", filepath.Join(u.installDir, "worker")},
{"mcp-server", filepath.Join(u.installDir, "mcp-server")},
{"hooks/session-start", filepath.Join(u.installDir, "hooks", "session-start")},
{"hooks/user-prompt", filepath.Join(u.installDir, "hooks", "user-prompt")},
{"hooks/post-tool-use", filepath.Join(u.installDir, "hooks", "post-tool-use")},
{"hooks/stop", filepath.Join(u.installDir, "hooks", "stop")},
{"hooks/subagent-stop", filepath.Join(u.installDir, "hooks", "subagent-stop")},
// Binary files to replace
binaryFiles := []string{
"worker",
"mcp-server",
"hooks/session-start",
"hooks/user-prompt",
"hooks/post-tool-use",
"hooks/stop",
"hooks/subagent-stop",
"hooks/statusline",
}
for _, b := range binaries {
src := filepath.Join(extractDir, b.src)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue // Skip if not in archive
}
// Get all install directories (marketplaces and cache directories)
installDirs := u.getInstallDirectories()
// Backup existing binary
if _, err := os.Stat(b.dest); err == nil {
backup := b.dest + ".bak"
if err := os.Rename(b.dest, backup); err != nil {
return fmt.Errorf("failed to backup %s: %w", b.dest, err)
for _, installDir := range installDirs {
log.Debug().Str("dir", installDir).Msg("Installing binaries to directory")
for _, binaryFile := range binaryFiles {
src := filepath.Join(extractDir, binaryFile)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue // Skip if not in archive
}
defer func(backup, dest string) {
// Clean up backup on success, restore on failure
if _, err := os.Stat(dest); err == nil {
_ = os.Remove(backup)
dest := filepath.Join(installDir, binaryFile)
// Backup existing binary
if _, err := os.Stat(dest); err == nil {
backup := dest + ".bak"
if err := os.Rename(dest, backup); err != nil {
log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway")
} else {
_ = os.Rename(backup, dest)
defer func(backup, dest string) {
// Clean up backup on success, restore on failure
if _, err := os.Stat(dest); err == nil {
_ = os.Remove(backup)
} else {
_ = os.Rename(backup, dest)
}
}(backup, dest)
}
}(backup, b.dest)
}
}
// Copy new binary
if err := copyFile(src, b.dest); err != nil {
return fmt.Errorf("failed to install %s: %w", b.dest, err)
}
// Copy new binary
if err := copyFile(src, dest); err != nil {
return fmt.Errorf("failed to install %s: %w", dest, err)
}
// Make executable
// #nosec G302 -- executables require 0755 permissions
if err := os.Chmod(b.dest, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", b.dest, err)
// Make executable
// #nosec G302 -- executables require 0755 permissions
if err := os.Chmod(dest, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", dest, err)
}
}
}
return nil
}
// getInstallDirectories returns all directories where binaries should be installed.
// This includes the marketplaces directory and any cache directories.
func (u *Updater) getInstallDirectories() []string {
dirs := []string{u.installDir}
// Also check cache directories where Claude Code looks for plugins
home, err := os.UserHomeDir()
if err != nil {
return dirs
}
// Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
entries, err := os.ReadDir(cacheBase)
if err != nil {
return dirs
}
for _, entry := range entries {
if entry.IsDir() {
cacheDir := filepath.Join(cacheBase, entry.Name())
// Only add if it's different from installDir and contains a worker binary
if cacheDir != u.installDir {
workerPath := filepath.Join(cacheDir, "worker")
if _, err := os.Stat(workerPath); err == nil {
dirs = append(dirs, cacheDir)
log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update")
}
}
}
}
return dirs
}
func copyFile(src, dst string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
+502
View File
@@ -0,0 +1,502 @@
package sqlitevec
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testDB creates a test SQLite database with the vectors table.
func testDB(t *testing.T) (*sql.DB, func()) {
t.Helper()
// Create temp directory
tmpDir, err := os.MkdirTemp("", "sqlitevec-test-*")
require.NoError(t, err)
dbPath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
// Enable sqlite-vec
sqlite_vec.Auto()
// Create vectors table (matches production schema)
_, err = db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS vectors USING vec0(
doc_id TEXT PRIMARY KEY,
embedding float[384],
sqlite_id INTEGER,
doc_type TEXT,
field_type TEXT,
project TEXT,
scope TEXT
)
`)
require.NoError(t, err)
cleanup := func() {
db.Close()
os.RemoveAll(tmpDir)
}
return db, cleanup
}
// testEmbeddingService creates a test embedding service.
func testEmbeddingService(t *testing.T) (*embedding.Service, func()) {
t.Helper()
svc, err := embedding.NewService()
require.NoError(t, err)
cleanup := func() {
svc.Close()
}
return svc, cleanup
}
func TestNewClient_Success(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
assert.NotNil(t, client)
}
func TestNewClient_NilDB(t *testing.T) {
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: nil}, embedSvc)
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "database connection required")
}
func TestNewClient_NilEmbedding(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
client, err := NewClient(Config{DB: db}, nil)
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "embedding service required")
}
func TestClient_AddDocuments_Empty(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
err = client.AddDocuments(context.Background(), []Document{})
require.NoError(t, err)
}
func TestClient_AddDocuments_Single(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
docs := []Document{
{
ID: "obs-1-title",
Content: "This is a test observation about authentication.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
"field_type": "title",
"project": "test-project",
"scope": "project",
},
},
}
err = client.AddDocuments(context.Background(), docs)
require.NoError(t, err)
// Verify document was inserted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM vectors WHERE doc_id = ?", "obs-1-title").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
func TestClient_AddDocuments_Multiple(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
docs := []Document{
{
ID: "obs-1-title",
Content: "Authentication flow implementation.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
"field_type": "title",
"project": "test-project",
"scope": "project",
},
},
{
ID: "obs-1-narrative",
Content: "We implemented JWT-based authentication.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
"field_type": "narrative",
"project": "test-project",
"scope": "project",
},
},
{
ID: "obs-2-title",
Content: "Database optimization.",
Metadata: map[string]any{
"sqlite_id": int64(2),
"doc_type": "observation",
"field_type": "title",
"project": "test-project",
"scope": "global",
},
},
}
err = client.AddDocuments(context.Background(), docs)
require.NoError(t, err)
// Verify all documents were inserted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM vectors").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 3, count)
}
func TestClient_DeleteDocuments_Empty(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
err = client.DeleteDocuments(context.Background(), []string{})
require.NoError(t, err)
}
func TestClient_DeleteDocuments_Existing(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
// Add documents first
docs := []Document{
{
ID: "doc-1",
Content: "First document.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
},
},
{
ID: "doc-2",
Content: "Second document.",
Metadata: map[string]any{
"sqlite_id": int64(2),
"doc_type": "observation",
},
},
}
err = client.AddDocuments(context.Background(), docs)
require.NoError(t, err)
// Delete one document
err = client.DeleteDocuments(context.Background(), []string{"doc-1"})
require.NoError(t, err)
// Verify only one remains
var count int
err = db.QueryRow("SELECT COUNT(*) FROM vectors").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
func TestClient_Query_Basic(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
// Add some test documents
docs := []Document{
{
ID: "obs-1",
Content: "Authentication and login security implementation.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
"project": "test-project",
"scope": "project",
},
},
{
ID: "obs-2",
Content: "Database query optimization techniques.",
Metadata: map[string]any{
"sqlite_id": int64(2),
"doc_type": "observation",
"project": "test-project",
"scope": "project",
},
},
}
err = client.AddDocuments(context.Background(), docs)
require.NoError(t, err)
// Query for authentication-related content
results, err := client.Query(context.Background(), "login authentication", 10, nil)
require.NoError(t, err)
assert.NotEmpty(t, results)
assert.LessOrEqual(t, len(results), 10)
// First result should be the authentication document (higher similarity)
assert.Equal(t, "obs-1", results[0].ID)
}
func TestClient_Query_WithDocTypeFilter(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
// Add documents of different types
docs := []Document{
{
ID: "obs-1",
Content: "Test content for observation.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
"project": "test-project",
},
},
{
ID: "summary-1",
Content: "Test content for summary.",
Metadata: map[string]any{
"sqlite_id": int64(10),
"doc_type": "session_summary",
"project": "test-project",
},
},
}
err = client.AddDocuments(context.Background(), docs)
require.NoError(t, err)
// Query with doc_type filter
where := map[string]any{"doc_type": "observation"}
results, err := client.Query(context.Background(), "test content", 10, where)
require.NoError(t, err)
// Should only return observation documents
for _, r := range results {
docType, _ := r.Metadata["doc_type"].(string)
assert.Equal(t, "observation", docType)
}
}
func TestClient_Query_WithProjectFilter(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
// Add documents from different projects
docs := []Document{
{
ID: "obs-1",
Content: "Project A authentication content.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
"project": "project-a",
"scope": "project",
},
},
{
ID: "obs-2",
Content: "Project B database content.",
Metadata: map[string]any{
"sqlite_id": int64(2),
"doc_type": "observation",
"project": "project-b",
"scope": "project",
},
},
{
ID: "obs-3",
Content: "Global security best practices.",
Metadata: map[string]any{
"sqlite_id": int64(3),
"doc_type": "observation",
"project": "project-b",
"scope": "global",
},
},
}
err = client.AddDocuments(context.Background(), docs)
require.NoError(t, err)
// Query without project filter to verify all docs are there
results, err := client.Query(context.Background(), "authentication security", 10, nil)
require.NoError(t, err)
assert.NotEmpty(t, results, "Should find some results")
}
func TestClient_IsConnected(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
assert.True(t, client.IsConnected())
}
func TestClient_Close(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
err = client.Close()
require.NoError(t, err)
}
func TestConfig_Fields(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
cfg := Config{DB: db}
assert.Equal(t, db, cfg.DB)
}
func TestClient_UpdateDocument_DeleteThenAdd(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
// Add document
docs1 := []Document{
{
ID: "doc-1",
Content: "Original content.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
},
},
}
err = client.AddDocuments(context.Background(), docs1)
require.NoError(t, err)
// Delete then add with new content (proper update pattern)
err = client.DeleteDocuments(context.Background(), []string{"doc-1"})
require.NoError(t, err)
docs2 := []Document{
{
ID: "doc-1",
Content: "Updated content.",
Metadata: map[string]any{
"sqlite_id": int64(1),
"doc_type": "observation",
},
},
}
err = client.AddDocuments(context.Background(), docs2)
require.NoError(t, err)
// Should have exactly 1 document
var count int
err = db.QueryRow("SELECT COUNT(*) FROM vectors WHERE doc_id = ?", "doc-1").Scan(&count)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
func TestClient_DeleteDocuments_NonExistent(t *testing.T) {
db, dbCleanup := testDB(t)
defer dbCleanup()
embedSvc, embedCleanup := testEmbeddingService(t)
defer embedCleanup()
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
// Deleting non-existent document should not error
err = client.DeleteDocuments(context.Background(), []string{"non-existent-id"})
require.NoError(t, err)
}
+574
View File
@@ -0,0 +1,574 @@
package sqlitevec
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDocTypes(t *testing.T) {
assert.Equal(t, DocType("observation"), DocTypeObservation)
assert.Equal(t, DocType("session_summary"), DocTypeSessionSummary)
assert.Equal(t, DocType("user_prompt"), DocTypeUserPrompt)
}
func TestDocument_Fields(t *testing.T) {
doc := Document{
ID: "doc-123",
Content: "test content",
Metadata: map[string]any{
"key": "value",
},
}
assert.Equal(t, "doc-123", doc.ID)
assert.Equal(t, "test content", doc.Content)
assert.Equal(t, "value", doc.Metadata["key"])
}
func TestQueryResult_Fields(t *testing.T) {
result := QueryResult{
ID: "result-123",
Distance: 0.5,
Metadata: map[string]any{
"sqlite_id": float64(42),
},
}
assert.Equal(t, "result-123", result.ID)
assert.Equal(t, 0.5, result.Distance)
assert.Equal(t, float64(42), result.Metadata["sqlite_id"])
}
func TestBuildWhereFilter(t *testing.T) {
tests := []struct {
name string
docType DocType
project string
expected map[string]interface{}
}{
{
name: "empty_filters",
docType: "",
project: "",
expected: map[string]interface{}{},
},
{
name: "doc_type_only",
docType: DocTypeObservation,
project: "",
expected: map[string]interface{}{
"doc_type": "observation",
},
},
{
name: "project_only",
docType: "",
project: "my-project",
expected: map[string]interface{}{
"project": "my-project",
},
},
{
name: "both_filters",
docType: DocTypeSessionSummary,
project: "test-project",
expected: map[string]interface{}{
"doc_type": "session_summary",
"project": "test-project",
},
},
{
name: "user_prompt_type",
docType: DocTypeUserPrompt,
project: "prompt-project",
expected: map[string]interface{}{
"doc_type": "user_prompt",
"project": "prompt-project",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildWhereFilter(tt.docType, tt.project)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExtractIDsByDocType_Empty(t *testing.T) {
results := []QueryResult{}
ids := ExtractIDsByDocType(results)
assert.Empty(t, ids.ObservationIDs)
assert.Empty(t, ids.SummaryIDs)
assert.Empty(t, ids.PromptIDs)
}
func TestExtractIDsByDocType_AllTypes(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1",
Distance: 0.1,
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "observation",
},
},
{
ID: "obs-2",
Distance: 0.2,
Metadata: map[string]any{
"sqlite_id": float64(2),
"doc_type": "observation",
},
},
{
ID: "summary-1",
Distance: 0.3,
Metadata: map[string]any{
"sqlite_id": float64(10),
"doc_type": "session_summary",
},
},
{
ID: "prompt-1",
Distance: 0.4,
Metadata: map[string]any{
"sqlite_id": float64(20),
"doc_type": "user_prompt",
},
},
}
ids := ExtractIDsByDocType(results)
assert.Equal(t, []int64{1, 2}, ids.ObservationIDs)
assert.Equal(t, []int64{10}, ids.SummaryIDs)
assert.Equal(t, []int64{20}, ids.PromptIDs)
}
func TestExtractIDsByDocType_Deduplication(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1-field1",
Distance: 0.1,
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "observation",
},
},
{
ID: "obs-1-field2",
Distance: 0.2,
Metadata: map[string]any{
"sqlite_id": float64(1), // Same ID, different field
"doc_type": "observation",
},
},
{
ID: "obs-2",
Distance: 0.3,
Metadata: map[string]any{
"sqlite_id": float64(2),
"doc_type": "observation",
},
},
}
ids := ExtractIDsByDocType(results)
assert.Equal(t, []int64{1, 2}, ids.ObservationIDs) // Should be deduplicated
}
func TestExtractIDsByDocType_Int64Fallback(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1",
Distance: 0.1,
Metadata: map[string]any{
"sqlite_id": int64(42), // int64 instead of float64
"doc_type": "observation",
},
},
}
ids := ExtractIDsByDocType(results)
assert.Equal(t, []int64{42}, ids.ObservationIDs)
}
func TestExtractIDsByDocType_MissingSqliteID(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1",
Distance: 0.1,
Metadata: map[string]any{
"doc_type": "observation",
// Missing sqlite_id
},
},
}
ids := ExtractIDsByDocType(results)
assert.Empty(t, ids.ObservationIDs)
}
func TestExtractIDsByDocType_UnknownType(t *testing.T) {
results := []QueryResult{
{
ID: "unknown-1",
Distance: 0.1,
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "unknown_type",
},
},
}
ids := ExtractIDsByDocType(results)
// Should not be added to any category
assert.Empty(t, ids.ObservationIDs)
assert.Empty(t, ids.SummaryIDs)
assert.Empty(t, ids.PromptIDs)
}
func TestExtractObservationIDs_NoFilter(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1",
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "observation",
"project": "proj-a",
},
},
{
ID: "obs-2",
Metadata: map[string]any{
"sqlite_id": float64(2),
"doc_type": "observation",
"project": "proj-b",
},
},
{
ID: "summary-1",
Metadata: map[string]any{
"sqlite_id": float64(10),
"doc_type": "session_summary",
"project": "proj-a",
},
},
}
ids := ExtractObservationIDs(results, "")
assert.Equal(t, []int64{1, 2}, ids)
}
func TestExtractObservationIDs_WithProjectFilter(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1",
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "observation",
"project": "proj-a",
"scope": "project",
},
},
{
ID: "obs-2",
Metadata: map[string]any{
"sqlite_id": float64(2),
"doc_type": "observation",
"project": "proj-b",
"scope": "project",
},
},
{
ID: "obs-global",
Metadata: map[string]any{
"sqlite_id": float64(3),
"doc_type": "observation",
"project": "proj-b",
"scope": "global",
},
},
}
ids := ExtractObservationIDs(results, "proj-a")
// Should include proj-a and global scope observations
assert.Equal(t, []int64{1, 3}, ids)
}
func TestExtractObservationIDs_Deduplication(t *testing.T) {
results := []QueryResult{
{
ID: "obs-1-field1",
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "observation",
},
},
{
ID: "obs-1-field2",
Metadata: map[string]any{
"sqlite_id": float64(1), // Same ID
"doc_type": "observation",
},
},
}
ids := ExtractObservationIDs(results, "")
assert.Equal(t, []int64{1}, ids)
}
func TestExtractSummaryIDs_NoFilter(t *testing.T) {
results := []QueryResult{
{
ID: "summary-1",
Metadata: map[string]any{
"sqlite_id": float64(10),
"doc_type": "session_summary",
"project": "proj-a",
},
},
{
ID: "summary-2",
Metadata: map[string]any{
"sqlite_id": float64(20),
"doc_type": "session_summary",
"project": "proj-b",
},
},
{
ID: "obs-1",
Metadata: map[string]any{
"sqlite_id": float64(1),
"doc_type": "observation",
},
},
}
ids := ExtractSummaryIDs(results, "")
assert.Equal(t, []int64{10, 20}, ids)
}
func TestExtractSummaryIDs_WithProjectFilter(t *testing.T) {
results := []QueryResult{
{
ID: "summary-1",
Metadata: map[string]any{
"sqlite_id": float64(10),
"doc_type": "session_summary",
"project": "proj-a",
},
},
{
ID: "summary-2",
Metadata: map[string]any{
"sqlite_id": float64(20),
"doc_type": "session_summary",
"project": "proj-b",
},
},
}
ids := ExtractSummaryIDs(results, "proj-a")
assert.Equal(t, []int64{10}, ids)
}
func TestExtractPromptIDs_NoFilter(t *testing.T) {
results := []QueryResult{
{
ID: "prompt-1",
Metadata: map[string]any{
"sqlite_id": float64(100),
"doc_type": "user_prompt",
"project": "proj-a",
},
},
{
ID: "prompt-2",
Metadata: map[string]any{
"sqlite_id": float64(200),
"doc_type": "user_prompt",
"project": "proj-b",
},
},
}
ids := ExtractPromptIDs(results, "")
assert.Equal(t, []int64{100, 200}, ids)
}
func TestExtractPromptIDs_WithProjectFilter(t *testing.T) {
results := []QueryResult{
{
ID: "prompt-1",
Metadata: map[string]any{
"sqlite_id": float64(100),
"doc_type": "user_prompt",
"project": "proj-a",
},
},
{
ID: "prompt-2",
Metadata: map[string]any{
"sqlite_id": float64(200),
"doc_type": "user_prompt",
"project": "proj-b",
},
},
}
ids := ExtractPromptIDs(results, "proj-b")
assert.Equal(t, []int64{200}, ids)
}
func TestCopyMetadata(t *testing.T) {
base := map[string]any{
"key1": "value1",
"key2": 42,
}
result := copyMetadata(base, "key3", "value3")
// Original should be unchanged
assert.Len(t, base, 2)
// Result should have new key
assert.Len(t, result, 3)
assert.Equal(t, "value1", result["key1"])
assert.Equal(t, 42, result["key2"])
assert.Equal(t, "value3", result["key3"])
}
func TestCopyMetadataMulti(t *testing.T) {
base := map[string]any{
"key1": "value1",
}
extra := map[string]any{
"key2": "value2",
"key3": "value3",
}
result := copyMetadataMulti(base, extra)
assert.Len(t, result, 3)
assert.Equal(t, "value1", result["key1"])
assert.Equal(t, "value2", result["key2"])
assert.Equal(t, "value3", result["key3"])
}
func TestJoinStrings(t *testing.T) {
tests := []struct {
name string
strs []string
sep string
expected string
}{
{
name: "empty_slice",
strs: []string{},
sep: ", ",
expected: "",
},
{
name: "single_element",
strs: []string{"one"},
sep: ", ",
expected: "one",
},
{
name: "multiple_elements",
strs: []string{"one", "two", "three"},
sep: ", ",
expected: "one, two, three",
},
{
name: "different_separator",
strs: []string{"a", "b", "c"},
sep: "-",
expected: "a-b-c",
},
{
name: "empty_separator",
strs: []string{"a", "b", "c"},
sep: "",
expected: "abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := joinStrings(tt.strs, tt.sep)
assert.Equal(t, tt.expected, result)
})
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
}{
{
name: "shorter_than_max",
input: "hello",
maxLen: 10,
expected: "hello",
},
{
name: "equal_to_max",
input: "hello",
maxLen: 5,
expected: "hello",
},
{
name: "longer_than_max",
input: "hello world",
maxLen: 5,
expected: "hello...",
},
{
name: "empty_string",
input: "",
maxLen: 5,
expected: "",
},
{
name: "zero_max_length",
input: "hello",
maxLen: 0,
expected: "...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateString(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExtractedIDs_Empty(t *testing.T) {
ids := &ExtractedIDs{}
assert.Nil(t, ids.ObservationIDs)
assert.Nil(t, ids.SummaryIDs)
assert.Nil(t, ids.PromptIDs)
}
+348
View File
@@ -0,0 +1,348 @@
package sqlitevec
import (
"context"
"database/sql"
"testing"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testClient creates a test client for sync tests.
func testClient(t *testing.T) (*Client, func()) {
t.Helper()
db, dbCleanup := testDB(t)
embedSvc, embedCleanup := testEmbeddingService(t)
client, err := NewClient(Config{DB: db}, embedSvc)
require.NoError(t, err)
cleanup := func() {
embedCleanup()
dbCleanup()
}
return client, cleanup
}
func TestNewSync(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
assert.NotNil(t, sync)
}
func TestSync_SyncObservation_Empty(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// Observation with no content should be handled gracefully
obs := &models.Observation{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
Type: models.ObsTypeDiscovery,
}
err := sync.SyncObservation(context.Background(), obs)
require.NoError(t, err)
}
func TestSync_SyncObservation_WithContent(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
obs := &models.Observation{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
Type: models.ObsTypeDiscovery,
Scope: models.ScopeProject,
Title: sql.NullString{String: "Authentication bug fix", Valid: true},
Subtitle: sql.NullString{String: "Fixed JWT validation", Valid: true},
Narrative: sql.NullString{String: "Fixed the JWT token validation to handle expired tokens correctly.", Valid: true},
Facts: []string{"JWT tokens expire after 24 hours", "Refresh tokens are used for renewal"},
Concepts: []string{"authentication", "security"},
FilesRead: []string{"auth.go"},
FilesModified: []string{"handler.go"},
}
err := sync.SyncObservation(context.Background(), obs)
require.NoError(t, err)
// Verify documents were added
results, err := client.Query(context.Background(), "authentication", 10, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(results), 1)
}
func TestSync_SyncObservation_DefaultScope(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// Observation without explicit scope
obs := &models.Observation{
ID: 2,
SDKSessionID: "test-session",
Project: "test-project",
Type: models.ObsTypeBugfix,
Narrative: sql.NullString{String: "Fixed a null pointer exception.", Valid: true},
}
err := sync.SyncObservation(context.Background(), obs)
require.NoError(t, err)
}
func TestSync_SyncSummary_Empty(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// Summary with no content
summary := &models.SessionSummary{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
}
err := sync.SyncSummary(context.Background(), summary)
require.NoError(t, err)
}
func TestSync_SyncSummary_WithContent(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
summary := &models.SessionSummary{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
Request: sql.NullString{String: "Help me fix the authentication bug", Valid: true},
Investigated: sql.NullString{String: "Looked at auth.go and handler.go", Valid: true},
Learned: sql.NullString{String: "JWT tokens were not being validated properly", Valid: true},
Completed: sql.NullString{String: "Fixed the JWT validation logic", Valid: true},
NextSteps: sql.NullString{String: "Add tests for edge cases", Valid: true},
Notes: sql.NullString{String: "Consider using a library for JWT handling", Valid: true},
PromptNumber: sql.NullInt64{Int64: 1, Valid: true},
}
err := sync.SyncSummary(context.Background(), summary)
require.NoError(t, err)
// Verify documents were added
results, err := client.Query(context.Background(), "authentication", 10, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(results), 1)
}
func TestSync_SyncUserPrompt(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
prompt := &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: 1,
PromptNumber: 1,
PromptText: "Help me fix the authentication bug in the login handler",
CreatedAtEpoch: 1234567890,
},
SDKSessionID: "test-session",
Project: "test-project",
}
err := sync.SyncUserPrompt(context.Background(), prompt)
require.NoError(t, err)
// Verify document was added
results, err := client.Query(context.Background(), "authentication", 10, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(results), 1)
}
func TestSync_DeleteObservations_Empty(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// Should handle empty list
err := sync.DeleteObservations(context.Background(), []int64{})
require.NoError(t, err)
}
func TestSync_DeleteObservations_WithData(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// First add an observation
obs := &models.Observation{
ID: 10,
SDKSessionID: "test-session",
Project: "test-project",
Type: models.ObsTypeDiscovery,
Narrative: sql.NullString{String: "This observation should be deleted.", Valid: true},
Facts: []string{"Fact 1", "Fact 2"},
}
err := sync.SyncObservation(context.Background(), obs)
require.NoError(t, err)
// Then delete it
err = sync.DeleteObservations(context.Background(), []int64{10})
require.NoError(t, err)
}
func TestSync_DeleteUserPrompts_Empty(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// Should handle empty list
err := sync.DeleteUserPrompts(context.Background(), []int64{})
require.NoError(t, err)
}
func TestSync_DeleteUserPrompts_WithData(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
// First add a prompt
prompt := &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: 20,
PromptNumber: 1,
PromptText: "This prompt should be deleted.",
CreatedAtEpoch: 1234567890,
},
SDKSessionID: "test-session",
Project: "test-project",
}
err := sync.SyncUserPrompt(context.Background(), prompt)
require.NoError(t, err)
// Then delete it
err = sync.DeleteUserPrompts(context.Background(), []int64{20})
require.NoError(t, err)
}
func TestSync_FormatObservationDocs_AllFields(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
obs := &models.Observation{
ID: 100,
SDKSessionID: "sdk-123",
Project: "my-project",
Type: models.ObsTypeFeature,
Scope: models.ScopeGlobal,
Title: sql.NullString{String: "Feature Title", Valid: true},
Subtitle: sql.NullString{String: "Feature Subtitle", Valid: true},
Narrative: sql.NullString{String: "Feature narrative content", Valid: true},
Facts: []string{"Fact A", "Fact B", "Fact C"},
Concepts: []string{"api", "performance"},
FilesRead: []string{"file1.go", "file2.go"},
FilesModified: []string{"file3.go"},
}
docs := sync.formatObservationDocs(obs)
// Should have 1 narrative + 3 facts = 4 docs
assert.Len(t, docs, 4)
// Check narrative doc
var narrativeDoc *Document
for i := range docs {
if docs[i].ID == "obs_100_narrative" {
narrativeDoc = &docs[i]
break
}
}
require.NotNil(t, narrativeDoc)
assert.Equal(t, "Feature narrative content", narrativeDoc.Content)
assert.Equal(t, int64(100), narrativeDoc.Metadata["sqlite_id"])
assert.Equal(t, "observation", narrativeDoc.Metadata["doc_type"])
assert.Equal(t, "global", narrativeDoc.Metadata["scope"])
assert.Equal(t, "narrative", narrativeDoc.Metadata["field_type"])
}
func TestSync_FormatSummaryDocs_AllFields(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
summary := &models.SessionSummary{
ID: 200,
SDKSessionID: "sdk-456",
Project: "summary-project",
Request: sql.NullString{String: "Request content", Valid: true},
Investigated: sql.NullString{String: "Investigated content", Valid: true},
Learned: sql.NullString{String: "Learned content", Valid: true},
Completed: sql.NullString{String: "Completed content", Valid: true},
NextSteps: sql.NullString{String: "Next steps content", Valid: true},
Notes: sql.NullString{String: "Notes content", Valid: true},
PromptNumber: sql.NullInt64{Int64: 5, Valid: true},
}
docs := sync.formatSummaryDocs(summary)
// Should have 6 docs (one for each field)
assert.Len(t, docs, 6)
// Check request doc
var requestDoc *Document
for i := range docs {
if docs[i].ID == "summary_200_request" {
requestDoc = &docs[i]
break
}
}
require.NotNil(t, requestDoc)
assert.Equal(t, "Request content", requestDoc.Content)
assert.Equal(t, int64(200), requestDoc.Metadata["sqlite_id"])
assert.Equal(t, "session_summary", requestDoc.Metadata["doc_type"])
assert.Equal(t, int64(5), requestDoc.Metadata["prompt_number"])
}
func TestSync_FormatObservationDocs_EmptyScope(t *testing.T) {
client, cleanup := testClient(t)
defer cleanup()
sync := NewSync(client)
obs := &models.Observation{
ID: 300,
SDKSessionID: "sdk-789",
Project: "scope-test",
Type: models.ObsTypeDecision,
Narrative: sql.NullString{String: "Test narrative", Valid: true},
// Scope intentionally left empty
}
docs := sync.formatObservationDocs(obs)
assert.Len(t, docs, 1)
assert.Equal(t, "project", docs[0].Metadata["scope"])
}
+64 -7
View File
@@ -159,7 +159,13 @@ type SessionInitResponse struct {
Reason string `json:"reason,omitempty"`
}
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
// If the same prompt text is seen within this window, it's considered a duplicate hook invocation.
const DuplicatePromptWindowSeconds = 10
// handleSessionInit handles session initialization from user-prompt hook.
// This handler is idempotent - duplicate requests within a short time window
// return the existing prompt data without creating duplicates.
func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
var req SessionInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -182,8 +188,31 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
return
}
// Clean prompt and create session
// Clean prompt
cleanedPrompt := privacy.Clean(req.Prompt)
// DUPLICATE DETECTION: Check if this exact prompt was already saved recently.
// This prevents the bug where the hook fires multiple times for the same user action,
// creating many duplicate prompts with incrementing numbers.
if existingID, existingNum, found := s.promptStore.FindRecentPromptByText(r.Context(), req.ClaudeSessionID, cleanedPrompt, DuplicatePromptWindowSeconds); found {
// Get or create session (idempotent)
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
log.Debug().
Int64("sessionId", sessionID).
Int("promptNumber", existingNum).
Int64("promptId", existingID).
Msg("Duplicate prompt detected - returning existing")
// Return existing prompt data without incrementing or saving again
writeJSON(w, SessionInitResponse{
SessionDBID: sessionID,
PromptNumber: existingNum,
})
return
}
// Create session (idempotent)
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -614,7 +643,8 @@ func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) {
// handleGetStats returns worker statistics.
func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
retrievalStats := s.GetRetrievalStats()
project := r.URL.Query().Get("project")
retrievalStats := s.GetRetrievalStats(project)
sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context())
response := map[string]interface{}{
@@ -629,7 +659,7 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
}
// Include project-specific observation count if project is specified
if project := r.URL.Query().Get("project"); project != "" {
if project != "" {
count, err := s.observationStore.GetObservationCount(r.Context(), project)
if err == nil {
response["projectObservations"] = count
@@ -642,7 +672,8 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
// handleGetRetrievalStats returns detailed retrieval statistics.
func (s *Service) handleGetRetrievalStats(w http.ResponseWriter, r *http.Request) {
stats := s.GetRetrievalStats()
project := r.URL.Query().Get("project")
stats := s.GetRetrievalStats(project)
writeJSON(w, stats)
}
@@ -746,7 +777,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
clusteredObservations := clusterObservations(freshObservations, 0.4)
// Record retrieval stats (no verification done, so verified=0, deleted=0)
s.recordRetrievalStats(int64(len(clusteredObservations)), 0, 0, true)
s.recordRetrievalStats(project, int64(len(clusteredObservations)), 0, 0, true)
log.Info().
Str("project", project).
@@ -824,7 +855,7 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
duplicatesRemoved := len(freshObservations) - len(clusteredObservations)
// Record retrieval stats (no verification done)
s.recordRetrievalStats(int64(len(clusteredObservations)), 0, 0, false)
s.recordRetrievalStats(project, int64(len(clusteredObservations)), 0, 0, false)
log.Info().
Str("project", project).
@@ -995,7 +1026,7 @@ func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
})
}
// handleUpdateRestart restarts the worker with the new binary.
// handleUpdateRestart restarts the worker with the new binary (after update).
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
if status.State != "done" {
@@ -1021,3 +1052,29 @@ func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
}
}()
}
// handleRestart restarts the worker process (general restart, not tied to update).
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("Manual restart requested via API")
// Send response before restarting
writeJSON(w, map[string]interface{}{
"success": true,
"message": "Restarting worker...",
"version": s.version,
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
// Small delay to ensure response is sent
time.Sleep(100 * time.Millisecond)
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
File diff suppressed because it is too large Load Diff
+537
View File
@@ -0,0 +1,537 @@
package sdk
import (
"testing"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
)
func TestParseObservations_SingleObservation(t *testing.T) {
text := `Some text before
<observation>
<type>bugfix</type>
<title>Fixed null pointer error</title>
<subtitle>In user service</subtitle>
<narrative>The service was crashing when user ID was nil</narrative>
<facts>
<fact>Added nil check</fact>
<fact>Added unit test</fact>
</facts>
<concepts>
<concept>error-handling</concept>
<concept>debugging</concept>
</concepts>
<files_read>
<file>user_service.go</file>
</files_read>
<files_modified>
<file>user_service.go</file>
<file>user_service_test.go</file>
</files_modified>
</observation>
Some text after`
observations := ParseObservations(text, "test-correlation-id")
assert.Len(t, observations, 1)
obs := observations[0]
assert.Equal(t, models.ObservationType("bugfix"), obs.Type)
assert.Equal(t, "Fixed null pointer error", obs.Title)
assert.Equal(t, "In user service", obs.Subtitle)
assert.Equal(t, "The service was crashing when user ID was nil", obs.Narrative)
assert.Equal(t, []string{"Added nil check", "Added unit test"}, obs.Facts)
assert.Equal(t, []string{"error-handling", "debugging"}, obs.Concepts)
assert.Equal(t, []string{"user_service.go"}, obs.FilesRead)
assert.Equal(t, []string{"user_service.go", "user_service_test.go"}, obs.FilesModified)
}
func TestParseObservations_MultipleObservations(t *testing.T) {
text := `
<observation>
<type>feature</type>
<title>Added caching</title>
<narrative>Implemented Redis caching</narrative>
<facts><fact>Added cache layer</fact></facts>
<concepts><concept>caching</concept></concepts>
</observation>
<observation>
<type>refactor</type>
<title>Cleaned up code</title>
<narrative>Removed dead code</narrative>
<facts><fact>Removed unused functions</fact></facts>
<concepts><concept>refactoring</concept></concepts>
</observation>
`
observations := ParseObservations(text, "test-id")
assert.Len(t, observations, 2)
assert.Equal(t, models.ObservationType("feature"), observations[0].Type)
assert.Equal(t, "Added caching", observations[0].Title)
assert.Equal(t, models.ObservationType("refactor"), observations[1].Type)
assert.Equal(t, "Cleaned up code", observations[1].Title)
}
func TestParseObservations_TableDriven(t *testing.T) {
tests := []struct {
name string
input string
expectedCount int
expectedType models.ObservationType
expectedTitle string
checkConcepts []string
}{
{
name: "valid_bugfix_observation",
input: `<observation>
<type>bugfix</type>
<title>Fixed bug</title>
<narrative>Details</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeBugfix,
expectedTitle: "Fixed bug",
},
{
name: "valid_feature_observation",
input: `<observation>
<type>feature</type>
<title>New feature</title>
<narrative>Added new stuff</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeFeature,
expectedTitle: "New feature",
},
{
name: "valid_refactor_observation",
input: `<observation>
<type>refactor</type>
<title>Code cleanup</title>
<narrative>Refactored module</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeRefactor,
expectedTitle: "Code cleanup",
},
{
name: "valid_change_observation",
input: `<observation>
<type>change</type>
<title>Config update</title>
<narrative>Changed settings</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeChange,
expectedTitle: "Config update",
},
{
name: "valid_discovery_observation",
input: `<observation>
<type>discovery</type>
<title>Found pattern</title>
<narrative>Discovered new pattern</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeDiscovery,
expectedTitle: "Found pattern",
},
{
name: "valid_decision_observation",
input: `<observation>
<type>decision</type>
<title>Architecture decision</title>
<narrative>Chose microservices</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeDecision,
expectedTitle: "Architecture decision",
},
{
name: "invalid_type_defaults_to_change",
input: `<observation>
<type>invalid_type</type>
<title>Some title</title>
<narrative>Details</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeChange,
expectedTitle: "Some title",
},
{
name: "missing_type_defaults_to_change",
input: `<observation>
<title>No type specified</title>
<narrative>Details</narrative>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeChange,
expectedTitle: "No type specified",
},
{
name: "empty_input",
input: "",
expectedCount: 0,
},
{
name: "no_observation_tags",
input: "Just regular text without any observation",
expectedCount: 0,
},
{
name: "valid_concepts_filtered",
input: `<observation>
<type>bugfix</type>
<title>Test</title>
<narrative>Test</narrative>
<concepts>
<concept>best-practice</concept>
<concept>invalid-concept</concept>
<concept>security</concept>
</concepts>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeBugfix,
checkConcepts: []string{"best-practice", "security"},
},
{
name: "type_in_concepts_filtered_out",
input: `<observation>
<type>bugfix</type>
<title>Test</title>
<narrative>Test</narrative>
<concepts>
<concept>bugfix</concept>
<concept>security</concept>
</concepts>
</observation>`,
expectedCount: 1,
expectedType: models.ObsTypeBugfix,
checkConcepts: []string{"security"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
observations := ParseObservations(tt.input, "test-correlation-id")
assert.Len(t, observations, tt.expectedCount)
if tt.expectedCount > 0 {
obs := observations[0]
assert.Equal(t, tt.expectedType, obs.Type)
if tt.expectedTitle != "" {
assert.Equal(t, tt.expectedTitle, obs.Title)
}
if tt.checkConcepts != nil {
assert.Equal(t, tt.checkConcepts, obs.Concepts)
}
}
})
}
}
func TestParseObservations_AllValidConcepts(t *testing.T) {
// Test all valid concepts are accepted
validConcepts := []string{
"how-it-works", "why-it-exists", "what-changed", "problem-solution", "gotcha", "pattern", "trade-off",
"best-practice", "anti-pattern", "architecture", "security", "performance", "testing", "debugging", "workflow", "tooling",
"refactoring", "api", "database", "configuration", "error-handling", "caching", "logging", "auth", "validation",
}
for _, concept := range validConcepts {
t.Run("concept_"+concept, func(t *testing.T) {
input := `<observation>
<type>discovery</type>
<title>Test</title>
<narrative>Test</narrative>
<concepts><concept>` + concept + `</concept></concepts>
</observation>`
observations := ParseObservations(input, "test-id")
assert.Len(t, observations, 1)
assert.Contains(t, observations[0].Concepts, concept)
})
}
}
func TestParseObservations_ConceptCaseInsensitive(t *testing.T) {
input := `<observation>
<type>discovery</type>
<title>Test</title>
<narrative>Test</narrative>
<concepts>
<concept>SECURITY</concept>
<concept>Best-Practice</concept>
<concept> caching </concept>
</concepts>
</observation>`
observations := ParseObservations(input, "test-id")
assert.Len(t, observations, 1)
assert.Equal(t, []string{"security", "best-practice", "caching"}, observations[0].Concepts)
}
func TestParseSummary_ValidSummary(t *testing.T) {
text := `Some text before
<summary>
<request>User asked to fix the bug</request>
<investigated>Looked at error logs and stack traces</investigated>
<learned>The issue was a race condition</learned>
<completed>Fixed the race condition with mutex</completed>
<next_steps>Add more tests for concurrent access</next_steps>
<notes>May need to review similar code elsewhere</notes>
</summary>
Some text after`
summary := ParseSummary(text, 123)
assert.NotNil(t, summary)
assert.Equal(t, "User asked to fix the bug", summary.Request)
assert.Equal(t, "Looked at error logs and stack traces", summary.Investigated)
assert.Equal(t, "The issue was a race condition", summary.Learned)
assert.Equal(t, "Fixed the race condition with mutex", summary.Completed)
assert.Equal(t, "Add more tests for concurrent access", summary.NextSteps)
assert.Equal(t, "May need to review similar code elsewhere", summary.Notes)
}
func TestParseSummary_TableDriven(t *testing.T) {
tests := []struct {
name string
input string
sessionID int64
expectNil bool
expectedRequest string
}{
{
name: "empty_input",
input: "",
sessionID: 1,
expectNil: true,
},
{
name: "no_summary_tag",
input: "Just some text without summary",
sessionID: 1,
expectNil: true,
},
{
name: "skip_summary_tag",
input: `<skip_summary reason="No significant changes made"/>`,
sessionID: 1,
expectNil: true,
},
{
name: "skip_summary_with_different_reason",
input: `<skip_summary reason="Only read files"/>`,
sessionID: 2,
expectNil: true,
},
{
name: "valid_summary_minimal",
input: `<summary>
<request>Test request</request>
</summary>`,
sessionID: 3,
expectNil: false,
expectedRequest: "Test request",
},
{
name: "valid_summary_all_fields",
input: `<summary>
<request>Full request</request>
<investigated>Full investigated</investigated>
<learned>Full learned</learned>
<completed>Full completed</completed>
<next_steps>Full next steps</next_steps>
<notes>Full notes</notes>
</summary>`,
sessionID: 4,
expectNil: false,
expectedRequest: "Full request",
},
{
name: "summary_with_empty_fields",
input: `<summary>
<request></request>
<investigated></investigated>
</summary>`,
sessionID: 5,
expectNil: false,
expectedRequest: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := ParseSummary(tt.input, tt.sessionID)
if tt.expectNil {
assert.Nil(t, summary)
} else {
assert.NotNil(t, summary)
assert.Equal(t, tt.expectedRequest, summary.Request)
}
})
}
}
func TestParseSummary_SkipSummaryPriority(t *testing.T) {
// skip_summary should take priority over summary block
text := `<skip_summary reason="No changes"/>
<summary>
<request>This should be ignored</request>
</summary>`
summary := ParseSummary(text, 1)
assert.Nil(t, summary)
}
func TestExtractField_TableDriven(t *testing.T) {
tests := []struct {
name string
content string
fieldName string
expected string
}{
{
name: "simple_field",
content: "<title>Test Title</title>",
fieldName: "title",
expected: "Test Title",
},
{
name: "field_with_whitespace",
content: "<title> Test Title </title>",
fieldName: "title",
expected: "Test Title",
},
{
name: "field_not_found",
content: "<other>Value</other>",
fieldName: "title",
expected: "",
},
{
name: "empty_field",
content: "<title></title>",
fieldName: "title",
expected: "",
},
{
name: "nested_content",
content: "<wrapper><title>Nested</title></wrapper>",
fieldName: "title",
expected: "Nested",
},
{
name: "field_among_others",
content: "<a>A</a><title>Target</title><b>B</b>",
fieldName: "title",
expected: "Target",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractField(tt.content, tt.fieldName)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExtractArrayElements_TableDriven(t *testing.T) {
tests := []struct {
name string
content string
arrayName string
elementName string
expected []string
}{
{
name: "simple_array",
content: "<facts><fact>One</fact><fact>Two</fact></facts>",
arrayName: "facts",
elementName: "fact",
expected: []string{"One", "Two"},
},
{
name: "empty_array",
content: "<facts></facts>",
arrayName: "facts",
elementName: "fact",
expected: nil,
},
{
name: "array_not_found",
content: "<other><item>Value</item></other>",
arrayName: "facts",
elementName: "fact",
expected: nil,
},
{
name: "single_element",
content: "<concepts><concept>security</concept></concepts>",
arrayName: "concepts",
elementName: "concept",
expected: []string{"security"},
},
{
name: "multiline_array",
content: `<files>
<file>file1.go</file>
<file>file2.go</file>
<file>file3.go</file>
</files>`,
arrayName: "files",
elementName: "file",
expected: []string{"file1.go", "file2.go", "file3.go"},
},
{
name: "whitespace_trimmed",
content: "<items><item> trimmed </item></items>",
arrayName: "items",
elementName: "item",
expected: []string{"trimmed"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractArrayElements(tt.content, tt.arrayName, tt.elementName)
assert.Equal(t, tt.expected, result)
})
}
}
func TestValidObsTypes(t *testing.T) {
expected := map[string]bool{
"bugfix": true,
"feature": true,
"refactor": true,
"change": true,
"discovery": true,
"decision": true,
}
assert.Equal(t, expected, validObsTypes)
}
func TestValidConcepts(t *testing.T) {
// Verify expected concepts are valid
expectedValid := []string{
"how-it-works", "why-it-exists", "what-changed", "problem-solution", "gotcha", "pattern", "trade-off",
"best-practice", "anti-pattern", "architecture", "security", "performance", "testing", "debugging", "workflow", "tooling",
"refactoring", "api", "database", "configuration", "error-handling", "caching", "logging", "auth", "validation",
}
for _, concept := range expectedValid {
assert.True(t, validConcepts[concept], "Expected %s to be valid", concept)
}
// Verify invalid concepts
invalidConcepts := []string{"random", "invalid", "not-a-concept", "foo", "bar"}
for _, concept := range invalidConcepts {
assert.False(t, validConcepts[concept], "Expected %s to be invalid", concept)
}
}
+850
View File
@@ -1,9 +1,12 @@
package sdk
import (
"os"
"path/filepath"
"testing"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
)
func TestIsSelfReferentialSummary(t *testing.T) {
@@ -124,3 +127,850 @@ No substantive work performed yet.`,
})
}
}
func TestShouldSkipTool(t *testing.T) {
tests := []struct {
name string
toolName string
expected bool
}{
// Tools that should be skipped
{"TodoWrite", "TodoWrite", true},
{"Task", "Task", true},
{"TaskOutput", "TaskOutput", true},
{"Glob", "Glob", true},
{"ListDir", "ListDir", true},
{"LS", "LS", true},
{"KillShell", "KillShell", true},
{"AskUserQuestion", "AskUserQuestion", true},
{"EnterPlanMode", "EnterPlanMode", true},
{"ExitPlanMode", "ExitPlanMode", true},
{"Skill", "Skill", true},
{"SlashCommand", "SlashCommand", true},
// Tools that should NOT be skipped
{"Read", "Read", false},
{"Edit", "Edit", false},
{"Write", "Write", false},
{"Grep", "Grep", false},
{"Bash", "Bash", false},
{"WebFetch", "WebFetch", false},
{"WebSearch", "WebSearch", false},
{"NotebookEdit", "NotebookEdit", false},
// Unknown tool (should not be skipped)
{"UnknownTool", "SomeUnknownTool", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := shouldSkipTool(tt.toolName)
assert.Equal(t, tt.expected, result)
})
}
}
func TestShouldSkipTrivialOperation(t *testing.T) {
tests := []struct {
name string
toolName string
inputStr string
outputStr string
expected bool
}{
// Short output (should be skipped)
{
name: "output_too_short",
toolName: "Read",
inputStr: `{"file_path": "/some/file.go"}`,
outputStr: "short",
expected: true,
},
// Trivial outputs
{
name: "no_matches_found",
toolName: "Grep",
inputStr: `{"pattern": "foo"}`,
outputStr: "No matches found in the codebase",
expected: true,
},
{
name: "file_not_found",
toolName: "Read",
inputStr: `{"file_path": "/nonexistent.go"}`,
outputStr: "Error: File not found at specified path",
expected: true,
},
{
name: "empty_array",
toolName: "Grep",
inputStr: `{"pattern": "foo"}`,
outputStr: "[]",
expected: true,
},
// Boring files
{
name: "package_lock_json",
toolName: "Read",
inputStr: `{"file_path": "/project/package-lock.json"}`,
outputStr: "This is a very long package-lock.json content that has more than 50 characters",
expected: true,
},
{
name: "go_sum",
toolName: "Read",
inputStr: `{"file_path": "/project/go.sum"}`,
outputStr: "This is a very long go.sum file content that has more than 50 characters",
expected: true,
},
// Grep with too many matches
{
name: "grep_too_many_matches",
toolName: "Grep",
inputStr: `{"pattern": "import"}`,
outputStr: func() string {
s := ""
for i := 0; i < 55; i++ {
s += "match line\n"
}
return s
}(),
expected: true,
},
// Boring Bash commands
{
name: "git_status",
toolName: "Bash",
inputStr: `{"command": "git status"}`,
outputStr: "On branch main\nYour branch is up to date with 'origin/main'.\nnothing to commit, working tree clean",
expected: true,
},
{
name: "ls_command",
toolName: "Bash",
inputStr: `{"command": "ls -la /some/directory"}`,
outputStr: "total 123\ndrwxr-xr-x some long listing that is at least 50 chars",
expected: true,
},
// Valid operations that should NOT be skipped
{
name: "valid_read",
toolName: "Read",
inputStr: `{"file_path": "/project/main.go"}`,
outputStr: "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}",
expected: false,
},
{
name: "valid_edit",
toolName: "Edit",
inputStr: `{"file_path": "/project/handler.go", "old_string": "foo", "new_string": "bar"}`,
outputStr: "Edit applied successfully. File /project/handler.go has been modified with the requested changes.",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := shouldSkipTrivialOperation(tt.toolName, tt.inputStr, tt.outputStr)
assert.Equal(t, tt.expected, result)
})
}
}
func TestTruncateForLog(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
}{
{
name: "shorter_than_max",
input: "hello",
maxLen: 10,
expected: "hello",
},
{
name: "equal_to_max",
input: "hello",
maxLen: 5,
expected: "hello",
},
{
name: "longer_than_max",
input: "hello world",
maxLen: 5,
expected: "hello...",
},
{
name: "empty_string",
input: "",
maxLen: 5,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateForLog(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
func TestToJSONString(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{
name: "nil_value",
input: nil,
expected: "",
},
{
name: "string_value",
input: "hello",
expected: "hello",
},
{
name: "int_value",
input: 42,
expected: "42",
},
{
name: "map_value",
input: map[string]string{"key": "value"},
expected: `{"key":"value"}`,
},
{
name: "slice_value",
input: []string{"a", "b", "c"},
expected: `["a","b","c"]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := toJSONString(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestCaptureFileMtimes(t *testing.T) {
// Create a temp directory with test files
tmpDir, err := os.MkdirTemp("", "mtime-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Create test files
file1 := filepath.Join(tmpDir, "file1.txt")
file2 := filepath.Join(tmpDir, "file2.txt")
err = os.WriteFile(file1, []byte("content1"), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(file2, []byte("content2"), 0644)
if err != nil {
t.Fatal(err)
}
t.Run("captures_mtimes_for_existing_files", func(t *testing.T) {
mtimes := captureFileMtimes([]string{file1}, []string{file2}, "")
assert.Len(t, mtimes, 2)
assert.Contains(t, mtimes, file1)
assert.Contains(t, mtimes, file2)
assert.Greater(t, mtimes[file1], int64(0))
assert.Greater(t, mtimes[file2], int64(0))
})
t.Run("handles_nonexistent_files", func(t *testing.T) {
mtimes := captureFileMtimes([]string{"/nonexistent/file.txt"}, nil, "")
assert.Empty(t, mtimes)
})
t.Run("handles_relative_paths_with_cwd", func(t *testing.T) {
mtimes := captureFileMtimes([]string{"file1.txt"}, nil, tmpDir)
assert.Len(t, mtimes, 1)
assert.Contains(t, mtimes, "file1.txt")
})
t.Run("empty_inputs", func(t *testing.T) {
mtimes := captureFileMtimes(nil, nil, "")
assert.Empty(t, mtimes)
})
}
func TestGetFileMtimes(t *testing.T) {
// Create a temp file
tmpDir, err := os.MkdirTemp("", "getmtime-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte("content"), 0644)
if err != nil {
t.Fatal(err)
}
mtimes := GetFileMtimes([]string{testFile}, "")
assert.Len(t, mtimes, 1)
assert.Contains(t, mtimes, testFile)
assert.Greater(t, mtimes[testFile], int64(0))
}
func TestGetFileContent(t *testing.T) {
// Create a temp directory with test files
tmpDir, err := os.MkdirTemp("", "content-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
t.Run("reads_existing_file", func(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
content := "test content"
err := os.WriteFile(testFile, []byte(content), 0644)
if err != nil {
t.Fatal(err)
}
result, ok := GetFileContent(testFile, "")
assert.True(t, ok)
assert.Equal(t, content, result)
})
t.Run("returns_false_for_nonexistent_file", func(t *testing.T) {
result, ok := GetFileContent("/nonexistent/file.txt", "")
assert.False(t, ok)
assert.Empty(t, result)
})
t.Run("truncates_long_content", func(t *testing.T) {
testFile := filepath.Join(tmpDir, "long.txt")
longContent := ""
for i := 0; i < 3000; i++ {
longContent += "x"
}
err := os.WriteFile(testFile, []byte(longContent), 0644)
if err != nil {
t.Fatal(err)
}
result, ok := GetFileContent(testFile, "")
assert.True(t, ok)
assert.Contains(t, result, "[truncated]")
assert.LessOrEqual(t, len(result), 2100)
})
t.Run("resolves_relative_path_with_cwd", func(t *testing.T) {
testFile := filepath.Join(tmpDir, "relative.txt")
content := "relative content"
err := os.WriteFile(testFile, []byte(content), 0644)
if err != nil {
t.Fatal(err)
}
result, ok := GetFileContent("relative.txt", tmpDir)
assert.True(t, ok)
assert.Equal(t, content, result)
})
}
func TestMaxConcurrentCLICalls(t *testing.T) {
assert.Equal(t, 4, MaxConcurrentCLICalls)
}
func TestObservationTypes(t *testing.T) {
expected := []string{"bugfix", "feature", "refactor", "change", "discovery", "decision"}
assert.Equal(t, expected, ObservationTypes)
}
func TestObservationConcepts(t *testing.T) {
expectedConcepts := []string{
"how-it-works",
"why-it-exists",
"what-changed",
"problem-solution",
"gotcha",
"pattern",
"trade-off",
}
assert.Equal(t, expectedConcepts, ObservationConcepts)
}
// TestProcessorStruct tests processor struct initialization and methods.
func TestProcessorStruct(t *testing.T) {
p := &Processor{
claudePath: "/path/to/claude",
model: "haiku",
sem: make(chan struct{}, MaxConcurrentCLICalls),
}
assert.Equal(t, "/path/to/claude", p.claudePath)
assert.Equal(t, "haiku", p.model)
assert.NotNil(t, p.sem)
}
// TestSetBroadcastFunc tests the broadcast callback setter.
func TestSetBroadcastFunc(t *testing.T) {
p := &Processor{}
assert.Nil(t, p.broadcastFunc)
var called bool
var receivedEvent map[string]interface{}
fn := func(event map[string]interface{}) {
called = true
receivedEvent = event
}
p.SetBroadcastFunc(fn)
assert.NotNil(t, p.broadcastFunc)
// Test broadcast
p.broadcast(map[string]interface{}{"type": "test"})
assert.True(t, called)
assert.Equal(t, "test", receivedEvent["type"])
}
// TestSetSyncObservationFunc tests the sync observation callback setter.
func TestSetSyncObservationFunc(t *testing.T) {
p := &Processor{}
assert.Nil(t, p.syncObservationFunc)
var called bool
fn := func(obs *models.Observation) {
called = true
}
p.SetSyncObservationFunc(fn)
assert.NotNil(t, p.syncObservationFunc)
// Verify it was set
p.syncObservationFunc(&models.Observation{})
assert.True(t, called)
}
// TestSetSyncSummaryFunc tests the sync summary callback setter.
func TestSetSyncSummaryFunc(t *testing.T) {
p := &Processor{}
assert.Nil(t, p.syncSummaryFunc)
var called bool
fn := func(summary *models.SessionSummary) {
called = true
}
p.SetSyncSummaryFunc(fn)
assert.NotNil(t, p.syncSummaryFunc)
// Verify it was set
p.syncSummaryFunc(&models.SessionSummary{})
assert.True(t, called)
}
// TestBroadcast_NilFunc tests broadcast with nil callback.
func TestBroadcast_NilFunc(t *testing.T) {
p := &Processor{}
// Should not panic
p.broadcast(map[string]interface{}{"type": "test"})
}
// TestIsAvailable_NonexistentPath tests IsAvailable with non-existent path.
func TestIsAvailable_NonexistentPath(t *testing.T) {
p := &Processor{
claudePath: "/nonexistent/path/to/claude",
}
assert.False(t, p.IsAvailable())
}
// TestIsAvailable_ExistingPath tests IsAvailable with existing path.
func TestIsAvailable_ExistingPath(t *testing.T) {
// Create a temp file to simulate claude binary
tmpFile, err := os.CreateTemp("", "claude-test-*")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpFile.Name())
tmpFile.Close()
p := &Processor{
claudePath: tmpFile.Name(),
}
assert.True(t, p.IsAvailable())
}
// TestShouldSkipTrivialOperation_EdgeCases tests edge cases for trivial operation detection.
func TestShouldSkipTrivialOperation_EdgeCases(t *testing.T) {
tests := []struct {
name string
toolName string
inputStr string
outputStr string
expected bool
}{
{
name: "gitignore_file",
toolName: "Read",
inputStr: `{"file_path": "/project/.gitignore"}`,
outputStr: "This is a gitignore file content that has more than 50 characters long",
expected: true,
},
{
name: "eslintignore_file",
toolName: "Read",
inputStr: `{"file_path": "/project/.eslintignore"}`,
outputStr: "This is an eslintignore file content that has more than 50 characters long",
expected: true,
},
{
name: "tsconfig_file",
toolName: "Read",
inputStr: `{"file_path": "/project/tsconfig.json"}`,
outputStr: "This is a tsconfig.json file content that has more than 50 characters long",
expected: true,
},
{
name: "tailwind_config",
toolName: "Read",
inputStr: `{"file_path": "/project/tailwind.config.js"}`,
outputStr: "This is a tailwind.config file content that has more than 50 characters long",
expected: true,
},
{
name: "pwd_command",
toolName: "Bash",
inputStr: `{"command": "pwd"}`,
outputStr: "/home/user/project/some/long/path/that/is/more/than/fifty/chars",
expected: true,
},
{
name: "echo_command",
toolName: "Bash",
inputStr: `{"command": "echo Hello World"}`,
outputStr: "Hello World output that is long enough to pass the length check here",
expected: true,
},
{
name: "npm_audit_command",
toolName: "Bash",
inputStr: `{"command": "npm audit"}`,
outputStr: "found 0 vulnerabilities in 500 packages which is more than fifty characters",
expected: true,
},
{
name: "permission_denied",
toolName: "Read",
inputStr: `{"file_path": "/root/secret"}`,
outputStr: "Error: Permission denied accessing the file at specified path",
expected: true,
},
{
name: "is_a_directory",
toolName: "Read",
inputStr: `{"file_path": "/some/dir"}`,
outputStr: "Error: /some/dir is a directory, not a file that can be read",
expected: true,
},
{
name: "empty_object",
toolName: "Grep",
inputStr: `{"pattern": "nonexistent"}`,
outputStr: "{}",
expected: true,
},
{
name: "valid_grep_result",
toolName: "Grep",
inputStr: `{"pattern": "func main"}`,
outputStr: "main.go:10:func main() {\nmain.go:11: fmt.Println(\"Hello\")\n}",
expected: false,
},
{
name: "valid_bash_build",
toolName: "Bash",
inputStr: `{"command": "go build ./..."}`,
outputStr: "Build completed successfully. Binary output at ./bin/myapp with size 10MB.",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := shouldSkipTrivialOperation(tt.toolName, tt.inputStr, tt.outputStr)
assert.Equal(t, tt.expected, result)
})
}
}
// TestIsSelfReferentialSummary_MoreCases tests additional self-referential detection cases.
func TestIsSelfReferentialSummary_MoreCases(t *testing.T) {
tests := []struct {
name string
summary *models.ParsedSummary
expected bool
}{
{
name: "progress_checkpoint",
summary: &models.ParsedSummary{
Request: "Progress checkpoint for current session",
Completed: "Responding to progress checkpoint request",
Learned: "No technical learnings yet",
},
expected: true,
},
{
name: "empty_session",
summary: &models.ParsedSummary{
Request: "Empty session",
Completed: "Just beginning the session",
Learned: "Nothing has been completed yet",
},
expected: true,
},
{
name: "hook_mechanism",
summary: &models.ParsedSummary{
Request: "Hook execution for session start",
Completed: "Hook mechanism triggered successfully",
Learned: "System hooks are working",
},
expected: true,
},
{
name: "api_implementation",
summary: &models.ParsedSummary{
Request: "Implement REST API endpoints",
Completed: "Created /users and /posts endpoints with CRUD operations",
Learned: "chi router handles middleware chaining elegantly",
NextSteps: "Add authentication middleware",
},
expected: false,
},
{
name: "database_migration",
summary: &models.ParsedSummary{
Request: "Add database migration for new user fields",
Completed: "Created migration 003_add_user_profile.sql with new columns",
Learned: "SQLite ALTER TABLE has limited capabilities, need to recreate table",
NextSteps: "Test migration rollback",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isSelfReferentialSummary(tt.summary)
assert.Equal(t, tt.expected, result)
})
}
}
// TestHasMeaningfulContent_MoreCases tests additional meaningful content detection.
func TestHasMeaningfulContent_MoreCases(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "code_with_functions",
content: `I've created a new handler function in handlers.go.
The function validateRequest() checks the incoming JSON payload.
Here's the implementation:
` + "```go\nfunc validateRequest(r *http.Request) error {\n\treturn nil\n}\n```",
expected: true,
},
{
name: "python_code_discussion",
content: `Updated the data processing module in processor.py.
Changed the filter function to use list comprehension.
def process_data(items):
return [item for item in items if item.valid]
This improved performance by 30%.`,
expected: true,
},
{
name: "typescript_changes",
content: `I've modified the React component in UserProfile.tsx.
Added a new functional component with proper TypeScript type annotations.
Here's the updated implementation:
` + "```tsx\nconst UserProfile: FC<Props> = ({ user }) => {\n return <div>{user.name}</div>;\n};\n```" + `
The type annotations ensure type safety across the application.
The component has been updated with proper error handling and loading states.`,
expected: true,
},
{
name: "yaml_config_update",
content: `I've updated the kubernetes deployment config in deploy.yaml.
Changed replicas from 2 to 4 and added resource limits for memory and CPU.
The deployment.yaml file now includes the following struct configuration:
` + "```yaml\nreplicas: 4\nresources:\n limits:\n memory: 512Mi\n```" + `
The changes have been implemented and will be applied on next deploy.`,
expected: true,
},
{
name: "just_system_messages",
content: `SessionStart:Callback hook success
System-reminder about tools
The session is starting
Waiting for user instructions`,
expected: false,
},
{
name: "borderline_short",
content: "Fixed bug. Updated file. Added test. Committed changes to repository.",
expected: false, // Too short (< 200 chars)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasMeaningfulContent(tt.content)
assert.Equal(t, tt.expected, result)
})
}
}
// TestToJSONString_ComplexTypes tests JSON conversion for complex types.
func TestToJSONString_ComplexTypes(t *testing.T) {
tests := []struct {
name string
input interface{}
contains string
}{
{
name: "nested_map",
input: map[string]interface{}{
"outer": map[string]string{"inner": "value"},
},
contains: "inner",
},
{
name: "bool_true",
input: true,
contains: "true",
},
{
name: "bool_false",
input: false,
contains: "false",
},
{
name: "float_value",
input: 3.14,
contains: "3.14",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := toJSONString(tt.input)
assert.Contains(t, result, tt.contains)
})
}
}
// TestSystemPrompt tests that the system prompt is defined.
func TestSystemPrompt(t *testing.T) {
assert.NotEmpty(t, systemPrompt)
assert.Contains(t, systemPrompt, "memory extraction agent")
assert.Contains(t, systemPrompt, "observation")
assert.Contains(t, systemPrompt, "GUIDELINES")
}
// TestProcessorSemaphore tests the semaphore behavior.
func TestProcessorSemaphore(t *testing.T) {
p := &Processor{
sem: make(chan struct{}, 2),
}
// Acquire 2 slots
p.sem <- struct{}{}
p.sem <- struct{}{}
// Third should block (we can test with select)
select {
case p.sem <- struct{}{}:
t.Error("Semaphore should be full")
default:
// Expected - semaphore is full
}
// Release one
<-p.sem
// Now should be able to acquire
select {
case p.sem <- struct{}{}:
// Expected
default:
t.Error("Should be able to acquire after release")
}
}
// TestCaptureFileMtimes_DuplicatePaths tests mtime capture with overlapping paths.
func TestCaptureFileMtimes_DuplicatePaths(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mtime-dup-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
testFile := filepath.Join(tmpDir, "shared.txt")
err = os.WriteFile(testFile, []byte("content"), 0644)
if err != nil {
t.Fatal(err)
}
// Same file in both read and modified lists
mtimes := captureFileMtimes([]string{testFile}, []string{testFile}, "")
// Should only have one entry (no duplicates)
assert.Len(t, mtimes, 1)
assert.Contains(t, mtimes, testFile)
}
// TestTruncateForLog_ZeroLength tests truncation with zero length.
func TestTruncateForLog_ZeroLength(t *testing.T) {
result := truncateForLog("hello", 0)
assert.Equal(t, "...", result)
}
// TestBroadcastFuncType tests the BroadcastFunc type.
func TestBroadcastFuncType(t *testing.T) {
var fn BroadcastFunc = func(event map[string]interface{}) {
// Do nothing
}
assert.NotNil(t, fn)
}
// TestSyncObservationFuncType tests the SyncObservationFunc type.
func TestSyncObservationFuncType(t *testing.T) {
var fn SyncObservationFunc = func(obs *models.Observation) {
// Do nothing
}
assert.NotNil(t, fn)
}
// TestSyncSummaryFuncType tests the SyncSummaryFunc type.
func TestSyncSummaryFuncType(t *testing.T) {
var fn SyncSummaryFunc = func(summary *models.SessionSummary) {
// Do nothing
}
assert.NotNil(t, fn)
}
+276
View File
@@ -0,0 +1,276 @@
package sdk
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTruncate(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
}{
{
name: "shorter_than_max",
input: "hello",
maxLen: 10,
expected: "hello",
},
{
name: "equal_to_max",
input: "hello",
maxLen: 5,
expected: "hello",
},
{
name: "longer_than_max",
input: "hello world",
maxLen: 5,
expected: "hello... (truncated)",
},
{
name: "empty_string",
input: "",
maxLen: 5,
expected: "",
},
{
name: "zero_max_length",
input: "hello",
maxLen: 0,
expected: "... (truncated)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncate(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
func TestBuildObservationPrompt(t *testing.T) {
now := time.Now().UnixMilli()
tests := []struct {
name string
exec ToolExecution
contains []string
}{
{
name: "basic_read_tool",
exec: ToolExecution{
ID: 1,
ToolName: "Read",
ToolInput: `{"file_path": "/path/to/file.go"}`,
ToolOutput: `package main\nfunc main() {}`,
CreatedAtEpoch: now,
CWD: "/project",
},
contains: []string{
"<observed_from_primary_session>",
"<what_happened>Read</what_happened>",
"<working_directory>/project</working_directory>",
"<parameters>",
"file_path",
"<outcome>",
"</observed_from_primary_session>",
},
},
{
name: "edit_tool_with_json_input",
exec: ToolExecution{
ID: 2,
ToolName: "Edit",
ToolInput: `{"file_path": "/file.go", "old_string": "foo", "new_string": "bar"}`,
ToolOutput: "Edit applied successfully",
CreatedAtEpoch: now,
CWD: "",
},
contains: []string{
"<what_happened>Edit</what_happened>",
"file_path",
"old_string",
"new_string",
},
},
{
name: "no_cwd",
exec: ToolExecution{
ID: 3,
ToolName: "Bash",
ToolInput: `{"command": "go test"}`,
ToolOutput: "ok",
CreatedAtEpoch: now,
CWD: "",
},
contains: []string{
"<what_happened>Bash</what_happened>",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildObservationPrompt(tt.exec)
for _, s := range tt.contains {
assert.Contains(t, result, s, "Expected result to contain: %s", s)
}
// Check CWD only appears when set
if tt.exec.CWD == "" {
assert.NotContains(t, result, "<working_directory>")
}
})
}
}
func TestBuildObservationPrompt_TruncatesLongContent(t *testing.T) {
longInput := strings.Repeat("x", 5000)
longOutput := strings.Repeat("y", 7000)
exec := ToolExecution{
ID: 1,
ToolName: "Read",
ToolInput: longInput,
ToolOutput: longOutput,
CreatedAtEpoch: time.Now().UnixMilli(),
CWD: "/project",
}
result := BuildObservationPrompt(exec)
// Input should be truncated to ~3000
assert.Contains(t, result, "truncated")
// The result should not be excessively long
assert.Less(t, len(result), 10000)
}
func TestBuildSummaryPrompt(t *testing.T) {
tests := []struct {
name string
req SummaryRequest
contains []string
}{
{
name: "basic_request",
req: SummaryRequest{
SessionDBID: 1,
SDKSessionID: "sdk-123",
Project: "test-project",
},
contains: []string{
"PROGRESS SUMMARY CHECKPOINT",
"<summary>",
"<request>",
"<investigated>",
"<learned>",
"<completed>",
"<next_steps>",
"<notes>",
"</summary>",
},
},
{
name: "with_assistant_message",
req: SummaryRequest{
SessionDBID: 2,
SDKSessionID: "sdk-456",
Project: "project-b",
LastAssistantMessage: "I fixed the authentication bug by updating the JWT validation.",
},
contains: []string{
"Claude's Full Response to User:",
"fixed the authentication",
},
},
{
name: "empty_assistant_message",
req: SummaryRequest{
SessionDBID: 3,
SDKSessionID: "sdk-789",
Project: "project-c",
LastAssistantMessage: "",
},
contains: []string{
"PROGRESS SUMMARY CHECKPOINT",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildSummaryPrompt(tt.req)
for _, s := range tt.contains {
assert.Contains(t, result, s, "Expected result to contain: %s", s)
}
// Check assistant message only appears when set
if tt.req.LastAssistantMessage == "" {
assert.NotContains(t, result, "Claude's Full Response")
}
})
}
}
func TestBuildSummaryPrompt_TruncatesLongAssistantMessage(t *testing.T) {
longMessage := strings.Repeat("a", 5000)
req := SummaryRequest{
SessionDBID: 1,
SDKSessionID: "sdk-123",
Project: "test",
LastAssistantMessage: longMessage,
}
result := BuildSummaryPrompt(req)
// Should contain truncation indicator
assert.Contains(t, result, "truncated")
// Result should be reasonable length (less than full 5000 + overhead)
assert.Less(t, len(result), 6000)
}
func TestToolExecution_Struct(t *testing.T) {
exec := ToolExecution{
ID: 42,
ToolName: "Write",
ToolInput: `{"file_path": "/test.go"}`,
ToolOutput: "File written",
CreatedAtEpoch: 1234567890000,
CWD: "/workspace",
}
assert.Equal(t, int64(42), exec.ID)
assert.Equal(t, "Write", exec.ToolName)
assert.Equal(t, `{"file_path": "/test.go"}`, exec.ToolInput)
assert.Equal(t, "File written", exec.ToolOutput)
assert.Equal(t, int64(1234567890000), exec.CreatedAtEpoch)
assert.Equal(t, "/workspace", exec.CWD)
}
func TestSummaryRequest_Struct(t *testing.T) {
req := SummaryRequest{
SessionDBID: 100,
SDKSessionID: "sdk-abc",
Project: "my-project",
UserPrompt: "Fix the bug",
LastUserMessage: "Please fix the auth bug",
LastAssistantMessage: "I've fixed the authentication issue",
}
assert.Equal(t, int64(100), req.SessionDBID)
assert.Equal(t, "sdk-abc", req.SDKSessionID)
assert.Equal(t, "my-project", req.Project)
assert.Equal(t, "Fix the bug", req.UserPrompt)
assert.Equal(t, "Please fix the auth bug", req.LastUserMessage)
assert.Equal(t, "I've fixed the authentication issue", req.LastAssistantMessage)
}
+55 -19
View File
@@ -80,8 +80,9 @@ type Service struct {
server *http.Server
startTime time.Time
// Retrieval statistics
retrievalStats RetrievalStats
// Retrieval statistics (per-project)
retrievalStats map[string]*RetrievalStats
retrievalStatsMu sync.RWMutex
// Lifecycle
ctx context.Context
@@ -137,6 +138,7 @@ func NewService(version string) (*Service, error) {
cancel: cancel,
startTime: time.Now(),
updater: update.New(version, installDir),
retrievalStats: make(map[string]*RetrievalStats),
}
// Setup middleware and routes (health endpoint works immediately)
@@ -635,6 +637,9 @@ func (s *Service) setupRoutes() {
s.router.Get("/api/update/status", s.handleUpdateStatus)
s.router.Post("/api/update/restart", s.handleUpdateRestart)
// General restart endpoint (works before DB is ready)
s.router.Post("/api/restart", s.handleRestart)
// Selfcheck endpoint (works before DB is ready - checks all components)
s.router.Get("/api/selfcheck", s.handleSelfCheck)
@@ -670,29 +675,60 @@ func (s *Service) setupRoutes() {
})
}
// recordRetrievalStats atomically updates retrieval statistics.
func (s *Service) recordRetrievalStats(served, verified, deleted int64, isSearch bool) {
atomic.AddInt64(&s.retrievalStats.TotalRequests, 1)
atomic.AddInt64(&s.retrievalStats.ObservationsServed, served)
atomic.AddInt64(&s.retrievalStats.VerifiedStale, verified)
atomic.AddInt64(&s.retrievalStats.DeletedInvalid, deleted)
// recordRetrievalStats atomically updates retrieval statistics for a project.
func (s *Service) recordRetrievalStats(project string, served, verified, deleted int64, isSearch bool) {
s.retrievalStatsMu.Lock()
stats := s.retrievalStats[project]
if stats == nil {
stats = &RetrievalStats{}
s.retrievalStats[project] = stats
}
s.retrievalStatsMu.Unlock()
atomic.AddInt64(&stats.TotalRequests, 1)
atomic.AddInt64(&stats.ObservationsServed, served)
atomic.AddInt64(&stats.VerifiedStale, verified)
atomic.AddInt64(&stats.DeletedInvalid, deleted)
if isSearch {
atomic.AddInt64(&s.retrievalStats.SearchRequests, 1)
atomic.AddInt64(&stats.SearchRequests, 1)
} else {
atomic.AddInt64(&s.retrievalStats.ContextInjections, 1)
atomic.AddInt64(&stats.ContextInjections, 1)
}
}
// GetRetrievalStats returns a copy of the retrieval stats.
func (s *Service) GetRetrievalStats() RetrievalStats {
return RetrievalStats{
TotalRequests: atomic.LoadInt64(&s.retrievalStats.TotalRequests),
ObservationsServed: atomic.LoadInt64(&s.retrievalStats.ObservationsServed),
VerifiedStale: atomic.LoadInt64(&s.retrievalStats.VerifiedStale),
DeletedInvalid: atomic.LoadInt64(&s.retrievalStats.DeletedInvalid),
SearchRequests: atomic.LoadInt64(&s.retrievalStats.SearchRequests),
ContextInjections: atomic.LoadInt64(&s.retrievalStats.ContextInjections),
// GetRetrievalStats returns a copy of the retrieval stats for a project.
// If project is empty, returns aggregate stats across all projects.
func (s *Service) GetRetrievalStats(project string) RetrievalStats {
s.retrievalStatsMu.RLock()
defer s.retrievalStatsMu.RUnlock()
if project != "" {
// Return stats for specific project
stats := s.retrievalStats[project]
if stats == nil {
return RetrievalStats{}
}
return RetrievalStats{
TotalRequests: atomic.LoadInt64(&stats.TotalRequests),
ObservationsServed: atomic.LoadInt64(&stats.ObservationsServed),
VerifiedStale: atomic.LoadInt64(&stats.VerifiedStale),
DeletedInvalid: atomic.LoadInt64(&stats.DeletedInvalid),
SearchRequests: atomic.LoadInt64(&stats.SearchRequests),
ContextInjections: atomic.LoadInt64(&stats.ContextInjections),
}
}
// Aggregate stats across all projects
var result RetrievalStats
for _, stats := range s.retrievalStats {
result.TotalRequests += atomic.LoadInt64(&stats.TotalRequests)
result.ObservationsServed += atomic.LoadInt64(&stats.ObservationsServed)
result.VerifiedStale += atomic.LoadInt64(&stats.VerifiedStale)
result.DeletedInvalid += atomic.LoadInt64(&stats.DeletedInvalid)
result.SearchRequests += atomic.LoadInt64(&stats.SearchRequests)
result.ContextInjections += atomic.LoadInt64(&stats.ContextInjections)
}
return result
}
// Start starts the worker service.
+615
View File
@@ -0,0 +1,615 @@
// Package session provides session lifecycle management for claude-mnemonic.
package session
import (
"context"
"os"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
// Import sqlite driver
_ "github.com/mattn/go-sqlite3"
)
// hasFTS5 checks if FTS5 is available in the SQLite build.
func hasFTS5(t *testing.T) bool {
t.Helper()
tmpDir, err := os.MkdirTemp("", "fts5-check-*")
if err != nil {
return false
}
defer func() { _ = os.RemoveAll(tmpDir) }()
store, err := sqlite.NewStore(sqlite.StoreConfig{
Path: tmpDir + "/check.db",
MaxConns: 1,
WALMode: true,
})
if err != nil {
return false
}
_ = store.Close()
return true
}
// testStore creates a sqlite.Store with a temporary database for testing.
func testStore(t *testing.T) (*sqlite.Store, func()) {
t.Helper()
if !hasFTS5(t) {
t.Skip("FTS5 not available in this SQLite build")
}
tmpDir, err := os.MkdirTemp("", "session-integration-test-*")
require.NoError(t, err)
dbPath := tmpDir + "/test.db"
store, err := sqlite.NewStore(sqlite.StoreConfig{
Path: dbPath,
MaxConns: 1,
WALMode: true,
})
require.NoError(t, err)
cleanup := func() {
_ = store.Close()
_ = os.RemoveAll(tmpDir)
}
return store, cleanup
}
// SessionIntegrationSuite tests session manager with real SQLite stores.
type SessionIntegrationSuite struct {
suite.Suite
store *sqlite.Store
sessionStore *sqlite.SessionStore
cleanup func()
manager *Manager
}
func (s *SessionIntegrationSuite) SetupTest() {
if !hasFTS5(s.T()) {
s.T().Skip("FTS5 not available in this SQLite build")
}
s.store, s.cleanup = testStore(s.T())
s.sessionStore = sqlite.NewSessionStore(s.store)
s.manager = NewManager(s.sessionStore)
}
func (s *SessionIntegrationSuite) TearDownTest() {
if s.manager != nil {
s.manager.ShutdownAll(context.Background())
}
if s.cleanup != nil {
s.cleanup()
}
}
func TestSessionIntegrationSuite(t *testing.T) {
suite.Run(t, new(SessionIntegrationSuite))
}
// TestNewManager_WithRealStore tests manager creation with real store.
func (s *SessionIntegrationSuite) TestNewManager_WithRealStore() {
s.NotNil(s.manager)
s.NotNil(s.manager.sessionStore)
s.NotNil(s.manager.sessions)
s.NotNil(s.manager.ProcessNotify)
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestInitializeSession_WithRealStore tests session initialization.
func (s *SessionIntegrationSuite) TestInitializeSession_WithRealStore() {
ctx := context.Background()
// Create a session in the database first
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-test-123", "test-project", "initial prompt")
s.Require().NoError(err)
s.Require().Greater(sessionID, int64(0))
// Initialize in manager
session, err := s.manager.InitializeSession(ctx, sessionID, "user prompt", 1)
s.Require().NoError(err)
s.Require().NotNil(session)
// Verify session properties
s.Equal(sessionID, session.SessionDBID)
s.Equal("claude-test-123", session.ClaudeSessionID)
s.Equal("test-project", session.Project)
s.Equal("user prompt", session.UserPrompt)
s.Equal(1, session.LastPromptNumber)
// Verify manager state
s.Equal(1, s.manager.GetActiveSessionCount())
}
// TestInitializeSession_ReuseExisting tests that existing sessions are reused.
func (s *SessionIntegrationSuite) TestInitializeSession_ReuseExisting() {
ctx := context.Background()
// Create session in database
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-reuse-123", "test-project", "prompt")
s.Require().NoError(err)
// Initialize first time
session1, err := s.manager.InitializeSession(ctx, sessionID, "prompt 1", 1)
s.Require().NoError(err)
s.Require().NotNil(session1)
// Initialize second time - should reuse
session2, err := s.manager.InitializeSession(ctx, sessionID, "prompt 2", 2)
s.Require().NoError(err)
s.Require().NotNil(session2)
// Should be the same session pointer
s.Same(session1, session2)
// Should have updated user prompt
s.Equal("prompt 2", session2.UserPrompt)
s.Equal(2, session2.LastPromptNumber)
// Still only 1 active session
s.Equal(1, s.manager.GetActiveSessionCount())
}
// TestInitializeSession_NonExistentSession tests initializing non-existent session.
func (s *SessionIntegrationSuite) TestInitializeSession_NonExistentSession() {
ctx := context.Background()
// Try to initialize non-existent session
session, err := s.manager.InitializeSession(ctx, 999999, "prompt", 1)
s.NoError(err) // No error, just nil session
s.Nil(session)
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestInitializeSession_EmptyUserPrompt tests initialization with empty user prompt.
func (s *SessionIntegrationSuite) TestInitializeSession_EmptyUserPrompt() {
ctx := context.Background()
// Create session with initial prompt in database
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-empty-prompt", "test-project", "db prompt")
s.Require().NoError(err)
// Initialize with empty user prompt - should use database prompt
session, err := s.manager.InitializeSession(ctx, sessionID, "", 0)
s.Require().NoError(err)
s.Require().NotNil(session)
// Should use database prompt
s.Equal("db prompt", session.UserPrompt)
}
// TestQueueObservation_WithRealStore tests observation queuing.
func (s *SessionIntegrationSuite) TestQueueObservation_WithRealStore() {
ctx := context.Background()
// Create and initialize session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-queue-obs", "test-project", "prompt")
s.Require().NoError(err)
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Queue an observation
err = s.manager.QueueObservation(ctx, sessionID, ObservationData{
ToolName: "Read",
ToolInput: map[string]string{"path": "/test.go"},
ToolResponse: "file content",
PromptNumber: 1,
CWD: "/project",
})
s.Require().NoError(err)
// Check queue depth
s.Equal(1, s.manager.GetTotalQueueDepth())
s.True(s.manager.IsAnySessionProcessing())
// Drain messages
messages := s.manager.DrainMessages(sessionID)
s.Len(messages, 1)
s.Equal(MessageTypeObservation, messages[0].Type)
s.Equal("Read", messages[0].Observation.ToolName)
}
// TestQueueObservation_AutoInitialize tests auto-initialization on queue.
func (s *SessionIntegrationSuite) TestQueueObservation_AutoInitialize() {
ctx := context.Background()
// Create session in database but don't initialize in manager
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-auto-init", "test-project", "prompt")
s.Require().NoError(err)
// Queue observation without explicit initialization
err = s.manager.QueueObservation(ctx, sessionID, ObservationData{
ToolName: "Write",
ToolInput: "test input",
ToolResponse: "success",
PromptNumber: 1,
})
s.Require().NoError(err)
// Session should be auto-initialized
s.Equal(1, s.manager.GetActiveSessionCount())
s.Equal(1, s.manager.GetTotalQueueDepth())
}
// TestQueueObservation_NonExistentSession tests queuing to non-existent session.
func (s *SessionIntegrationSuite) TestQueueObservation_NonExistentSession() {
ctx := context.Background()
// Try to queue to non-existent session
err := s.manager.QueueObservation(ctx, 999999, ObservationData{
ToolName: "Test",
})
// Should not error, but session won't be created
s.NoError(err)
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestQueueSummarize_WithRealStore tests summarize queuing.
func (s *SessionIntegrationSuite) TestQueueSummarize_WithRealStore() {
ctx := context.Background()
// Create and initialize session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-queue-sum", "test-project", "prompt")
s.Require().NoError(err)
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Queue a summarize request
err = s.manager.QueueSummarize(ctx, sessionID, "What did you do?", "I completed the task.")
s.Require().NoError(err)
// Check queue depth
s.Equal(1, s.manager.GetTotalQueueDepth())
s.True(s.manager.IsAnySessionProcessing())
// Drain messages
messages := s.manager.DrainMessages(sessionID)
s.Len(messages, 1)
s.Equal(MessageTypeSummarize, messages[0].Type)
s.Equal("What did you do?", messages[0].Summarize.LastUserMessage)
s.Equal("I completed the task.", messages[0].Summarize.LastAssistantMessage)
}
// TestQueueSummarize_AutoInitialize tests auto-initialization on summarize queue.
func (s *SessionIntegrationSuite) TestQueueSummarize_AutoInitialize() {
ctx := context.Background()
// Create session in database but don't initialize in manager
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-sum-auto", "test-project", "prompt")
s.Require().NoError(err)
// Queue summarize without explicit initialization
err = s.manager.QueueSummarize(ctx, sessionID, "user msg", "assistant msg")
s.Require().NoError(err)
// Session should be auto-initialized
s.Equal(1, s.manager.GetActiveSessionCount())
s.Equal(1, s.manager.GetTotalQueueDepth())
}
// TestQueueSummarize_NonExistentSession tests summarize queuing to non-existent session.
func (s *SessionIntegrationSuite) TestQueueSummarize_NonExistentSession() {
ctx := context.Background()
// Try to queue to non-existent session
err := s.manager.QueueSummarize(ctx, 999999, "user", "assistant")
// Should not error, but session won't be created
s.NoError(err)
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestMixedQueueOperations tests mixed observation and summarize queuing.
func (s *SessionIntegrationSuite) TestMixedQueueOperations() {
ctx := context.Background()
// Create and initialize session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-mixed", "test-project", "prompt")
s.Require().NoError(err)
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Queue multiple messages of different types
err = s.manager.QueueObservation(ctx, sessionID, ObservationData{ToolName: "Tool1"})
s.Require().NoError(err)
err = s.manager.QueueSummarize(ctx, sessionID, "user1", "assistant1")
s.Require().NoError(err)
err = s.manager.QueueObservation(ctx, sessionID, ObservationData{ToolName: "Tool2"})
s.Require().NoError(err)
// Check total queue depth
s.Equal(3, s.manager.GetTotalQueueDepth())
// Drain and verify order
messages := s.manager.DrainMessages(sessionID)
s.Len(messages, 3)
s.Equal(MessageTypeObservation, messages[0].Type)
s.Equal("Tool1", messages[0].Observation.ToolName)
s.Equal(MessageTypeSummarize, messages[1].Type)
s.Equal(MessageTypeObservation, messages[2].Type)
s.Equal("Tool2", messages[2].Observation.ToolName)
}
// TestProcessNotifyChannel tests the process notification channel behavior.
func (s *SessionIntegrationSuite) TestProcessNotifyChannel() {
ctx := context.Background()
// Create and initialize session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-notify", "test-project", "prompt")
s.Require().NoError(err)
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Drain any existing notifications
select {
case <-s.manager.ProcessNotify:
default:
}
// Queue observation - should trigger notification
err = s.manager.QueueObservation(ctx, sessionID, ObservationData{ToolName: "Test"})
s.Require().NoError(err)
// Should be able to receive notification
select {
case <-s.manager.ProcessNotify:
// Success
case <-time.After(100 * time.Millisecond):
s.Fail("Should have received process notification")
}
}
// TestSessionCallbacks tests session lifecycle callbacks.
func (s *SessionIntegrationSuite) TestSessionCallbacks() {
ctx := context.Background()
var createdID, deletedID int64
s.manager.SetOnSessionCreated(func(id int64) {
createdID = id
})
s.manager.SetOnSessionDeleted(func(id int64) {
deletedID = id
})
// Create session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-callbacks", "test-project", "prompt")
s.Require().NoError(err)
// Initialize - should trigger created callback
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
s.Equal(sessionID, createdID)
// Delete - should trigger deleted callback
s.manager.DeleteSession(sessionID)
s.Equal(sessionID, deletedID)
}
// TestMultipleSessions tests managing multiple sessions.
func (s *SessionIntegrationSuite) TestMultipleSessions() {
ctx := context.Background()
// Create multiple sessions
var sessionIDs []int64
for i := 0; i < 5; i++ {
id, err := s.sessionStore.CreateSDKSession(ctx, "claude-multi-"+string(rune('A'+i)), "project-"+string(rune('a'+i)), "prompt")
s.Require().NoError(err)
sessionIDs = append(sessionIDs, id)
}
// Initialize all
for _, id := range sessionIDs {
_, err := s.manager.InitializeSession(ctx, id, "prompt", 1)
s.Require().NoError(err)
}
s.Equal(5, s.manager.GetActiveSessionCount())
// Queue observations to each
for i, id := range sessionIDs {
err := s.manager.QueueObservation(ctx, id, ObservationData{
ToolName: "Tool" + string(rune('A'+i)),
})
s.Require().NoError(err)
}
s.Equal(5, s.manager.GetTotalQueueDepth())
// Get all sessions
sessions := s.manager.GetAllSessions()
s.Len(sessions, 5)
// Delete all
for _, id := range sessionIDs {
s.manager.DeleteSession(id)
}
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions tests the cleanup of stale sessions.
func (s *SessionIntegrationSuite) TestCleanupStaleSessions() {
ctx := context.Background()
// Create and initialize a session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-stale", "test-project", "prompt")
s.Require().NoError(err)
session, err := s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Manually set start time to past (simulate stale session)
session.StartTime = time.Now().Add(-SessionTimeout - time.Minute)
// Run cleanup
s.manager.cleanupStaleSessions()
// Session should be deleted
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_WithPendingMessages tests cleanup doesn't delete sessions with pending messages.
func (s *SessionIntegrationSuite) TestCleanupStaleSessions_WithPendingMessages() {
ctx := context.Background()
// Create and initialize a session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-stale-pending", "test-project", "prompt")
s.Require().NoError(err)
session, err := s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Make session stale but add pending messages
session.StartTime = time.Now().Add(-SessionTimeout - time.Minute)
err = s.manager.QueueObservation(ctx, sessionID, ObservationData{ToolName: "Test"})
s.Require().NoError(err)
// Run cleanup
s.manager.cleanupStaleSessions()
// Session should NOT be deleted (has pending messages)
s.Equal(1, s.manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_WithActiveGenerator tests cleanup doesn't delete sessions with active generator.
func (s *SessionIntegrationSuite) TestCleanupStaleSessions_WithActiveGenerator() {
ctx := context.Background()
// Create and initialize a session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-stale-gen", "test-project", "prompt")
s.Require().NoError(err)
session, err := s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Make session stale but mark generator as active
session.StartTime = time.Now().Add(-SessionTimeout - time.Minute)
session.generatorActive.Store(true)
// Run cleanup
s.manager.cleanupStaleSessions()
// Session should NOT be deleted (generator is active)
s.Equal(1, s.manager.GetActiveSessionCount())
}
// TestConcurrentQueueOperations tests thread-safe queue operations.
func (s *SessionIntegrationSuite) TestConcurrentQueueOperations() {
ctx := context.Background()
// Create and initialize session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-concurrent", "test-project", "prompt")
s.Require().NoError(err)
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
// Concurrent queue operations
done := make(chan bool)
numGoroutines := 50
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
if idx%2 == 0 {
_ = s.manager.QueueObservation(ctx, sessionID, ObservationData{
ToolName: "Tool",
})
} else {
_ = s.manager.QueueSummarize(ctx, sessionID, "user", "assistant")
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < numGoroutines; i++ {
<-done
}
// All messages should be queued
s.Equal(numGoroutines, s.manager.GetTotalQueueDepth())
}
// TestShutdownAll_WithRealSessions tests shutdown of all real sessions.
func (s *SessionIntegrationSuite) TestShutdownAll_WithRealSessions() {
ctx := context.Background()
// Create and initialize multiple sessions
for i := 0; i < 3; i++ {
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-shutdown-"+string(rune('A'+i)), "project", "prompt")
s.Require().NoError(err)
_, err = s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
}
s.Equal(3, s.manager.GetActiveSessionCount())
// Shutdown all
s.manager.ShutdownAll(ctx)
// All sessions should be deleted
s.Equal(0, s.manager.GetActiveSessionCount())
}
// TestSessionSDKSessionID tests SDK session ID handling.
func (s *SessionIntegrationSuite) TestSessionSDKSessionID() {
ctx := context.Background()
// Create session - SDK session ID is generated
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-sdk-test", "test-project", "prompt")
s.Require().NoError(err)
// Initialize in manager
session, err := s.manager.InitializeSession(ctx, sessionID, "prompt", 1)
s.Require().NoError(err)
s.Require().NotNil(session)
// SDK session ID should be set
s.NotEmpty(session.SDKSessionID)
}
// TestPromptNumberTracking tests prompt number tracking across operations.
func (s *SessionIntegrationSuite) TestPromptNumberTracking() {
ctx := context.Background()
// Create session
sessionID, err := s.sessionStore.CreateSDKSession(ctx, "claude-prompt-num", "test-project", "initial")
s.Require().NoError(err)
// Initialize with prompt 1
session, err := s.manager.InitializeSession(ctx, sessionID, "prompt 1", 1)
s.Require().NoError(err)
s.Equal(1, session.LastPromptNumber)
// Re-initialize with prompt 2
session, err = s.manager.InitializeSession(ctx, sessionID, "prompt 2", 2)
s.Require().NoError(err)
s.Equal(2, session.LastPromptNumber)
// Re-initialize with prompt 5
session, err = s.manager.InitializeSession(ctx, sessionID, "prompt 5", 5)
s.Require().NoError(err)
s.Equal(5, session.LastPromptNumber)
}
+695
View File
@@ -0,0 +1,695 @@
// Package session provides session lifecycle management for claude-mnemonic.
package session
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
// ManagerSuite is a test suite for Manager operations.
type ManagerSuite struct {
suite.Suite
manager *Manager
}
func (s *ManagerSuite) SetupTest() {
// Create manager without real session store (use nil for unit tests)
s.manager = &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
// Initialize context for manager
ctx, cancel := context.WithCancel(context.Background())
s.manager.ctx = ctx
s.manager.cancel = cancel
}
func (s *ManagerSuite) TearDownTest() {
if s.manager != nil && s.manager.cancel != nil {
s.manager.cancel()
}
}
func TestManagerSuite(t *testing.T) {
suite.Run(t, new(ManagerSuite))
}
// TestActiveSession tests ActiveSession creation and basic operations.
func (s *ManagerSuite) TestActiveSession() {
session := &ActiveSession{
SessionDBID: 1,
ClaudeSessionID: "claude-123",
SDKSessionID: "sdk-123",
Project: "test-project",
UserPrompt: "Hello",
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
s.Equal(int64(1), session.SessionDBID)
s.Equal("claude-123", session.ClaudeSessionID)
s.Equal("sdk-123", session.SDKSessionID)
s.Equal("test-project", session.Project)
s.Equal("Hello", session.UserPrompt)
}
// TestGetActiveSessionCount tests session counting.
func (s *ManagerSuite) TestGetActiveSessionCount() {
// Initially 0
s.Equal(0, s.manager.GetActiveSessionCount())
// Add sessions directly for testing
s.manager.sessions[1] = &ActiveSession{SessionDBID: 1}
s.manager.sessions[2] = &ActiveSession{SessionDBID: 2}
s.Equal(2, s.manager.GetActiveSessionCount())
}
// TestGetTotalQueueDepth tests queue depth calculation.
func (s *ManagerSuite) TestGetTotalQueueDepth() {
// Initially 0
s.Equal(0, s.manager.GetTotalQueueDepth())
// Add sessions with pending messages
s.manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 3),
}
s.manager.sessions[2] = &ActiveSession{
SessionDBID: 2,
pendingMessages: make([]PendingMessage, 5),
}
s.Equal(8, s.manager.GetTotalQueueDepth())
}
// TestIsAnySessionProcessing tests processing status detection.
func (s *ManagerSuite) TestIsAnySessionProcessing() {
// No sessions - not processing
s.False(s.manager.IsAnySessionProcessing())
// Session with no pending - not processing
s.manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
pendingMessages: []PendingMessage{},
}
s.False(s.manager.IsAnySessionProcessing())
// Session with pending - processing
s.manager.sessions[1].pendingMessages = []PendingMessage{{Type: MessageTypeObservation}}
s.True(s.manager.IsAnySessionProcessing())
// Clear pending but set generator active
s.manager.sessions[1].pendingMessages = []PendingMessage{}
s.manager.sessions[1].generatorActive.Store(true)
s.True(s.manager.IsAnySessionProcessing())
}
// TestGetAllSessions tests retrieving all sessions.
func (s *ManagerSuite) TestGetAllSessions() {
// Empty
sessions := s.manager.GetAllSessions()
s.Empty(sessions)
// Add sessions
session1 := &ActiveSession{SessionDBID: 1, Project: "project-a"}
session2 := &ActiveSession{SessionDBID: 2, Project: "project-b"}
s.manager.sessions[1] = session1
s.manager.sessions[2] = session2
sessions = s.manager.GetAllSessions()
s.Len(sessions, 2)
}
// TestDeleteSession tests session deletion.
func (s *ManagerSuite) TestDeleteSession() {
// Create session with context
ctx, cancel := context.WithCancel(context.Background())
session := &ActiveSession{
SessionDBID: 1,
Project: "test-project",
StartTime: time.Now(),
pendingMessages: []PendingMessage{},
ctx: ctx,
cancel: cancel,
}
s.manager.sessions[1] = session
// Track callback
var deletedID int64
s.manager.SetOnSessionDeleted(func(id int64) {
deletedID = id
})
s.Equal(1, s.manager.GetActiveSessionCount())
// Delete
s.manager.DeleteSession(1)
s.Equal(0, s.manager.GetActiveSessionCount())
s.Equal(int64(1), deletedID)
// Double delete should be safe
s.manager.DeleteSession(1)
}
// TestDrainMessages tests message draining.
func (s *ManagerSuite) TestDrainMessages() {
// No session - nil
messages := s.manager.DrainMessages(999)
s.Nil(messages)
// Session with messages
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: []PendingMessage{
{Type: MessageTypeObservation},
{Type: MessageTypeSummarize},
},
}
s.manager.sessions[1] = session
messages = s.manager.DrainMessages(1)
s.Len(messages, 2)
// Queue should be empty now
s.Empty(session.pendingMessages)
// Drain again - empty
messages = s.manager.DrainMessages(1)
s.Empty(messages)
}
// TestSetOnSessionCreated tests callback setting.
func (s *ManagerSuite) TestSetOnSessionCreated() {
var calledWith int64
callback := func(id int64) {
calledWith = id
}
s.manager.SetOnSessionCreated(callback)
s.NotNil(s.manager.onCreated)
// Simulate callback
if s.manager.onCreated != nil {
s.manager.onCreated(42)
}
s.Equal(int64(42), calledWith)
}
// TestSetOnSessionDeleted tests callback setting.
func (s *ManagerSuite) TestSetOnSessionDeleted() {
var calledWith int64
callback := func(id int64) {
calledWith = id
}
s.manager.SetOnSessionDeleted(callback)
s.NotNil(s.manager.onDeleted)
// Simulate callback
if s.manager.onDeleted != nil {
s.manager.onDeleted(42)
}
s.Equal(int64(42), calledWith)
}
// TestMessageTypes tests message type constants.
func TestMessageTypes(t *testing.T) {
assert.Equal(t, MessageType(0), MessageTypeObservation)
assert.Equal(t, MessageType(1), MessageTypeSummarize)
}
// TestTimeoutConstants tests timeout constants.
func TestTimeoutConstants(t *testing.T) {
assert.Equal(t, 30*time.Minute, SessionTimeout)
assert.Equal(t, 5*time.Minute, CleanupInterval)
}
// TestObservationData tests observation data structure.
func TestObservationData(t *testing.T) {
data := ObservationData{
ToolName: "Read",
ToolInput: map[string]string{"path": "/test/file.go"},
ToolResponse: "file content",
PromptNumber: 1,
CWD: "/test",
}
assert.Equal(t, "Read", data.ToolName)
assert.Equal(t, 1, data.PromptNumber)
assert.Equal(t, "/test", data.CWD)
}
// TestSummarizeData tests summarize data structure.
func TestSummarizeData(t *testing.T) {
data := SummarizeData{
LastUserMessage: "What did you do?",
LastAssistantMessage: "I completed the task.",
}
assert.Equal(t, "What did you do?", data.LastUserMessage)
assert.Equal(t, "I completed the task.", data.LastAssistantMessage)
}
// TestPendingMessage tests pending message structure.
func TestPendingMessage(t *testing.T) {
obsData := &ObservationData{ToolName: "Read"}
msg := PendingMessage{
Type: MessageTypeObservation,
Observation: obsData,
}
assert.Equal(t, MessageTypeObservation, msg.Type)
assert.NotNil(t, msg.Observation)
assert.Nil(t, msg.Summarize)
sumData := &SummarizeData{LastUserMessage: "Test"}
msg2 := PendingMessage{
Type: MessageTypeSummarize,
Summarize: sumData,
}
assert.Equal(t, MessageTypeSummarize, msg2.Type)
assert.Nil(t, msg2.Observation)
assert.NotNil(t, msg2.Summarize)
}
// TestConcurrentSessionAccess tests thread-safe session operations.
func TestConcurrentSessionAccess(t *testing.T) {
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
var wg sync.WaitGroup
numGoroutines := 100
// Concurrent session operations
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int64) {
defer wg.Done()
// Add session
ctx, cancel := context.WithCancel(context.Background())
manager.mu.Lock()
manager.sessions[id] = &ActiveSession{
SessionDBID: id,
Project: "test",
StartTime: time.Now(),
ctx: ctx,
cancel: cancel,
}
manager.mu.Unlock()
// Read operations
_ = manager.GetActiveSessionCount()
_ = manager.GetTotalQueueDepth()
_ = manager.IsAnySessionProcessing()
_ = manager.GetAllSessions()
// Delete session
manager.DeleteSession(id)
}(int64(i))
}
wg.Wait()
// All sessions should be deleted
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestProcessNotifyChannel tests the process notification channel.
func TestProcessNotifyChannel(t *testing.T) {
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
// Non-blocking send should work
select {
case manager.ProcessNotify <- struct{}{}:
// Success
default:
t.Error("ProcessNotify channel should accept first message")
}
// Second send should not block (channel is buffered with size 1)
select {
case manager.ProcessNotify <- struct{}{}:
// Full buffer, this is expected behavior
default:
// This is fine - channel is full
}
// Drain the channel
select {
case <-manager.ProcessNotify:
// Drained
default:
t.Error("Should be able to receive from ProcessNotify")
}
}
// TestActiveSessionContext tests session context handling.
func TestActiveSessionContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
session := &ActiveSession{
SessionDBID: 1,
ctx: ctx,
cancel: cancel,
}
// Context should not be done
select {
case <-session.ctx.Done():
t.Error("Context should not be done yet")
default:
// Expected
}
// Cancel context
session.cancel()
// Context should be done
select {
case <-session.ctx.Done():
// Expected
default:
t.Error("Context should be done after cancel")
}
}
// TestGeneratorActive tests the atomic generator active flag.
func TestGeneratorActive(t *testing.T) {
session := &ActiveSession{}
// Initially false
assert.False(t, session.generatorActive.Load())
// Set to true
session.generatorActive.Store(true)
assert.True(t, session.generatorActive.Load())
// Set back to false
session.generatorActive.Store(false)
assert.False(t, session.generatorActive.Load())
}
// TestTokenAccumulation tests token accumulation fields.
func TestTokenAccumulation(t *testing.T) {
session := &ActiveSession{
CumulativeInputTokens: 0,
CumulativeOutputTokens: 0,
}
// Accumulate tokens
session.CumulativeInputTokens += 100
session.CumulativeOutputTokens += 50
assert.Equal(t, int64(100), session.CumulativeInputTokens)
assert.Equal(t, int64(50), session.CumulativeOutputTokens)
// Add more
session.CumulativeInputTokens += 200
session.CumulativeOutputTokens += 100
assert.Equal(t, int64(300), session.CumulativeInputTokens)
assert.Equal(t, int64(150), session.CumulativeOutputTokens)
}
// TestShutdownAll tests graceful shutdown of all sessions.
func (s *ManagerSuite) TestShutdownAll() {
// Create multiple sessions
for i := int64(1); i <= 3; i++ {
ctx, cancel := context.WithCancel(context.Background())
s.manager.sessions[i] = &ActiveSession{
SessionDBID: i,
Project: "test-project",
StartTime: time.Now(),
pendingMessages: []PendingMessage{},
ctx: ctx,
cancel: cancel,
}
}
s.Equal(3, s.manager.GetActiveSessionCount())
// Track deleted sessions
var deletedIDs []int64
s.manager.SetOnSessionDeleted(func(id int64) {
deletedIDs = append(deletedIDs, id)
})
// Shutdown all
s.manager.ShutdownAll(context.Background())
// All sessions should be deleted
s.Equal(0, s.manager.GetActiveSessionCount())
s.Len(deletedIDs, 3)
}
// TestDeleteNonExistentSession tests deleting a session that doesn't exist.
func (s *ManagerSuite) TestDeleteNonExistentSession() {
// Track callback
callbackCalled := false
s.manager.SetOnSessionDeleted(func(id int64) {
callbackCalled = true
})
// Delete non-existent session
s.manager.DeleteSession(999)
// Callback should not be called
s.False(callbackCalled)
}
// TestLastPromptNumber tests prompt number tracking.
func TestLastPromptNumber(t *testing.T) {
session := &ActiveSession{
SessionDBID: 1,
LastPromptNumber: 0,
}
assert.Equal(t, 0, session.LastPromptNumber)
session.LastPromptNumber = 5
assert.Equal(t, 5, session.LastPromptNumber)
session.LastPromptNumber++
assert.Equal(t, 6, session.LastPromptNumber)
}
// TestActiveSessionNotifyChannel tests session notification channel.
func TestActiveSessionNotifyChannel(t *testing.T) {
session := &ActiveSession{
notify: make(chan struct{}, 1),
}
// Non-blocking send
select {
case session.notify <- struct{}{}:
// Success
default:
t.Error("Should accept first notification")
}
// Second send should not block
select {
case session.notify <- struct{}{}:
// Full buffer
default:
// Expected - buffer is full
}
// Drain
select {
case <-session.notify:
// Drained
default:
t.Error("Should receive notification")
}
}
// TestMessageMutex tests message mutex operations.
func TestMessageMutex(t *testing.T) {
session := &ActiveSession{
pendingMessages: make([]PendingMessage, 0),
}
var wg sync.WaitGroup
// Concurrent message operations
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
session.messageMu.Lock()
session.pendingMessages = append(session.pendingMessages, PendingMessage{
Type: MessageTypeObservation,
})
session.messageMu.Unlock()
}()
}
wg.Wait()
assert.Len(t, session.pendingMessages, 50)
}
// TestQueueDepthMultipleSessions tests queue depth with multiple sessions.
func (s *ManagerSuite) TestQueueDepthMultipleSessions() {
// Add sessions with varying queue depths
s.manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 10),
}
s.manager.sessions[2] = &ActiveSession{
SessionDBID: 2,
pendingMessages: make([]PendingMessage, 0),
}
s.manager.sessions[3] = &ActiveSession{
SessionDBID: 3,
pendingMessages: make([]PendingMessage, 5),
}
s.Equal(15, s.manager.GetTotalQueueDepth())
}
// TestIsAnySessionProcessing_GeneratorOnly tests processing status with only generator active.
func (s *ManagerSuite) TestIsAnySessionProcessingGeneratorOnly() {
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: []PendingMessage{},
}
s.manager.sessions[1] = session
// No processing initially
s.False(s.manager.IsAnySessionProcessing())
// Set generator active
session.generatorActive.Store(true)
s.True(s.manager.IsAnySessionProcessing())
// Clear generator
session.generatorActive.Store(false)
s.False(s.manager.IsAnySessionProcessing())
}
// TestPendingMessageWithBothTypes tests pending messages with both types.
func TestPendingMessageWithBothTypes(t *testing.T) {
messages := []PendingMessage{
{
Type: MessageTypeObservation,
Observation: &ObservationData{ToolName: "Read"},
},
{
Type: MessageTypeSummarize,
Summarize: &SummarizeData{LastUserMessage: "Test"},
},
{
Type: MessageTypeObservation,
Observation: &ObservationData{ToolName: "Write"},
},
}
assert.Len(t, messages, 3)
// Verify types
assert.Equal(t, MessageTypeObservation, messages[0].Type)
assert.Equal(t, MessageTypeSummarize, messages[1].Type)
assert.Equal(t, MessageTypeObservation, messages[2].Type)
// Verify data
assert.Equal(t, "Read", messages[0].Observation.ToolName)
assert.Nil(t, messages[0].Summarize)
assert.Equal(t, "Test", messages[1].Summarize.LastUserMessage)
assert.Nil(t, messages[1].Observation)
assert.Equal(t, "Write", messages[2].Observation.ToolName)
}
// TestDrainMessagesPreservesOrder tests that draining preserves message order.
func (s *ManagerSuite) TestDrainMessagesPreservesOrder() {
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: []PendingMessage{
{Type: MessageTypeObservation, Observation: &ObservationData{ToolName: "Tool1"}},
{Type: MessageTypeSummarize, Summarize: &SummarizeData{LastUserMessage: "Msg1"}},
{Type: MessageTypeObservation, Observation: &ObservationData{ToolName: "Tool2"}},
},
}
s.manager.sessions[1] = session
messages := s.manager.DrainMessages(1)
s.Len(messages, 3)
s.Equal("Tool1", messages[0].Observation.ToolName)
s.Equal("Msg1", messages[1].Summarize.LastUserMessage)
s.Equal("Tool2", messages[2].Observation.ToolName)
}
// TestActiveSessionCWD tests CWD field in ObservationData.
func TestActiveSessionCWD(t *testing.T) {
tests := []struct {
name string
cwd string
}{
{"empty_cwd", ""},
{"absolute_path", "/home/user/project"},
{"windows_path", "C:\\Users\\test\\project"},
{"path_with_spaces", "/home/user/my project"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := ObservationData{
ToolName: "Test",
CWD: tt.cwd,
}
assert.Equal(t, tt.cwd, data.CWD)
})
}
}
// TestToolInputResponse tests various tool input/response types.
func TestToolInputResponse(t *testing.T) {
tests := []struct {
name string
input interface{}
response interface{}
}{
{"nil_values", nil, nil},
{"string_values", "input string", "response string"},
{"map_values", map[string]string{"key": "value"}, map[string]interface{}{"result": true}},
{"slice_values", []string{"a", "b"}, []int{1, 2, 3}},
{"int_values", 42, 100},
{"bool_values", true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := ObservationData{
ToolName: "TestTool",
ToolInput: tt.input,
ToolResponse: tt.response,
}
assert.Equal(t, tt.input, data.ToolInput)
assert.Equal(t, tt.response, data.ToolResponse)
})
}
}
+545
View File
@@ -0,0 +1,545 @@
// Package sse provides Server-Sent Events broadcasting for claude-mnemonic.
package sse
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// BroadcasterSuite is a test suite for Broadcaster operations.
type BroadcasterSuite struct {
suite.Suite
broadcaster *Broadcaster
}
func (s *BroadcasterSuite) SetupTest() {
s.broadcaster = NewBroadcaster()
}
func TestBroadcasterSuite(t *testing.T) {
suite.Run(t, new(BroadcasterSuite))
}
// TestNewBroadcaster tests broadcaster creation.
func (s *BroadcasterSuite) TestNewBroadcaster() {
b := NewBroadcaster()
s.NotNil(b)
s.NotNil(b.clients)
s.Equal(0, b.ClientCount())
}
// TestClientCount tests client counting.
func (s *BroadcasterSuite) TestClientCount() {
s.Equal(0, s.broadcaster.ClientCount())
}
// mockResponseWriter implements http.ResponseWriter and http.Flusher for testing.
type mockResponseWriter struct {
header http.Header
body []byte
statusCode int
mu sync.Mutex
}
func newMockResponseWriter() *mockResponseWriter {
return &mockResponseWriter{
header: make(http.Header),
statusCode: http.StatusOK,
}
}
func (m *mockResponseWriter) Header() http.Header {
return m.header
}
func (m *mockResponseWriter) Write(data []byte) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.body = append(m.body, data...)
return len(data), nil
}
func (m *mockResponseWriter) WriteHeader(statusCode int) {
m.statusCode = statusCode
}
func (m *mockResponseWriter) Flush() {
// No-op for testing
}
func (m *mockResponseWriter) GetBody() []byte {
m.mu.Lock()
defer m.mu.Unlock()
return m.body
}
// TestAddClient tests adding clients.
func (s *BroadcasterSuite) TestAddClient() {
w := newMockResponseWriter()
client, err := s.broadcaster.AddClient(w)
s.NoError(err)
s.NotNil(client)
s.NotEmpty(client.ID)
s.NotNil(client.Done)
s.Equal(1, s.broadcaster.ClientCount())
}
// TestAddMultipleClients tests adding multiple clients.
func (s *BroadcasterSuite) TestAddMultipleClients() {
for i := 0; i < 5; i++ {
w := newMockResponseWriter()
_, err := s.broadcaster.AddClient(w)
s.NoError(err)
}
s.Equal(5, s.broadcaster.ClientCount())
}
// TestRemoveClient tests removing clients.
func (s *BroadcasterSuite) TestRemoveClient() {
w := newMockResponseWriter()
client, err := s.broadcaster.AddClient(w)
s.NoError(err)
s.Equal(1, s.broadcaster.ClientCount())
s.broadcaster.RemoveClient(client)
s.Equal(0, s.broadcaster.ClientCount())
// Check that Done channel is closed
select {
case <-client.Done:
// Expected - channel is closed
default:
s.Fail("Done channel should be closed")
}
}
// TestBroadcast tests broadcasting messages.
func (s *BroadcasterSuite) TestBroadcast() {
w := newMockResponseWriter()
_, err := s.broadcaster.AddClient(w)
s.NoError(err)
// Broadcast a message
s.broadcaster.Broadcast(map[string]string{"type": "test", "message": "hello"})
// Give time for async write
time.Sleep(50 * time.Millisecond)
body := string(w.GetBody())
s.Contains(body, "data:")
s.Contains(body, "test")
s.Contains(body, "hello")
}
// TestBroadcastNoClients tests broadcasting with no clients.
func (s *BroadcasterSuite) TestBroadcastNoClients() {
// Should not panic
s.broadcaster.Broadcast(map[string]string{"type": "test"})
}
// TestBroadcastMultipleClients tests broadcasting to multiple clients.
func (s *BroadcasterSuite) TestBroadcastMultipleClients() {
writers := make([]*mockResponseWriter, 3)
for i := 0; i < 3; i++ {
writers[i] = newMockResponseWriter()
_, err := s.broadcaster.AddClient(writers[i])
s.NoError(err)
}
// Broadcast
s.broadcaster.Broadcast(map[string]string{"type": "test"})
// Give time for async writes
time.Sleep(100 * time.Millisecond)
// All clients should receive the message
for i, w := range writers {
body := string(w.GetBody())
s.Contains(body, "data:", "Client %d should receive data", i)
}
}
// TestClient tests Client structure.
func TestClient(t *testing.T) {
w := newMockResponseWriter()
client := &Client{
ID: "test-client",
Writer: w,
Flusher: w,
Done: make(chan struct{}),
}
assert.Equal(t, "test-client", client.ID)
assert.NotNil(t, client.Writer)
assert.NotNil(t, client.Flusher)
assert.NotNil(t, client.Done)
// Close done channel
close(client.Done)
select {
case <-client.Done:
// Expected
default:
t.Error("Done channel should be closed")
}
}
// TestClientUniqueIDs tests that clients get unique IDs.
func TestClientUniqueIDs(t *testing.T) {
b := NewBroadcaster()
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
w := newMockResponseWriter()
client, err := b.AddClient(w)
require.NoError(t, err)
// ID should be unique
assert.False(t, ids[client.ID], "ID %s should be unique", client.ID)
ids[client.ID] = true
}
}
// TestWriteTimeout tests the write timeout constant.
func TestWriteTimeout(t *testing.T) {
assert.Equal(t, 2*time.Second, WriteTimeout)
}
// TestHandleSSE tests the HandleSSE HTTP handler.
func TestHandleSSE(t *testing.T) {
b := NewBroadcaster()
// Create a test server
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set up context that will be cancelled
ctx := r.Context()
// Start goroutine to cancel context after short delay
go func() {
time.Sleep(50 * time.Millisecond)
// Request will be cancelled by the test client
}()
// This will block until context is cancelled
select {
case <-ctx.Done():
return
case <-time.After(100 * time.Millisecond):
return
}
})
_ = handler
_ = b
// Just verify the handler exists and broadcaster can handle SSE
req := httptest.NewRequest(http.MethodGet, "/events", nil)
rec := httptest.NewRecorder()
// Can't easily test HandleSSE since it blocks, but we can verify setup
assert.NotNil(t, req)
assert.NotNil(t, rec)
}
// TestBroadcastJSON tests broadcasting various JSON types.
func TestBroadcastJSON(t *testing.T) {
tests := []struct {
name string
data interface{}
wantErr bool
}{
{
name: "string map",
data: map[string]string{"key": "value"},
wantErr: false,
},
{
name: "int map",
data: map[string]int{"count": 42},
wantErr: false,
},
{
name: "nested struct",
data: struct{ Name string }{Name: "test"},
wantErr: false,
},
{
name: "array",
data: []string{"a", "b", "c"},
wantErr: false,
},
{
name: "interface map",
data: map[string]interface{}{"type": "test", "count": 1, "active": true},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := NewBroadcaster()
w := newMockResponseWriter()
_, err := b.AddClient(w)
require.NoError(t, err)
// Should not panic
b.Broadcast(tt.data)
time.Sleep(50 * time.Millisecond)
body := string(w.GetBody())
assert.Contains(t, body, "data:")
})
}
}
// TestConcurrentBroadcast tests concurrent broadcasting.
func TestConcurrentBroadcast(t *testing.T) {
b := NewBroadcaster()
// Add clients
for i := 0; i < 10; i++ {
w := newMockResponseWriter()
_, err := b.AddClient(w)
require.NoError(t, err)
}
// Broadcast concurrently
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
b.Broadcast(map[string]int{"index": i})
}(i)
}
wg.Wait()
// Should complete without panics
assert.Equal(t, 10, b.ClientCount())
}
// TestRemoveNonExistentClient tests removing a non-existent client.
func TestRemoveNonExistentClient(t *testing.T) {
b := NewBroadcaster()
// Create a client but don't add it
client := &Client{
ID: "fake-client",
Done: make(chan struct{}),
}
// Should not panic
b.RemoveClient(client)
// Done channel should be closed
select {
case <-client.Done:
// Expected
default:
t.Error("Done channel should be closed")
}
}
// TestBroadcasterConcurrentAddRemove tests concurrent add/remove operations.
func TestBroadcasterConcurrentAddRemove(t *testing.T) {
b := NewBroadcaster()
var wg sync.WaitGroup
// Concurrent adds
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
w := newMockResponseWriter()
client, err := b.AddClient(w)
if err == nil {
// Random chance to remove
if time.Now().UnixNano()%2 == 0 {
b.RemoveClient(client)
}
}
}()
}
wg.Wait()
// Should not panic and have some clients
count := b.ClientCount()
assert.GreaterOrEqual(t, count, 0)
}
// TestRemoveClientByID tests removing a client by ID.
func TestRemoveClientByID(t *testing.T) {
b := NewBroadcaster()
w := newMockResponseWriter()
client, err := b.AddClient(w)
require.NoError(t, err)
assert.Equal(t, 1, b.ClientCount())
// Remove by ID
b.removeClientByID(client.ID)
assert.Equal(t, 0, b.ClientCount())
// Done channel should be closed
select {
case <-client.Done:
// Expected
default:
t.Error("Done channel should be closed")
}
}
// TestRemoveClientByID_NonExistent tests removing a non-existent client by ID.
func TestRemoveClientByID_NonExistent(t *testing.T) {
b := NewBroadcaster()
// Should not panic
b.removeClientByID("non-existent-id")
assert.Equal(t, 0, b.ClientCount())
}
// TestRemoveClientByID_AlreadyClosed tests removing a client with already closed Done channel.
func TestRemoveClientByID_AlreadyClosed(t *testing.T) {
b := NewBroadcaster()
w := newMockResponseWriter()
client, err := b.AddClient(w)
require.NoError(t, err)
// Pre-close the Done channel
close(client.Done)
// Should not panic when trying to close again
b.removeClientByID(client.ID)
assert.Equal(t, 0, b.ClientCount())
}
// TestHandleSSE_NonFlusher tests HandleSSE with a non-flusher response writer.
func TestHandleSSE_NonFlusher(t *testing.T) {
b := NewBroadcaster()
// Create a response writer that doesn't implement Flusher
nonFlusher := &nonFlusherWriter{header: make(http.Header)}
req := httptest.NewRequest(http.MethodGet, "/events", nil)
// Should return immediately since writer isn't a Flusher
b.HandleSSE(nonFlusher, req)
// No clients should be added
assert.Equal(t, 0, b.ClientCount())
}
// nonFlusherWriter is a response writer that doesn't implement http.Flusher.
type nonFlusherWriter struct {
header http.Header
}
func (w *nonFlusherWriter) Header() http.Header { return w.header }
func (w *nonFlusherWriter) Write(data []byte) (int, error) { return len(data), nil }
func (w *nonFlusherWriter) WriteHeader(statusCode int) {}
// TestWriteToClient_Timeout tests write timeout behavior.
func TestWriteToClient_Timeout(t *testing.T) {
b := NewBroadcaster()
// Create a slow writer that blocks
slowWriter := &slowMockWriter{
header: make(http.Header),
blockFor: 5 * time.Second, // Longer than WriteTimeout
}
client, err := b.AddClient(slowWriter)
require.NoError(t, err)
// Create dead client channel
deadCh := make(chan string, 1)
// Try to write - should timeout
msg := "data: test\n\n"
b.writeToClient(client, msg, deadCh)
// May report dead client due to timeout
// Check if client was reported as dead (with timeout)
select {
case deadID := <-deadCh:
// Client was reported as dead
assert.Equal(t, client.ID, deadID)
case <-time.After(WriteTimeout + 500*time.Millisecond):
// Timed out waiting - also acceptable
}
}
// slowMockWriter simulates a slow writer for testing timeouts.
type slowMockWriter struct {
header http.Header
blockFor time.Duration
mu sync.Mutex
}
func (m *slowMockWriter) Header() http.Header {
return m.header
}
func (m *slowMockWriter) Write(data []byte) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
time.Sleep(m.blockFor)
return len(data), nil
}
func (m *slowMockWriter) WriteHeader(statusCode int) {}
func (m *slowMockWriter) Flush() {}
// TestBroadcast_InvalidJSON tests broadcasting un-marshalable data.
func TestBroadcast_InvalidJSON(t *testing.T) {
b := NewBroadcaster()
w := newMockResponseWriter()
_, err := b.AddClient(w)
require.NoError(t, err)
// channels can't be marshaled to JSON
ch := make(chan int)
b.Broadcast(ch) // Should log error but not panic
// Give time for async processing
time.Sleep(20 * time.Millisecond)
// Body should be empty or not contain the channel data
body := string(w.GetBody())
assert.NotContains(t, body, "chan")
}
// TestBroadcast_ClientDoneChannel tests broadcasting when client Done is closed.
func TestBroadcast_ClientDoneChannel(t *testing.T) {
b := NewBroadcaster()
w := newMockResponseWriter()
client, err := b.AddClient(w)
require.NoError(t, err)
// Close the done channel
close(client.Done)
// Broadcast should skip this client
b.Broadcast(map[string]string{"type": "test"})
// Give time for async processing
time.Sleep(20 * time.Millisecond)
}
+10 -10
View File
@@ -13,7 +13,7 @@
{
"name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
"version": "0.6.8",
"version": "0.6.33",
"author": {
"name": "lukaszraczylo",
"url": "https://github.com/lukaszraczylo"
@@ -23,25 +23,25 @@
"license": "MIT",
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"releases": {
"latest": "0.6.8",
"latest": "0.6.33",
"versions": {
"0.6.8": {
"releaseDate": "2025-12-16",
"0.6.33": {
"releaseDate": "2025-12-17",
"downloads": {
"darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.8/claude-mnemonic_0.6.8_darwin_arm64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.33/claude-mnemonic_0.6.33_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "1a796f9e47ee27e2d2c9425e1d1dea886771c9c3f0d4905dc414b5c0763665c6"
"sha256": "fe42634c859b4146fb1953f1d895872fd827f7e95b6510b49770ac4e2f4e4e4f"
},
"linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.8/claude-mnemonic_0.6.8_linux_amd64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.33/claude-mnemonic_0.6.33_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "52221d0e0f10d312abd1d521840ac0ac8558145795b8fb63f60a411dd2331637"
"sha256": "be0665ee52ed56aafc7154d7f223f236f347aa88babc49126b5459341b1158f1"
},
"windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.8/claude-mnemonic_0.6.8_windows_amd64.zip",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.33/claude-mnemonic_0.6.33_windows_amd64.zip",
"format": "zip",
"sha256": "08af5820a48bda13d791c21d6e8cb47b0ddfbba101be47c0b8f1c03966749167"
"sha256": "09c5e1ddf5ea04f58ad148cebf973c21d8ee8a15c6dd09a9609000a1caadfb5e"
}
}
}
File diff suppressed because it is too large Load Diff
+424
View File
@@ -0,0 +1,424 @@
// Package models contains domain models for claude-mnemonic.
package models
import (
"database/sql"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// ObservationSuite is a test suite for Observation operations.
type ObservationSuite struct {
suite.Suite
}
func TestObservationSuite(t *testing.T) {
suite.Run(t, new(ObservationSuite))
}
// TestObservationTypeConstants tests observation type constants.
func (s *ObservationSuite) TestObservationTypeConstants() {
s.Equal(ObservationType("discovery"), ObsTypeDiscovery)
s.Equal(ObservationType("decision"), ObsTypeDecision)
s.Equal(ObservationType("bugfix"), ObsTypeBugfix)
s.Equal(ObservationType("feature"), ObsTypeFeature)
s.Equal(ObservationType("refactor"), ObsTypeRefactor)
s.Equal(ObservationType("change"), ObsTypeChange)
}
// TestScopeConstants tests scope constants.
func (s *ObservationSuite) TestScopeConstants() {
s.Equal(ObservationScope("project"), ScopeProject)
s.Equal(ObservationScope("global"), ScopeGlobal)
}
// TestGlobalizableConcepts tests that globalizable concepts are defined.
func (s *ObservationSuite) TestGlobalizableConcepts() {
expected := []string{
"best-practice", "pattern", "anti-pattern", "architecture",
"security", "performance", "testing",
"debugging", "workflow", "tooling",
}
s.Equal(expected, GlobalizableConcepts)
}
// TestDetermineScope_TableDriven tests scope determination with various concepts.
func (s *ObservationSuite) TestDetermineScope_TableDriven() {
tests := []struct {
name string
concepts []string
expected ObservationScope
}{
{
name: "empty concepts - project scope",
concepts: []string{},
expected: ScopeProject,
},
{
name: "no globalizable concepts - project scope",
concepts: []string{"how-it-works", "custom-tag"},
expected: ScopeProject,
},
{
name: "security concept - global scope",
concepts: []string{"security"},
expected: ScopeGlobal,
},
{
name: "best-practice concept - global scope",
concepts: []string{"best-practice"},
expected: ScopeGlobal,
},
{
name: "mixed concepts with globalizable - global scope",
concepts: []string{"how-it-works", "security"},
expected: ScopeGlobal,
},
{
name: "performance concept - global scope",
concepts: []string{"performance"},
expected: ScopeGlobal,
},
{
name: "testing concept - global scope",
concepts: []string{"testing"},
expected: ScopeGlobal,
},
{
name: "pattern concept - global scope",
concepts: []string{"pattern"},
expected: ScopeGlobal,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := DetermineScope(tt.concepts)
s.Equal(tt.expected, result)
})
}
}
// TestParsedObservation_FileMtimesJSON tests FileMtimes JSON serialization.
func (s *ObservationSuite) TestParsedObservation_FileMtimesJSON() {
obs := &ParsedObservation{
Type: ObsTypeDiscovery,
Title: "Test",
FileMtimes: map[string]int64{"file1.go": 1234567890, "file2.go": 1234567891},
}
// Verify mtimes can be marshaled
data, err := json.Marshal(obs.FileMtimes)
s.NoError(err)
s.Contains(string(data), "file1.go")
s.Contains(string(data), "1234567890")
}
// TestObservation_CheckStaleness_TableDriven tests staleness checking.
func (s *ObservationSuite) TestObservation_CheckStaleness_TableDriven() {
tests := []struct {
name string
storedMtimes map[string]int64
currentMtimes map[string]int64
expectedStale bool
}{
{
name: "empty stored mtimes - not stale",
storedMtimes: map[string]int64{},
currentMtimes: map[string]int64{"file.go": 1000},
expectedStale: false,
},
{
name: "matching mtimes - not stale",
storedMtimes: map[string]int64{"file.go": 1000},
currentMtimes: map[string]int64{"file.go": 1000},
expectedStale: false,
},
{
name: "file modified - stale",
storedMtimes: map[string]int64{"file.go": 1000},
currentMtimes: map[string]int64{"file.go": 2000},
expectedStale: true,
},
{
name: "file missing from current - not stale (files might not be checked)",
storedMtimes: map[string]int64{"file.go": 1000},
currentMtimes: map[string]int64{},
expectedStale: false, // Missing files don't mark as stale per the implementation
},
{
name: "multiple files, one modified - stale",
storedMtimes: map[string]int64{"file1.go": 1000, "file2.go": 2000},
currentMtimes: map[string]int64{"file1.go": 1000, "file2.go": 3000},
expectedStale: true,
},
{
name: "nil current mtimes - not stale",
storedMtimes: map[string]int64{"file.go": 1000},
currentMtimes: nil,
expectedStale: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
obs := &Observation{
FileMtimes: tt.storedMtimes,
}
result := obs.CheckStaleness(tt.currentMtimes)
s.Equal(tt.expectedStale, result)
})
}
}
// TestObservation_MarshalJSON tests JSON marshaling of Observation.
func (s *ObservationSuite) TestObservation_MarshalJSON() {
obs := &Observation{
ID: 1,
Project: "test-project",
Type: ObsTypeDiscovery,
Title: sql.NullString{String: "Test Title", Valid: true},
Scope: ScopeProject,
}
data, err := json.Marshal(obs)
s.NoError(err)
s.Contains(string(data), `"id":1`)
s.Contains(string(data), `"project":"test-project"`)
s.Contains(string(data), `"type":"discovery"`)
}
// TestParsedObservation_Fields tests ParsedObservation field access.
func (s *ObservationSuite) TestParsedObservation_Fields() {
obs := &ParsedObservation{
Type: ObsTypeFeature,
Title: "Add authentication",
Subtitle: "JWT-based auth",
Narrative: "Implemented JWT authentication for API endpoints",
Facts: []string{"Uses RS256 algorithm", "Tokens expire in 24h"},
Concepts: []string{"security", "auth"},
FilesRead: []string{"config.go"},
FilesModified: []string{"handler.go", "middleware.go"},
FileMtimes: map[string]int64{"handler.go": 1234567890},
}
s.Equal(ObsTypeFeature, obs.Type)
s.Equal("Add authentication", obs.Title)
s.Equal("JWT-based auth", obs.Subtitle)
s.Contains(obs.Narrative, "JWT")
s.Len(obs.Facts, 2)
s.Len(obs.Concepts, 2)
s.Len(obs.FilesRead, 1)
s.Len(obs.FilesModified, 2)
s.Len(obs.FileMtimes, 1)
}
// TestObservation_NullFields tests handling of nullable fields.
func (s *ObservationSuite) TestObservation_NullFields() {
// Test with null fields
obs := &Observation{
ID: 1,
Project: "test",
Type: ObsTypeDiscovery,
Title: sql.NullString{Valid: false},
Subtitle: sql.NullString{Valid: false},
Narrative: sql.NullString{Valid: false},
}
s.False(obs.Title.Valid)
s.False(obs.Subtitle.Valid)
s.False(obs.Narrative.Valid)
// Test with valid fields
obs2 := &Observation{
ID: 2,
Project: "test",
Type: ObsTypeBugfix,
Title: sql.NullString{String: "Fix bug", Valid: true},
Subtitle: sql.NullString{String: "Memory leak", Valid: true},
Narrative: sql.NullString{String: "Fixed memory leak in handler", Valid: true},
}
s.True(obs2.Title.Valid)
s.Equal("Fix bug", obs2.Title.String)
s.True(obs2.Subtitle.Valid)
s.Equal("Memory leak", obs2.Subtitle.String)
}
// TestNewObservation tests observation creation from parsed data.
func TestNewObservation(t *testing.T) {
parsed := &ParsedObservation{
Type: ObsTypeFeature,
Title: "Add authentication",
Subtitle: "JWT-based",
Narrative: "Implemented JWT auth",
Facts: []string{"Uses RS256"},
Concepts: []string{"security"},
FilesRead: []string{"config.go"},
FilesModified: []string{"handler.go"},
FileMtimes: map[string]int64{"handler.go": 1234567890},
}
obs := NewObservation("sdk-123", "test-project", parsed, 5, 1000)
assert.Equal(t, "sdk-123", obs.SDKSessionID)
assert.Equal(t, "test-project", obs.Project)
assert.Equal(t, ScopeGlobal, obs.Scope) // security triggers global
assert.Equal(t, ObsTypeFeature, obs.Type)
assert.Equal(t, "Add authentication", obs.Title.String)
assert.True(t, obs.Title.Valid)
assert.Equal(t, int64(5), obs.PromptNumber.Int64)
assert.Equal(t, int64(1000), obs.DiscoveryTokens)
assert.NotEmpty(t, obs.CreatedAt)
assert.Greater(t, obs.CreatedAtEpoch, int64(0))
}
// TestParsedObservation_ToStoredObservation tests conversion.
func TestParsedObservation_ToStoredObservation(t *testing.T) {
parsed := &ParsedObservation{
Type: ObsTypeDiscovery,
Title: "Test Title",
Subtitle: "Test Subtitle",
Narrative: "Test narrative",
Facts: []string{"Fact 1"},
Concepts: []string{"testing"},
}
obs := parsed.ToStoredObservation()
assert.Equal(t, ObsTypeDiscovery, obs.Type)
assert.Equal(t, "Test Title", obs.Title.String)
assert.True(t, obs.Title.Valid)
assert.Equal(t, "Test Subtitle", obs.Subtitle.String)
assert.True(t, obs.Subtitle.Valid)
}
// TestJSONStringArray tests JSONStringArray scanning.
func TestJSONStringArray(t *testing.T) {
tests := []struct {
name string
input interface{}
wantErr bool
expected JSONStringArray
}{
{
name: "nil input",
input: nil,
wantErr: false,
expected: nil,
},
{
name: "empty string",
input: "",
wantErr: false,
expected: nil,
},
{
name: "json array string",
input: `["item1", "item2"]`,
wantErr: false,
expected: JSONStringArray{"item1", "item2"},
},
{
name: "json array bytes",
input: []byte(`["a", "b", "c"]`),
wantErr: false,
expected: JSONStringArray{"a", "b", "c"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var arr JSONStringArray
err := arr.Scan(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, arr)
}
})
}
}
// TestJSONInt64Map tests JSONInt64Map scanning.
func TestJSONInt64Map(t *testing.T) {
tests := []struct {
name string
input interface{}
wantErr bool
expected JSONInt64Map
}{
{
name: "nil input",
input: nil,
wantErr: false,
expected: nil,
},
{
name: "empty string",
input: "",
wantErr: false,
expected: nil,
},
{
name: "json map string",
input: `{"file.go": 1234567890}`,
wantErr: false,
expected: JSONInt64Map{"file.go": 1234567890},
},
{
name: "json map bytes",
input: []byte(`{"a.go": 100, "b.go": 200}`),
wantErr: false,
expected: JSONInt64Map{"a.go": 100, "b.go": 200},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var m JSONInt64Map
err := m.Scan(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, m)
}
})
}
}
// TestObservation_JSONRoundTrip tests that observations can be marshaled and unmarshaled.
func TestObservation_JSONRoundTrip(t *testing.T) {
original := &Observation{
ID: 1,
SDKSessionID: "session-123",
Project: "test-project",
Type: ObsTypeDiscovery,
Title: sql.NullString{String: "Test Title", Valid: true},
Subtitle: sql.NullString{String: "Test Subtitle", Valid: true},
Narrative: sql.NullString{String: "Test narrative content", Valid: true},
Scope: ScopeProject,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedAtEpoch: 1704067200000,
}
// Marshal
data, err := json.Marshal(original)
require.NoError(t, err)
// Unmarshal into map to check fields
var result map[string]interface{}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, float64(1), result["id"])
assert.Equal(t, "test-project", result["project"])
assert.Equal(t, "discovery", result["type"])
assert.Equal(t, "Test Title", result["title"])
}
+267
View File
@@ -0,0 +1,267 @@
// Package models contains domain models for claude-mnemonic.
package models
import (
"database/sql"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// SummarySuite is a test suite for SessionSummary operations.
type SummarySuite struct {
suite.Suite
}
func TestSummarySuite(t *testing.T) {
suite.Run(t, new(SummarySuite))
}
// TestNewSessionSummary tests summary creation.
func (s *SummarySuite) TestNewSessionSummary() {
parsed := &ParsedSummary{
Request: "Fix the bug in handler.go",
Investigated: "Looked at error logs",
Learned: "The issue was a race condition",
Completed: "Fixed the race condition",
NextSteps: "Add more tests",
Notes: "Consider adding mutex",
}
summary := NewSessionSummary("sdk-123", "test-project", parsed, 5, 1000)
s.NotNil(summary)
s.Equal("sdk-123", summary.SDKSessionID)
s.Equal("test-project", summary.Project)
s.True(summary.Request.Valid)
s.Equal("Fix the bug in handler.go", summary.Request.String)
s.True(summary.Investigated.Valid)
s.True(summary.Learned.Valid)
s.True(summary.Completed.Valid)
s.True(summary.NextSteps.Valid)
s.True(summary.Notes.Valid)
s.True(summary.PromptNumber.Valid)
s.Equal(int64(5), summary.PromptNumber.Int64)
s.Equal(int64(1000), summary.DiscoveryTokens)
s.NotEmpty(summary.CreatedAt)
s.Greater(summary.CreatedAtEpoch, int64(0))
}
// TestNewSessionSummary_EmptyFields tests summary creation with empty fields.
func (s *SummarySuite) TestNewSessionSummary_EmptyFields() {
parsed := &ParsedSummary{
Request: "Test request",
// All other fields empty
}
summary := NewSessionSummary("sdk-123", "project", parsed, 0, 0)
s.True(summary.Request.Valid)
s.False(summary.Investigated.Valid)
s.False(summary.Learned.Valid)
s.False(summary.Completed.Valid)
s.False(summary.NextSteps.Valid)
s.False(summary.Notes.Valid)
s.False(summary.PromptNumber.Valid) // 0 is not valid
s.Equal(int64(0), summary.DiscoveryTokens)
}
// TestSessionSummary_MarshalJSON tests JSON marshaling.
func (s *SummarySuite) TestSessionSummary_MarshalJSON() {
summary := &SessionSummary{
ID: 1,
SDKSessionID: "sdk-123",
Project: "test-project",
Request: sql.NullString{String: "Test request", Valid: true},
Investigated: sql.NullString{String: "Test investigation", Valid: true},
Learned: sql.NullString{Valid: false}, // Invalid - should be omitted
Completed: sql.NullString{String: "Test completion", Valid: true},
NextSteps: sql.NullString{Valid: false},
Notes: sql.NullString{String: "Test notes", Valid: true},
PromptNumber: sql.NullInt64{Int64: 3, Valid: true},
DiscoveryTokens: 500,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedAtEpoch: 1704067200000,
}
data, err := json.Marshal(summary)
s.NoError(err)
// Parse the JSON
var result map[string]interface{}
err = json.Unmarshal(data, &result)
s.NoError(err)
// Check fields
s.Equal(float64(1), result["id"])
s.Equal("sdk-123", result["sdk_session_id"])
s.Equal("test-project", result["project"])
s.Equal("Test request", result["request"])
s.Equal("Test investigation", result["investigated"])
s.Equal("Test completion", result["completed"])
s.Equal("Test notes", result["notes"])
s.Equal(float64(3), result["prompt_number"])
s.Equal(float64(500), result["discovery_tokens"])
// Empty fields should be omitted
_, hasLearned := result["learned"]
s.False(hasLearned, "Empty learned should be omitted")
_, hasNextSteps := result["next_steps"]
s.False(hasNextSteps, "Empty next_steps should be omitted")
}
// TestSessionSummary_MarshalJSON_AllEmpty tests JSON marshaling with all empty optional fields.
func (s *SummarySuite) TestSessionSummary_MarshalJSON_AllEmpty() {
summary := &SessionSummary{
ID: 1,
SDKSessionID: "sdk-123",
Project: "test-project",
Request: sql.NullString{Valid: false},
Investigated: sql.NullString{Valid: false},
Learned: sql.NullString{Valid: false},
Completed: sql.NullString{Valid: false},
NextSteps: sql.NullString{Valid: false},
Notes: sql.NullString{Valid: false},
PromptNumber: sql.NullInt64{Valid: false},
DiscoveryTokens: 0,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedAtEpoch: 1704067200000,
}
data, err := json.Marshal(summary)
s.NoError(err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
s.NoError(err)
// Required fields should be present
s.Equal(float64(1), result["id"])
s.Equal("sdk-123", result["sdk_session_id"])
s.Equal("test-project", result["project"])
// Optional fields should be empty strings or omitted
request, hasRequest := result["request"]
if hasRequest {
s.Equal("", request)
}
}
// TestParsedSummary tests ParsedSummary structure.
func (s *SummarySuite) TestParsedSummary() {
parsed := &ParsedSummary{
Request: "Request text",
Investigated: "Investigation text",
Learned: "Learned text",
Completed: "Completed text",
NextSteps: "Next steps text",
Notes: "Notes text",
}
s.Equal("Request text", parsed.Request)
s.Equal("Investigation text", parsed.Investigated)
s.Equal("Learned text", parsed.Learned)
s.Equal("Completed text", parsed.Completed)
s.Equal("Next steps text", parsed.NextSteps)
s.Equal("Notes text", parsed.Notes)
}
// TestSessionSummaryJSON tests the JSON-friendly type.
func (s *SummarySuite) TestSessionSummaryJSON() {
j := SessionSummaryJSON{
ID: 1,
SDKSessionID: "sdk-123",
Project: "test-project",
Request: "Request",
Investigated: "Investigation",
Learned: "Learned",
Completed: "Completed",
NextSteps: "Next steps",
Notes: "Notes",
PromptNumber: 5,
DiscoveryTokens: 1000,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedAtEpoch: 1704067200000,
}
s.Equal(int64(1), j.ID)
s.Equal("sdk-123", j.SDKSessionID)
s.Equal("test-project", j.Project)
s.Equal("Request", j.Request)
s.Equal("Investigation", j.Investigated)
s.Equal("Learned", j.Learned)
s.Equal("Completed", j.Completed)
s.Equal("Next steps", j.NextSteps)
s.Equal("Notes", j.Notes)
s.Equal(int64(5), j.PromptNumber)
s.Equal(int64(1000), j.DiscoveryTokens)
}
// TestSessionSummary_TimestampValidity tests that timestamps are set correctly.
func TestSessionSummary_TimestampValidity(t *testing.T) {
before := time.Now().Add(-time.Second) // Give 1 second buffer
parsed := &ParsedSummary{Request: "Test"}
summary := NewSessionSummary("sdk-123", "project", parsed, 1, 100)
after := time.Now().Add(time.Second) // Give 1 second buffer
// Parse the timestamp
createdAt, err := time.Parse(time.RFC3339, summary.CreatedAt)
require.NoError(t, err)
// Timestamp should be between before and after (with buffer)
assert.True(t, createdAt.After(before) || createdAt.Equal(before), "created_at should be >= before")
assert.True(t, createdAt.Before(after) || createdAt.Equal(after), "created_at should be <= after")
// Epoch should also be in range (with buffer)
beforeEpoch := before.UnixMilli()
afterEpoch := after.UnixMilli()
assert.GreaterOrEqual(t, summary.CreatedAtEpoch, beforeEpoch, "epoch should be >= before epoch")
assert.LessOrEqual(t, summary.CreatedAtEpoch, afterEpoch, "epoch should be <= after epoch")
}
// TestSessionSummary_JSONRoundTrip tests that summaries can be marshaled and unmarshaled.
func TestSessionSummary_JSONRoundTrip(t *testing.T) {
original := &SessionSummary{
ID: 1,
SDKSessionID: "sdk-123",
Project: "test-project",
Request: sql.NullString{String: "Test request", Valid: true},
Investigated: sql.NullString{String: "Test investigation", Valid: true},
Learned: sql.NullString{String: "Test learned", Valid: true},
Completed: sql.NullString{String: "Test completed", Valid: true},
NextSteps: sql.NullString{String: "Test next steps", Valid: true},
Notes: sql.NullString{String: "Test notes", Valid: true},
PromptNumber: sql.NullInt64{Int64: 5, Valid: true},
DiscoveryTokens: 1000,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedAtEpoch: 1704067200000,
}
// Marshal
data, err := json.Marshal(original)
require.NoError(t, err)
// Unmarshal into JSON type
var result SessionSummaryJSON
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Verify
assert.Equal(t, original.ID, result.ID)
assert.Equal(t, original.SDKSessionID, result.SDKSessionID)
assert.Equal(t, original.Project, result.Project)
assert.Equal(t, original.Request.String, result.Request)
assert.Equal(t, original.Investigated.String, result.Investigated)
assert.Equal(t, original.Learned.String, result.Learned)
assert.Equal(t, original.Completed.String, result.Completed)
assert.Equal(t, original.NextSteps.String, result.NextSteps)
assert.Equal(t, original.Notes.String, result.Notes)
assert.Equal(t, original.PromptNumber.Int64, result.PromptNumber)
assert.Equal(t, original.DiscoveryTokens, result.DiscoveryTokens)
}
+21
View File
@@ -0,0 +1,21 @@
# Restart Claude Mnemonic Worker
Restart the claude-mnemonic worker process. Use this command when experiencing issues with the memory system.
## Instructions
1. Call the restart API endpoint using curl:
```bash
curl -X POST http://127.0.0.1:37777/api/restart
```
2. Wait a moment for the worker to restart (typically 1-2 seconds)
3. Verify the worker is running by checking the version:
```bash
curl -s http://127.0.0.1:37777/api/version
```
4. Report the result to the user, including the version number from the response.
If the restart fails, suggest the user check `/tmp/claude-mnemonic-worker.log` for errors.
+7
View File
@@ -176,6 +176,7 @@ download_release() {
info "Installing to ${INSTALL_DIR}..."
mkdir -p "$INSTALL_DIR/hooks"
mkdir -p "$INSTALL_DIR/.claude-plugin"
mkdir -p "$INSTALL_DIR/commands"
# Copy binaries
cp "$tmp_dir/worker" "$INSTALL_DIR/"
@@ -185,6 +186,11 @@ download_release() {
# Copy plugin configuration
cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"
# Copy slash commands if they exist in the release
if [[ -d "$tmp_dir/commands" ]]; then
cp -r "$tmp_dir/commands/"* "$INSTALL_DIR/commands/" 2>/dev/null || true
fi
# Make binaries executable
chmod +x "$INSTALL_DIR/worker"
chmod +x "$INSTALL_DIR/mcp-server"
@@ -230,6 +236,7 @@ register_plugin() {
# Copy files to cache directory
mkdir -p "$cache_path/.claude-plugin"
mkdir -p "$cache_path/hooks"
mkdir -p "$cache_path/commands"
cp -r "$INSTALL_DIR/"* "$cache_path/" 2>/dev/null || true
# Register in installed_plugins.json
+1
View File
@@ -45,6 +45,7 @@ if command -v jq &> /dev/null; then
# Ensure cache directory exists and copy plugin files
mkdir -p "$CACHE_PATH/.claude-plugin"
mkdir -p "$CACHE_PATH/hooks"
mkdir -p "$CACHE_PATH/commands"
# Copy files from marketplace to cache
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.4-2-gb396d05-dirty",
"version": "v0.6.33-3-gf38ce5c-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.6.4-2-gb396d05-dirty",
"version": "v0.6.33-3-gf38ce5c-dirty",
"dependencies": {
"vue": "^3.5.13"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.4-2-gb396d05-dirty",
"version": "v0.6.33-3-gf38ce5c-dirty",
"private": true,
"type": "module",
"scripts": {
+28 -2
View File
@@ -7,10 +7,10 @@ import Timeline from '@/components/Timeline.vue'
import Sidebar from '@/components/Sidebar.vue'
// Composables
const { isConnected, isProcessing, queueDepth } = useSSE()
const { stats } = useStats()
const { isConnected, isReconnecting, reconnectCountdown, isProcessing, queueDepth } = useSSE()
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
const { health } = useHealth()
// Initialize useTimeline first to get currentProject ref
const {
filteredItems,
loading,
@@ -27,12 +27,25 @@ const {
setTypeFilter,
setConceptFilter
} = useTimeline()
// Pass currentProject ref to useStats for project-specific retrieval stats
const { stats } = useStats(currentProject)
// Note: Timeline refresh is handled by useTimeline's SSE watcher
</script>
<template>
<div class="min-h-screen">
<!-- Reconnection Banner -->
<Transition name="slide">
<div
v-if="isReconnecting"
class="fixed top-0 left-0 right-0 z-50 bg-amber-500/90 backdrop-blur-sm text-black px-4 py-2 text-center text-sm font-medium shadow-lg"
>
<i class="fas fa-sync-alt fa-spin mr-2" />
Connection lost. Reconnecting<span v-if="reconnectCountdown > 0"> in {{ reconnectCountdown }}s</span>...
</div>
</Transition>
<!-- Header -->
<Header
:is-connected="isConnected"
@@ -94,3 +107,16 @@ const {
</main>
</div>
</template>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>
+33
View File
@@ -3,12 +3,15 @@ import type { SSEEvent } from '@/types'
// Singleton state - shared across all useSSE() calls
const isConnected = ref(false)
const isReconnecting = ref(false)
const reconnectCountdown = ref(0)
const isProcessing = ref(false)
const queueDepth = ref(0)
const lastEvent = ref<SSEEvent | null>(null)
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let countdownInterval: number | null = null
let connectionCount = 0
let reconnectAttempt = 0
@@ -24,6 +27,28 @@ function getBackoffDelay(): number {
return Math.floor(baseDelay + jitter)
}
function startCountdown(delayMs: number) {
reconnectCountdown.value = Math.ceil(delayMs / 1000)
if (countdownInterval) {
clearInterval(countdownInterval)
}
countdownInterval = window.setInterval(() => {
reconnectCountdown.value = Math.max(0, reconnectCountdown.value - 1)
if (reconnectCountdown.value <= 0 && countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
reconnectCountdown.value = 0
}
export function useSSE() {
const connect = () => {
@@ -36,6 +61,8 @@ export function useSSE() {
eventSource.onopen = () => {
isConnected.value = true
isReconnecting.value = false
stopCountdown()
reconnectAttempt = 0 // Reset backoff on successful connection
console.log('[SSE] Connected')
}
@@ -62,6 +89,7 @@ export function useSSE() {
eventSource.onerror = () => {
isConnected.value = false
isReconnecting.value = true
eventSource?.close()
eventSource = null
@@ -70,6 +98,7 @@ export function useSSE() {
reconnectAttempt++
console.log(`[SSE] Reconnecting in ${Math.round(delay/1000)}s (attempt ${reconnectAttempt})`)
startCountdown(delay)
reconnectTimeout = window.setTimeout(() => {
connect()
}, delay)
@@ -81,11 +110,13 @@ export function useSSE() {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
stopCountdown()
if (eventSource) {
eventSource.close()
eventSource = null
}
isConnected.value = false
isReconnecting.value = false
}
// Handle page unload/refresh to ensure SSE connection is closed immediately
@@ -132,6 +163,8 @@ export function useSSE() {
return {
isConnected,
isReconnecting,
reconnectCountdown,
isProcessing,
queueDepth,
lastEvent,
+12 -3
View File
@@ -1,4 +1,4 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue'
import type { Stats } from '@/types'
import { fetchStats } from '@/utils/api'
import { useSSE } from './useSSE'
@@ -6,7 +6,7 @@ import { useSSE } from './useSSE'
// Fallback poll interval when SSE is disconnected
const FALLBACK_POLL_INTERVAL = 10000 // 10 seconds
export function useStats() {
export function useStats(projectRef?: Ref<string | null>) {
const stats = ref<Stats | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
@@ -21,7 +21,8 @@ export function useStats() {
error.value = null
try {
stats.value = await fetchStats()
const project = projectRef?.value ?? null
stats.value = await fetchStats(project)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch stats'
console.error('[Stats] Error:', err)
@@ -54,6 +55,14 @@ export function useStats() {
}
})
// Watch for project filter changes
if (projectRef) {
watch(projectRef, () => {
console.log('[Stats] Project filter changed, refreshing stats')
refresh()
})
}
// Switch between SSE-driven and fallback polling based on connection status
watch(isConnected, (connected) => {
if (connected) {
+5 -2
View File
@@ -107,8 +107,11 @@ export async function fetchSummaries(limit: number = 50, project?: string, signa
return fetchWithRetry<SessionSummary[]>(`${API_BASE}/summaries?${params}`, { signal })
}
export async function fetchStats(): Promise<Stats> {
return fetchWithRetry<Stats>(`${API_BASE}/stats`)
export async function fetchStats(project?: string | null): Promise<Stats> {
const params = new URLSearchParams()
if (project) params.append('project', project)
const query = params.toString()
return fetchWithRetry<Stats>(`${API_BASE}/stats${query ? '?' + query : ''}`)
}
export async function fetchProjects(): Promise<string[]> {