mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cce1c62184 | ||
|
|
9629cff306 | ||
|
|
bc336a892c | ||
|
|
949e82b8e6 | ||
|
|
b09d9aafa8 | ||
|
|
03bc296ab4 | ||
|
|
73dacf5f3f | ||
|
|
2084a28548 | ||
|
|
6541d63f68 | ||
|
|
c331899040 | ||
|
|
99850838e8 | ||
|
|
d8a4939201 | ||
|
|
50f078bc6a | ||
|
|
8ff08732c6 | ||
|
|
433d165f17 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user