Changeset 0.7.2 (#40)

This commit is contained in:
2026-02-21 19:03:19 +00:00
parent 494b1aa981
commit 416e5439ea
28 changed files with 3813 additions and 138 deletions

View File

@@ -0,0 +1,50 @@
-- 014_notes.sql — Notes table with full-text search
--
-- Notes are user-scoped persistent documents. The LLM can create, search,
-- and update them via built-in tools (note_create, note_search, etc.).
-- Full-text search uses PostgreSQL tsvector — zero additional infrastructure.
--
-- DROP first: if a previous deployment created an incomplete version of
-- this table (e.g. missing search_vector), IF NOT EXISTS would skip and
-- the indexes/trigger would fail. Safe because 014 was never recorded
-- in schema_migrations — any existing data is from a failed attempt.
DROP TABLE IF EXISTS notes CASCADE;
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT DEFAULT '',
folder_path TEXT DEFAULT '/',
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
search_vector TSVECTOR,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_notes_folder ON notes(user_id, folder_path);
CREATE INDEX idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX idx_notes_updated ON notes(user_id, updated_at DESC);
-- Auto-update search vector from title + content
CREATE OR REPLACE FUNCTION notes_search_update_fn()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop first to allow re-run
DROP TRIGGER IF EXISTS notes_search_update ON notes;
CREATE TRIGGER notes_search_update
BEFORE INSERT OR UPDATE OF title, content ON notes
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();

View File

@@ -0,0 +1,246 @@
package database
import (
"database/sql"
"fmt"
"log"
"os"
"strings"
"testing"
_ "github.com/lib/pq"
)
// TestDB holds a connection to the test database.
// Use SetupTestDB in TestMain and pass this to subtests.
var TestDB *sql.DB
const testDBName = "chat_switchboard_ci"
// SetupTestDB connects to PostgreSQL, creates the CI test database (or
// connects to an existing one created by CI bootstrap), runs all
// migrations, and sets database.DB so handlers can use it.
//
// Call in TestMain:
//
// func TestMain(m *testing.M) {
// teardown := database.SetupTestDB()
// code := m.Run()
// teardown()
// os.Exit(code)
// }
//
// Requires env: TEST_DATABASE_URL (full DSN to maintenance DB, e.g. postgres)
// OR individual: PGHOST, PGPORT, PGUSER, PGPASSWORD
//
// Returns a cleanup function that drops the test database.
func SetupTestDB() func() {
mainDSN := os.Getenv("TEST_DATABASE_URL")
host := envOr("PGHOST", "")
port := envOr("PGPORT", "5432")
user := envOr("PGUSER", "")
pass := envOr("PGPASSWORD", "")
if mainDSN == "" {
if host == "" || user == "" {
log.Println("⚠ TEST_DATABASE_URL / PGHOST+PGUSER not set — skipping DB tests")
return func() {}
}
mainDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
host, port, user, pass)
}
// Try to connect to admin/maintenance DB for create/drop operations
adminDB, err := sql.Open("postgres", mainDSN)
if err != nil {
log.Printf("⚠ Cannot connect to admin DB: %v — skipping DB tests", err)
return func() {}
}
if err := adminDB.Ping(); err != nil {
adminDB.Close()
log.Printf("⚠ Cannot ping admin DB: %v — skipping DB tests", err)
return func() {}
}
// Attempt to create the test DB. If the user lacks CREATE DATABASE
// permissions (e.g. CI already created it via admin bootstrap step),
// that's fine — we'll just connect to the existing one.
createdByUs := false
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
// Permission denied is expected in CI — the bootstrap step
// already created the DB with admin creds. Just proceed.
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
} else {
createdByUs = true
log.Printf("✓ Created test database: %s", testDBName)
}
// Build DSN for the test DB
var testDSN string
if host != "" {
testDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, pass, testDBName)
} else {
testDSN = replaceDBName(mainDSN, testDBName)
}
// Connect to test DB
DB, err = sql.Open("postgres", testDSN)
if err != nil {
adminDB.Close()
log.Printf("⚠ Cannot connect to test DB: %v — skipping DB tests", err)
return func() {}
}
if err := DB.Ping(); err != nil {
DB.Close()
DB = nil
adminDB.Close()
log.Printf("⚠ Cannot ping test DB: %v — skipping DB tests", err)
return func() {}
}
TestDB = DB
// Ensure required extensions (may already exist from CI bootstrap)
for _, ext := range []string{"uuid-ossp", "pgcrypto"} {
DB.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext))
}
// Wipe all tables so migrations apply cleanly
DB.Exec(`
DO $$ DECLARE r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`)
// Run all migrations
if err := Migrate(); err != nil {
DB.Close()
DB = nil
TestDB = nil
if createdByUs {
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
}
adminDB.Close()
log.Fatalf("❌ Migration failed on test DB: %v", err)
}
// Return cleanup
return func() {
if DB != nil {
DB.Close()
DB = nil
TestDB = nil
}
if createdByUs {
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
log.Printf("✓ Dropped test database: %s", testDBName)
}
adminDB.Close()
}
}
// RequireTestDB skips a test if no test database is available.
func RequireTestDB(t *testing.T) {
t.Helper()
if DB == nil || TestDB == nil {
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables")
}
}
// TruncateAll truncates all application tables for test isolation.
// Faster than recreating the DB for each test.
func TruncateAll(t *testing.T) {
t.Helper()
if DB == nil {
return
}
// Order matters due to foreign keys — truncate with CASCADE
tables := []string{
"notes",
"channel_cursors",
"messages",
"channel_models",
"channel_members",
"channels",
"model_configs",
"model_presets",
"api_configs",
"refresh_tokens",
"users",
}
for _, table := range tables {
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
}
}
// SeedTestUser creates a test user and returns the user ID.
func SeedTestUser(t *testing.T, username, email string) string {
t.Helper()
var id string
err := DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role)
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
RETURNING id
`, username, email).Scan(&id)
if err != nil {
t.Fatalf("SeedTestUser: %v", err)
}
return id
}
// SeedTestChannel creates a test channel owned by userID and returns the channel ID.
func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper()
var id string
err := DB.QueryRow(`
INSERT INTO channels (title, created_by)
VALUES ($1, $2)
RETURNING id
`, title, userID).Scan(&id)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
// Add ownership
DB.Exec(`INSERT INTO channel_members (channel_id, user_id, role) VALUES ($1, $2, 'owner')`,
id, userID)
return id
}
// ── Helpers ─────────────────────────────────
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// replaceDBName swaps the dbname in a DSN string.
func replaceDBName(dsn, newDB string) string {
// Handle key=value format
if strings.Contains(dsn, "dbname=") {
parts := strings.Fields(dsn)
for i, p := range parts {
if strings.HasPrefix(p, "dbname=") {
parts[i] = "dbname=" + newDB
return strings.Join(parts, " ")
}
}
}
// Handle URL format: postgres://user:pass@host/olddb?...
if strings.Contains(dsn, "://") {
// Find the last / before ? and replace the path
idx := strings.LastIndex(dsn, "/")
qIdx := strings.Index(dsn, "?")
if qIdx > idx {
return dsn[:idx+1] + newDB + dsn[qIdx:]
}
return dsn[:idx+1] + newDB
}
// Fallback: append dbname
return dsn + " dbname=" + newDB
}

View File

@@ -12,11 +12,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
func init() {
gin.SetMode(gin.TestMode)
providers.Init()
}
func TestCreateConfigMissingFields(t *testing.T) {
h := NewAPIConfigHandler()
r := gin.New()

View File

@@ -17,10 +17,6 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/config"
)
func init() {
gin.SetMode(gin.TestMode)
}
func testConfig() *config.Config {
return &config.Config{
JWTSecret: "test-secret-key-for-unit-tests",

View File

@@ -1,17 +1,16 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Channel Request Validation ─────────────────
@@ -60,7 +59,7 @@ func TestUpdateChannelEmptyBody(t *testing.T) {
c.Request.Header.Set("Content-Type", "application/json")
// Without a DB connection, UpdateChannel will fail at ownership check.
// Integration tests with a real DB would validate the "no fields" path.
// Integration tests with a real DB validate the "no fields" path.
// Here we just confirm it doesn't return 400 for valid JSON.
h.UpdateChannel(c)
@@ -122,19 +121,151 @@ func TestCreateMessageMissingContent(t *testing.T) {
}
}
// ── Integration: Message CRUD with Real DB ──────
func TestCreateMessageValidRoles(t *testing.T) {
// Valid roles should pass validation (not return 400).
// They'll fail at ownership check (no DB), which is expected.
t.Skip("requires database connection for ownership check")
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "roletester", "role@test.com")
channelID := database.SeedTestChannel(t, userID, "Role Test")
h := NewMessageHandler()
for _, role := range []string{"user", "assistant", "system"} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", userID)
c.Params = gin.Params{{Key: "id", Value: channelID}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages",
strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusCreated {
t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String())
}
})
}
}
// ── Regenerate ───────────────────────────────
func TestChannelCRUDIntegration(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
func TestRegenerateRequiresDB(t *testing.T) {
// Regenerate is now a full handler (creates sibling assistant message
// via tree-aware streaming). It calls userOwnsChannel → database.DB,
// so it cannot run without a live database connection.
t.Skip("requires database connection for ownership check")
userID := database.SeedTestUser(t, "cruduser", "crud@test.com")
h := NewChannelHandler()
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
r.POST("/channels", h.CreateChannel)
r.GET("/channels", h.ListChannels)
r.GET("/channels/:id", h.GetChannel)
r.PUT("/channels/:id", h.UpdateChannel)
r.DELETE("/channels/:id", h.DeleteChannel)
// Create
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/channels",
strings.NewReader(`{"title":"Integration Test Channel"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &created)
channelID := created["id"].(string)
// Get
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Get: expected 200, got %d", w.Code)
}
// List
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("List: expected 200, got %d", w.Code)
}
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
if listResp["total"].(float64) < 1 {
t.Error("List: expected at least 1 channel")
}
// Update
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/channels/"+channelID,
strings.NewReader(`{"title":"Updated Title"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Delete
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Delete: expected 200, got %d", w.Code)
}
// Verify gone
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Get after delete: expected 404, got %d", w.Code)
}
}
func TestRegeneratePassesOwnershipCheck(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "regenuser", "regen@test.com")
channelID := database.SeedTestChannel(t, userID, "Regen Test")
// Seed an assistant message to regenerate
var msgID string
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, participant_type, participant_id)
VALUES ($1, 'assistant', 'original response', 'model', 'test-model')
RETURNING id
`, channelID).Scan(&msgID)
if err != nil {
t.Fatalf("seed message: %v", err)
}
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", userID)
c.Params = gin.Params{
{Key: "id", Value: channelID},
{Key: "msgId", Value: msgID},
}
c.Request = httptest.NewRequest("POST",
"/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Regenerate(c)
// Should NOT be 404 — ownership check passed. Will be 400 or 500
// because no API config is set up, which is expected.
if w.Code == http.StatusNotFound {
t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String())
}
}
// ── Pagination Helpers ──────────────────────

View File

@@ -4,14 +4,15 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
// ── Request Types ───────────────────────────
@@ -45,9 +46,11 @@ func NewCompletionHandler() *CompletionHandler {
// 2. Resolve api_config (from request, chat, or user default)
// 3. Load conversation history from messages
// 4. Persist user message
// 5. Call provider (stream or non-stream)
// 6. Stream SSE to client / return JSON
// 7. Persist assistant message with token counts
// 5. Attach tool definitions if model supports tools
// 6. Call provider (stream or non-stream)
// 7. Tool loop: if LLM requests tool calls, execute them and re-call
// 8. Stream SSE to client / return JSON
// 9. Persist assistant message with token counts
func (h *CompletionHandler) Complete(c *gin.Context) {
var req completionRequest
@@ -153,6 +156,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
provReq.TopP = req.TopP
}
// Attach tool definitions if model supports tool calling and tools are available
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = h.buildToolDefs()
}
// Determine streaming
stream := true // default
if req.Stream != nil {
@@ -166,7 +174,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Streaming Completion (SSE) ──────────────
// buildToolDefs converts registered tools to provider-format tool definitions.
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
allTools := tools.AllDefinitions()
defs := make([]providers.ToolDef, len(allTools))
for i, t := range allTools {
defs[i] = providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
}
}
return defs
}
// ── Streaming Completion (SSE) with Tool Loop ──
const maxToolIterations = 10 // safety limit on tool call rounds
func (h *CompletionHandler) streamCompletion(
c *gin.Context,
@@ -175,12 +202,6 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest,
channelID, userID, model string,
) {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
return
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
@@ -188,45 +209,150 @@ func (h *CompletionHandler) streamCompletion(
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
c.Status(http.StatusOK)
var fullContent string
flusher, _ := c.Writer.(http.Flusher)
for event := range ch {
if event.Error != nil {
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
if flusher != nil {
flusher.Flush()
}
break
}
if event.Delta != "" {
fullContent += event.Delta
// OpenAI-compatible SSE format
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n",
event.Delta, model)
if flusher != nil {
flusher.Flush()
}
}
if event.Done {
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n",
finishReason, model)
io.WriteString(c.Writer, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
break
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
// Persist assistant response
// sendSSE sends a standard OpenAI-compatible SSE data line.
sendSSE := func(data string) {
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
flush()
}
// sendEvent sends a named SSE event (for tool status — non-standard).
sendEvent := func(event, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flush()
}
var fullContent string
for iteration := 0; iteration < maxToolIterations; iteration++ {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
return
}
var iterContent string
var toolCalls []providers.ToolCall
for event := range ch {
if event.Error != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
return
}
// Stream text deltas to client
if event.Delta != "" {
iterContent += event.Delta
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
event.Delta, model))
}
if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
} else {
// Normal completion — send finish event
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
fullContent += iterContent
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
finishReason, model))
sendSSE("[DONE]")
// Persist assistant response
if fullContent != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
return
}
break
}
}
// ── Tool execution round ────────────────
if len(toolCalls) == 0 {
// Stream ended without tool calls or finish — shouldn't happen
sendSSE("[DONE]")
if iterContent != "" {
fullContent += iterContent
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
return
}
// Notify client about tool calls
toolCallsJSON, _ := json.Marshal(toolCalls)
sendEvent("tool_use", string(toolCallsJSON))
// Append assistant message (with tool_calls, possibly with text) to conversation
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
req.Messages = append(req.Messages, assistantMsg)
// Execute all tool calls
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
}
for _, tc := range toolCalls {
call := tools.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
// Notify client about tool result
resultJSON, _ := json.Marshal(map[string]interface{}{
"tool_call_id": result.ToolCallID,
"name": result.Name,
"content": result.Content,
"is_error": result.IsError,
})
sendEvent("tool_result", string(resultJSON))
// Append tool result to conversation for next iteration
req.Messages = append(req.Messages, providers.Message{
Role: "tool",
Content: result.Content,
ToolCallID: result.ToolCallID,
Name: result.Name,
})
}
fullContent += iterContent
// Loop: send updated messages back to provider
}
// If we hit max iterations, send what we have
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
escapeJSON("[Tool execution limit reached]"), model))
sendSSE("[DONE]")
if fullContent != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
@@ -234,7 +360,7 @@ func (h *CompletionHandler) streamCompletion(
}
}
// ── Non-Streaming Completion ────────────────
// ── Non-Streaming Completion with Tool Loop ──
func (h *CompletionHandler) syncCompletion(
c *gin.Context,
@@ -243,39 +369,108 @@ func (h *CompletionHandler) syncCompletion(
req providers.CompletionRequest,
channelID, userID, model string,
) {
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
return
var totalInput, totalOutput int
var finalContent string
for iteration := 0; iteration < maxToolIterations; iteration++ {
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
return
}
totalInput += resp.InputTokens
totalOutput += resp.OutputTokens
// No tool calls — normal response
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
finalContent = resp.Content
// Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
// Return OpenAI-compatible response
c.JSON(http.StatusOK, gin.H{
"model": resp.Model,
"choices": []gin.H{
{
"message": gin.H{
"role": "assistant",
"content": finalContent,
},
"finish_reason": resp.FinishReason,
},
},
"usage": gin.H{
"prompt_tokens": totalInput,
"completion_tokens": totalOutput,
"total_tokens": totalInput + totalOutput,
},
})
return
}
// ── Tool execution round ────────────────
assistantMsg := providers.Message{
Role: "assistant",
Content: resp.Content,
}
for _, tc := range resp.ToolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
req.Messages = append(req.Messages, assistantMsg)
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
}
for _, tc := range resp.ToolCalls {
call := tools.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
req.Messages = append(req.Messages, providers.Message{
Role: "tool",
Content: result.Content,
ToolCallID: result.ToolCallID,
Name: result.Name,
})
}
}
// Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
// Return OpenAI-compatible response
// Max iterations reached
c.JSON(http.StatusOK, gin.H{
"model": resp.Model,
"model": model,
"choices": []gin.H{
{
"message": gin.H{
"role": "assistant",
"content": resp.Content,
"content": "[Tool execution limit reached]",
},
"finish_reason": resp.FinishReason,
"finish_reason": "stop",
},
},
"usage": gin.H{
"prompt_tokens": resp.InputTokens,
"completion_tokens": resp.OutputTokens,
"total_tokens": resp.InputTokens + resp.OutputTokens,
},
})
}
// ── Model Capabilities ──────────────────────
// escapeJSON escapes a string for safe embedding in a JSON string value.
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
s = strings.ReplaceAll(s, "\t", `\t`)
return s
}
// getModelCapabilities looks up capabilities from model_configs DB,
// then overlays with known model defaults and heuristic detection.
func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities {

504
server/handlers/notes.go Normal file
View File

@@ -0,0 +1,504 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Request / Response Types ────────────────
type createNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
}
type updateNoteRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
FolderPath *string `json:"folder_path"`
Tags []string `json:"tags"`
// Mode controls how content is applied: "replace" (default), "append", "prepend"
Mode string `json:"mode"`
}
type noteResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID *string `json:"source_channel_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type noteListItem struct {
ID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
Preview string `json:"preview"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type searchResult struct {
noteListItem
Rank float64 `json:"rank"`
Headline string `json:"headline"`
}
// NoteHandler handles notes CRUD.
type NoteHandler struct{}
// NewNoteHandler creates a new handler.
func NewNoteHandler() *NoteHandler {
return &NoteHandler{}
}
// ── Create ──────────────────────────────────
// POST /api/v1/notes
func (h *NoteHandler) Create(c *gin.Context) {
var req createNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
folder := normalizeFolderPath(req.FolderPath)
tags := req.Tags
if tags == nil {
tags = []string{}
}
var sourceChannelID *string
if req.SourceChannelID != "" {
sourceChannelID = &req.SourceChannelID
}
var note noteResponse
var dbTags pq.StringArray
err := database.DB.QueryRow(`
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, content, folder_path, tags, source_channel_id,
created_at::text, updated_at::text
`, userID, req.Title, req.Content, folder, pq.Array(tags), sourceChannelID,
).Scan(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
return
}
note.Tags = []string(dbTags)
if note.Tags == nil {
note.Tags = []string{}
}
c.JSON(http.StatusCreated, note)
}
// ── Get ─────────────────────────────────────
// GET /api/v1/notes/:id
func (h *NoteHandler) Get(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
var note noteResponse
var dbTags pq.StringArray
err := database.DB.QueryRow(`
SELECT id, title, content, folder_path, tags, source_channel_id,
created_at::text, updated_at::text
FROM notes WHERE id = $1 AND user_id = $2
`, noteID, userID).Scan(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.UpdatedAt,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get note"})
return
}
note.Tags = []string(dbTags)
if note.Tags == nil {
note.Tags = []string{}
}
c.JSON(http.StatusOK, note)
}
// ── Update ──────────────────────────────────
// PUT /api/v1/notes/:id
func (h *NoteHandler) Update(c *gin.Context) {
var req updateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
var exists bool
err := database.DB.QueryRow(
`SELECT EXISTS(SELECT 1 FROM notes WHERE id = $1 AND user_id = $2)`,
noteID, userID,
).Scan(&exists)
if err != nil || !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Build dynamic update
setClauses := []string{}
args := []interface{}{}
argIdx := 1
if req.Title != nil {
setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx))
args = append(args, *req.Title)
argIdx++
}
if req.Content != nil {
mode := strings.ToLower(req.Mode)
switch mode {
case "append":
setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx))
case "prepend":
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content")
default: // "replace" or empty
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx))
}
args = append(args, *req.Content)
argIdx++
}
if req.FolderPath != nil {
setClauses = append(setClauses, "folder_path = $"+strconv.Itoa(argIdx))
args = append(args, normalizeFolderPath(*req.FolderPath))
argIdx++
}
if req.Tags != nil {
setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx))
args = append(args, pq.Array(req.Tags))
argIdx++
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
// WHERE clause
args = append(args, noteID, userID)
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
" WHERE id = $" + strconv.Itoa(argIdx) +
" AND user_id = $" + strconv.Itoa(argIdx+1) +
" RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text"
var note noteResponse
var dbTags pq.StringArray
err = database.DB.QueryRow(query, args...).Scan(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
return
}
note.Tags = []string(dbTags)
if note.Tags == nil {
note.Tags = []string{}
}
c.JSON(http.StatusOK, note)
}
// ── Delete ──────────────────────────────────
// DELETE /api/v1/notes/:id
func (h *NoteHandler) Delete(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
result, err := database.DB.Exec(
`DELETE FROM notes WHERE id = $1 AND user_id = $2`,
noteID, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ── Bulk Delete ────────────────────────────
// POST /api/v1/notes/bulk-delete
func (h *NoteHandler) BulkDelete(c *gin.Context) {
var req struct {
IDs []string `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"})
return
}
if len(req.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"})
return
}
userID := getUserID(c)
result, err := database.DB.Exec(
`DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`,
pq.Array(req.IDs), userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
return
}
count, _ := result.RowsAffected()
c.JSON(http.StatusOK, gin.H{"deleted": count})
}
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
func (h *NoteHandler) List(c *gin.Context) {
userID := getUserID(c)
folder := c.Query("folder")
tag := c.Query("tag")
sort := c.DefaultQuery("sort", "updated_desc")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 200 {
limit = 50
}
query := `SELECT id, title, folder_path, tags, LEFT(content, 200),
created_at::text, updated_at::text
FROM notes WHERE user_id = $1`
args := []interface{}{userID}
argIdx := 2
if folder != "" {
query += " AND folder_path = $" + strconv.Itoa(argIdx)
args = append(args, normalizeFolderPath(folder))
argIdx++
}
if tag != "" {
query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)"
args = append(args, tag)
argIdx++
}
// Sort options
switch sort {
case "created_asc":
query += " ORDER BY created_at ASC"
case "created_desc":
query += " ORDER BY created_at DESC"
case "updated_asc":
query += " ORDER BY updated_at ASC"
case "title_asc":
query += " ORDER BY title ASC"
case "title_desc":
query += " ORDER BY title DESC"
default: // "updated_desc"
query += " ORDER BY updated_at DESC"
}
query += " LIMIT $" + strconv.Itoa(argIdx) +
" OFFSET $" + strconv.Itoa(argIdx+1)
args = append(args, limit, offset)
rows, err := database.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
defer rows.Close()
notes := make([]noteListItem, 0)
for rows.Next() {
var n noteListItem
var dbTags pq.StringArray
if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath, &dbTags, &n.Preview,
&n.CreatedAt, &n.UpdatedAt); err != nil {
continue
}
n.Tags = []string(dbTags)
if n.Tags == nil {
n.Tags = []string{}
}
notes = append(notes, n)
}
// Get total count
var total int
countQuery := `SELECT COUNT(*) FROM notes WHERE user_id = $1`
countArgs := []interface{}{userID}
if folder != "" {
countQuery += " AND folder_path = $2"
countArgs = append(countArgs, normalizeFolderPath(folder))
}
_ = database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
c.JSON(http.StatusOK, gin.H{
"data": notes,
"total": total,
"limit": limit,
"offset": offset,
})
}
// ── Search ──────────────────────────────────
// GET /api/v1/notes/search?q=query&limit=20
func (h *NoteHandler) Search(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 || limit > 100 {
limit = 20
}
// Use plainto_tsquery for natural language (not websearch_to_tsquery which
// requires Postgres 11+ and has stricter syntax).
rows, err := database.DB.Query(`
SELECT id, title, folder_path, tags, LEFT(content, 200),
created_at::text, updated_at::text,
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
ts_headline('english', content, plainto_tsquery('english', $2),
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline
FROM notes
WHERE user_id = $1
AND search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC
LIMIT $3
`, userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
defer rows.Close()
results := make([]searchResult, 0)
for rows.Next() {
var r searchResult
var dbTags pq.StringArray
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview,
&r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil {
continue
}
r.Tags = []string(dbTags)
if r.Tags == nil {
r.Tags = []string{}
}
results = append(results, r)
}
c.JSON(http.StatusOK, gin.H{
"data": results,
"query": q,
"total": len(results),
})
}
// ── List Folders ────────────────────────────
// GET /api/v1/notes/folders
func (h *NoteHandler) ListFolders(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT DISTINCT folder_path, COUNT(*) AS count
FROM notes WHERE user_id = $1
GROUP BY folder_path
ORDER BY folder_path
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
defer rows.Close()
type folderInfo struct {
Path string `json:"path"`
Count int `json:"count"`
}
folders := make([]folderInfo, 0)
for rows.Next() {
var f folderInfo
if err := rows.Scan(&f.Path, &f.Count); err != nil {
continue
}
folders = append(folders, f)
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
}
// ── Helpers ─────────────────────────────────
// normalizeFolderPath ensures consistent folder path format.
func normalizeFolderPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
// Collapse double slashes
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
return p
}

View File

@@ -0,0 +1,329 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Notes: Validation (no DB needed) ────────
func TestCreateNoteMissingTitle(t *testing.T) {
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
strings.NewReader(`{"content":"body only"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateNoteMissingContent(t *testing.T) {
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
strings.NewReader(`{"title":"title only"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String())
}
}
// ── Notes: Full CRUD Integration ────────────
func TestNoteCRUDIntegration(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "noteuser", "note@test.com")
h := NewNoteHandler()
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
r.POST("/notes", h.Create)
r.GET("/notes", h.List)
r.GET("/notes/search", h.Search)
r.GET("/notes/folders", h.ListFolders)
r.GET("/notes/:id", h.Get)
r.PUT("/notes/:id", h.Update)
r.DELETE("/notes/:id", h.Delete)
// ── Create ──
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notes",
strings.NewReader(`{
"title": "Meeting Notes",
"content": "Discussed project timeline and deliverables",
"folder_path": "/work/meetings",
"tags": ["project", "planning"]
}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &created)
noteID, ok := created["id"].(string)
if !ok || noteID == "" {
t.Fatal("Create: missing or empty id in response")
}
if created["title"] != "Meeting Notes" {
t.Errorf("Create: title mismatch: %v", created["title"])
}
if created["folder_path"] != "/work/meetings/" {
t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"])
}
// ── Create second note for search/list tests ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/notes",
strings.NewReader(`{
"title": "Recipe Ideas",
"content": "Try making sourdough bread with rosemary",
"folder_path": "/personal",
"tags": ["food", "recipes"]
}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create 2nd note: expected 201, got %d", w.Code)
}
// ── Get ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Get: expected 200, got %d", w.Code)
}
var got map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &got)
if got["content"] != "Discussed project timeline and deliverables" {
t.Errorf("Get: wrong content: %v", got["content"])
}
// ── List (all) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List: expected 200, got %d", w.Code)
}
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
total := listResp["total"].(float64)
if total != 2 {
t.Errorf("List: expected total=2, got %.0f", total)
}
// ── List (filtered by folder) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List by folder: expected 200, got %d", w.Code)
}
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("List by folder: expected total=1, got %.0f", total)
}
// ── List (filtered by tag) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes?tag=food", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List by tag: expected 200, got %d", w.Code)
}
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("List by tag: expected total=1, got %.0f", total)
}
// ── Search ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String())
}
var searchResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &searchResp)
count := searchResp["count"].(float64)
if count != 1 {
t.Errorf("Search 'sourdough': expected count=1, got %.0f", count)
}
// ── Folders ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/folders", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Folders: expected 200, got %d", w.Code)
}
var folders []interface{}
json.Unmarshal(w.Body.Bytes(), &folders)
if len(folders) != 2 {
t.Errorf("Folders: expected 2 folders, got %d", len(folders))
}
// ── Update (replace) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify update
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &got)
if got["title"] != "Updated Meeting Notes" {
t.Errorf("Update title: got %v", got["title"])
}
if got["content"] != "New content" {
t.Errorf("Update content: got %v", got["content"])
}
// ── Update (append) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
strings.NewReader(`{"content":"\nAppended line","mode":"append"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &got)
content := got["content"].(string)
if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") {
t.Errorf("Append: expected both parts in content, got %q", content)
}
// ── Delete ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Delete: expected 200, got %d", w.Code)
}
// Verify gone
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Get after delete: expected 404, got %d", w.Code)
}
}
// ── Notes: Search empty query ───────────────
func TestNoteSearchEmptyQuery(t *testing.T) {
database.RequireTestDB(t)
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil)
h.Search(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Search with no query: expected 400, got %d", w.Code)
}
}
// ── Notes: Cross-user isolation ─────────────
func TestNoteIsolationBetweenUsers(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userA := database.SeedTestUser(t, "alice", "alice@test.com")
userB := database.SeedTestUser(t, "bob", "bob@test.com")
h := NewNoteHandler()
// Alice creates a note
rA := gin.New()
rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() })
rA.POST("/notes", h.Create)
rA.GET("/notes", h.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notes",
strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`))
req.Header.Set("Content-Type", "application/json")
rA.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Alice create: expected 201, got %d", w.Code)
}
// Bob lists — should see zero
rB := gin.New()
rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() })
rB.GET("/notes", h.List)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
rB.ServeHTTP(w, req)
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
total := listResp["total"].(float64)
if total != 0 {
t.Errorf("Bob should see 0 notes, got %.0f", total)
}
// Alice lists — should see one
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
rA.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("Alice should see 1 note, got %.0f", total)
}
}

View File

@@ -0,0 +1,24 @@
package handlers
import (
"os"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
// TestMain sets up the test DB (if available) and runs all tests.
// DB-dependent tests call database.RequireTestDB(t) to skip gracefully
// when no DB is configured.
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
providers.Init()
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}

View File

@@ -11,6 +11,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
)
func main() {
@@ -143,6 +144,17 @@ func main() {
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
// Notes
notes := handlers.NewNoteHandler()
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler()
protected.GET("/settings/public", adm.PublicSettings)

View File

@@ -58,6 +58,8 @@ func TestAllRoutesRegistered(t *testing.T) {
comp := handlers.NewCompletionHandler()
apiCfg := handlers.NewAPIConfigHandler()
settings := handlers.NewSettingsHandler()
presets := handlers.NewPresetHandler()
notes := handlers.NewNoteHandler()
adm := handlers.NewAdminHandler()
r := gin.New()
@@ -83,7 +85,13 @@ func TestAllRoutesRegistered(t *testing.T) {
// Messages
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
protected.POST("/channels/:id/regenerate", msgs.Regenerate)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Completion Engine
protected.POST("/chat/completions", comp.Complete)
@@ -104,6 +112,22 @@ func TestAllRoutesRegistered(t *testing.T) {
protected.POST("/profile/password", settings.ChangePassword)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Model Presets
protected.GET("/presets", presets.ListUserPresets)
protected.POST("/presets", presets.CreateUserPreset)
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
// Notes
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
}
// Admin routes
@@ -149,7 +173,12 @@ func TestAllRoutesRegistered(t *testing.T) {
// Messages
"GET /api/v1/channels/:id/messages",
"POST /api/v1/channels/:id/messages",
"POST /api/v1/channels/:id/regenerate",
// Message tree (forking)
"GET /api/v1/channels/:id/path",
"PUT /api/v1/channels/:id/cursor",
"POST /api/v1/channels/:id/messages/:msgId/edit",
"POST /api/v1/channels/:id/messages/:msgId/regenerate",
"GET /api/v1/channels/:id/messages/:msgId/siblings",
// Completion Engine
"POST /api/v1/chat/completions",
// API Configs
@@ -168,6 +197,20 @@ func TestAllRoutesRegistered(t *testing.T) {
"POST /api/v1/profile/password",
"GET /api/v1/settings",
"PUT /api/v1/settings",
// Presets
"GET /api/v1/presets",
"POST /api/v1/presets",
"PUT /api/v1/presets/:id",
"DELETE /api/v1/presets/:id",
// Notes
"GET /api/v1/notes",
"POST /api/v1/notes",
"GET /api/v1/notes/search",
"GET /api/v1/notes/folders",
"POST /api/v1/notes/bulk-delete",
"GET /api/v1/notes/:id",
"PUT /api/v1/notes/:id",
"DELETE /api/v1/notes/:id",
// Admin
"GET /api/v1/admin/users",
"POST /api/v1/admin/users",

View File

@@ -35,21 +35,37 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConf
return nil, fmt.Errorf("decode response: %w", err)
}
// Extract text from content blocks
var content string
for _, block := range resp.Content {
if block.Type == "text" {
content += block.Text
}
}
return &CompletionResponse{
Content: content,
result := &CompletionResponse{
Model: resp.Model,
FinishReason: resp.StopReason,
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
}, nil
}
// Extract text and tool_use blocks
for _, block := range resp.Content {
switch block.Type {
case "text":
result.Content += block.Text
case "tool_use":
argsJSON, _ := json.Marshal(block.Input)
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: block.ID,
Type: "function",
Function: FunctionCall{
Name: block.Name,
Arguments: string(argsJSON),
},
})
}
}
// Normalize stop reason
if resp.StopReason == "tool_use" {
result.FinishReason = "tool_calls"
}
return result, nil
}
// ── Stream Completion ───────────────────────
@@ -68,6 +84,9 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
defer close(ch)
defer body.Close()
var toolCalls []ToolCall
var currentToolIdx int = -1
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
@@ -84,18 +103,42 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
}
switch event.Type {
case "content_block_start":
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
currentToolIdx++
toolCalls = append(toolCalls, ToolCall{
ID: event.ContentBlock.ID,
Type: "function",
Function: FunctionCall{
Name: event.ContentBlock.Name,
Arguments: "",
},
})
}
case "content_block_delta":
if event.Delta.Type == "text_delta" {
ch <- StreamEvent{Delta: event.Delta.Text}
} else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 {
toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON
}
case "message_delta":
ch <- StreamEvent{
stopReason := event.Delta.StopReason
ev := StreamEvent{
Done: true,
FinishReason: event.Delta.StopReason,
FinishReason: stopReason,
}
if stopReason == "tool_use" {
ev.FinishReason = "tool_calls"
ev.ToolCalls = toolCalls
}
ch <- ev
case "message_stop":
ch <- StreamEvent{Done: true}
return
case "error":
ch <- StreamEvent{
Error: fmt.Errorf("anthropic stream error: %s", data),
@@ -115,8 +158,6 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
// ── List Models ─────────────────────────────
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
// Anthropic doesn't have a list models endpoint.
// Return known models with authoritative capabilities from our table.
modelIDs := []struct{ id, name string }{
{"claude-opus-4-20250514", "Claude Opus 4"},
{"claude-sonnet-4-20250514", "Claude Sonnet 4"},
@@ -147,7 +188,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
}
endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages"
// Separate system message from conversation
// Separate system message and convert to Anthropic format
var system string
messages := make([]anthropicMessage, 0, len(req.Messages))
for _, m := range req.Messages {
@@ -155,13 +196,55 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
system = m.Content
continue
}
messages = append(messages, anthropicMessage{
Role: m.Role,
Content: m.Content,
})
antMsg := anthropicMessage{Role: m.Role}
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
// Assistant with tool calls → mixed content blocks
if m.Content != "" {
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: m.Content},
}
} else {
antMsg.Content = []anthropicContentBlock{}
}
for _, tc := range m.ToolCalls {
var input json.RawMessage
if tc.Function.Arguments != "" {
input = json.RawMessage(tc.Function.Arguments)
} else {
input = json.RawMessage("{}")
}
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
} else if m.Role == "tool" {
// Tool results → user message with tool_result block
antMsg.Role = "user"
antMsg.Content = []anthropicContentBlock{
{
Type: "tool_result",
ToolUseID: m.ToolCallID,
Content: m.Content,
},
}
} else {
// Regular text message
antMsg.Content = []anthropicContentBlock{
{Type: "text", Text: m.Content},
}
}
messages = append(messages, antMsg)
}
// Build Anthropic-format request
// Merge consecutive user messages (Anthropic requires alternating roles)
messages = mergeConsecutiveUserMessages(messages)
antReq := anthropicRequest{
Model: req.Model,
Messages: messages,
@@ -172,7 +255,6 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.System = system
}
if antReq.MaxTokens == 0 {
// Anthropic requires max_tokens. Resolve from known model table.
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
}
if req.Temperature != nil {
@@ -182,6 +264,15 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.TopP = req.TopP
}
// Add tools in Anthropic format
for _, t := range req.Tools {
antReq.Tools = append(antReq.Tools, anthropicToolDef{
Name: t.Function.Name,
Description: t.Function.Description,
InputSchema: t.Function.Parameters,
})
}
body, err := json.Marshal(antReq)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
@@ -209,11 +300,47 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
return resp.Body, nil
}
// mergeConsecutiveUserMessages combines consecutive user-role messages.
// Anthropic requires strictly alternating user/assistant roles.
func mergeConsecutiveUserMessages(msgs []anthropicMessage) []anthropicMessage {
if len(msgs) <= 1 {
return msgs
}
merged := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
if len(merged) > 0 && merged[len(merged)-1].Role == "user" && m.Role == "user" {
merged[len(merged)-1].Content = append(merged[len(merged)-1].Content, m.Content...)
} else {
merged = append(merged, m)
}
}
return merged
}
// ── Anthropic Wire Types ────────────────────
type anthropicContentBlock struct {
Type string `json:"type"`
// text
Text string `json:"text,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content []anthropicContentBlock `json:"content"`
}
type anthropicToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
type anthropicRequest struct {
@@ -224,14 +351,18 @@ type anthropicRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
Tools []anthropicToolDef `json:"tools,omitempty"`
}
type anthropicResponse struct {
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
@@ -242,8 +373,14 @@ type anthropicResponse struct {
type anthropicStreamEvent struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
StopReason string `json:"stop_reason"`
Type string `json:"type"`
Text string `json:"text"`
StopReason string `json:"stop_reason"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
ContentBlock *struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"content_block,omitempty"`
}

View File

@@ -36,13 +36,32 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig,
return nil, fmt.Errorf("no choices in response")
}
return &CompletionResponse{
result := &CompletionResponse{
Content: resp.Choices[0].Message.Content,
Model: resp.Model,
FinishReason: resp.Choices[0].FinishReason,
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}, nil
}
// Extract tool calls if present
if len(resp.Choices[0].Message.ToolCalls) > 0 {
for _, tc := range resp.Choices[0].Message.ToolCalls {
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
if result.FinishReason == "" {
result.FinishReason = "tool_calls"
}
}
return result, nil
}
// ── Stream Completion ───────────────────────
@@ -61,6 +80,9 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
defer close(ch)
defer body.Close()
// Accumulate tool calls across stream chunks
toolCallMap := map[int]*ToolCall{} // index → accumulated call
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
@@ -84,13 +106,45 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
continue
}
ev := StreamEvent{
Delta: chunk.Choices[0].Delta.Content,
Model: chunk.Model,
FinishReason: chunk.Choices[0].FinishReason,
choice := chunk.Choices[0]
// Accumulate tool call deltas
for _, tc := range choice.Delta.ToolCalls {
idx := 0
if tc.Index != nil {
idx = *tc.Index
}
if existing, ok := toolCallMap[idx]; ok {
// Append arguments fragment
existing.Function.Arguments += tc.Function.Arguments
} else {
// First chunk for this tool — has ID and name
toolCallMap[idx] = &ToolCall{
ID: tc.ID,
Type: "function",
Function: FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
}
}
}
ev := StreamEvent{
Delta: choice.Delta.Content,
Model: chunk.Model,
FinishReason: choice.FinishReason,
}
if ev.FinishReason != "" {
ev.Done = true
// Attach accumulated tool calls on final event
if ev.FinishReason == "tool_calls" && len(toolCallMap) > 0 {
for _, tc := range toolCallMap {
ev.ToolCalls = append(ev.ToolCalls, *tc)
}
}
}
ch <- ev
@@ -178,11 +232,46 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
oaiReq.TopP = req.TopP
}
// Convert tools
for _, t := range req.Tools {
oaiReq.Tools = append(oaiReq.Tools, openaiToolDef{
Type: t.Type,
Function: openaiToolDefFunction{
Name: t.Function.Name,
Description: t.Function.Description,
Parameters: t.Function.Parameters,
},
})
}
// Convert messages — handle all roles including tool
for _, m := range req.Messages {
oaiReq.Messages = append(oaiReq.Messages, openaiMessage{
oaiMsg := openaiMessage{
Role: m.Role,
Content: m.Content,
})
}
// Assistant messages with tool calls
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
oaiMsg.ToolCalls = append(oaiMsg.ToolCalls, openaiToolCall{
ID: tc.ID,
Type: tc.Type,
Function: openaiToolCallFunction{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
}
// Tool result messages
if m.Role == "tool" {
oaiMsg.ToolCallID = m.ToolCallID
oaiMsg.Name = m.Name
}
oaiReq.Messages = append(oaiReq.Messages, oaiMsg)
}
body, err := json.Marshal(oaiReq)
@@ -220,8 +309,34 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
// ── OpenAI Wire Types ───────────────────────
type openaiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
Name string `json:"name,omitempty"` // tool name for role=tool
}
type openaiToolCallFunction struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
type openaiToolCall struct {
Index *int `json:"index,omitempty"` // present in streaming deltas
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // "function"
Function openaiToolCallFunction `json:"function"`
}
type openaiToolDefFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
type openaiToolDef struct {
Type string `json:"type"` // "function"
Function openaiToolDefFunction `json:"function"`
}
type openaiChatRequest struct {
@@ -231,6 +346,7 @@ type openaiChatRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
Tools []openaiToolDef `json:"tools,omitempty"`
}
type openaiChatResponse struct {
@@ -249,7 +365,8 @@ type openaiStreamChunk struct {
Model string `json:"model"`
Choices []struct {
Delta struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`

View File

@@ -2,6 +2,7 @@ package providers
import (
"context"
"encoding/json"
)
// ── Provider Interface ──────────────────────
@@ -37,8 +38,39 @@ type ProviderConfig struct {
// Message represents a chat message in the conversation.
type Message struct {
Role string `json:"role"` // user, assistant, system
Role string `json:"role"` // user, assistant, system, tool
Content string `json:"content"`
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
Name string `json:"name,omitempty"` // tool name for role="tool"
}
// ToolCall represents a function call requested by the LLM.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function FunctionCall `json:"function"`
}
// FunctionCall is the function name and JSON arguments.
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ToolDef describes a tool available to the LLM.
type ToolDef struct {
Type string `json:"type"` // "function"
Function FunctionDef `json:"function"`
}
// FunctionDef is the schema for a tool function.
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
// CompletionRequest is the normalized request sent to any provider.
@@ -49,15 +81,17 @@ type CompletionRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling
}
// CompletionResponse is the normalized non-streaming response.
type CompletionResponse struct {
Content string `json:"content"`
Model string `json:"model"`
FinishReason string `json:"finish_reason"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
Content string `json:"content"`
Model string `json:"model"`
FinishReason string `json:"finish_reason"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls"
}
// StreamEvent is a single chunk from a streaming response.
@@ -69,8 +103,13 @@ type StreamEvent struct {
Done bool `json:"done,omitempty"`
// FinishReason is set on the final event.
// "stop" = normal, "tool_calls" = LLM wants to call tools.
FinishReason string `json:"finish_reason,omitempty"`
// ToolCalls accumulates tool call data during streaming.
// Fully populated on the final event when FinishReason is "tool_calls".
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// Model echoes back the model used.
Model string `json:"model,omitempty"`

372
server/tools/notes.go Normal file
View File

@@ -0,0 +1,372 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Init ────────────────────────────────────
func init() {
Register(&NoteCreateTool{})
Register(&NoteSearchTool{})
Register(&NoteUpdateTool{})
Register(&NoteListTool{})
}
// ═══════════════════════════════════════════
// note_create
// ═══════════════════════════════════════════
type NoteCreateTool struct{}
func (t *NoteCreateTool) Definition() ToolDef {
return ToolDef{
Name: "note_create",
Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.",
Parameters: JSONSchema(map[string]interface{}{
"title": Prop("string", "Title of the note"),
"content": Prop("string", "Markdown content of the note"),
"folder": Prop("string", "Folder path, e.g. '/projects/switchboard/'. Defaults to '/'"),
"tags": PropArray("Tags for categorization, e.g. ['research', 'golang']"),
}, []string{"title", "content"}),
}
}
func (t *NoteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Title string `json:"title"`
Content string `json:"content"`
Folder string `json:"folder"`
Tags []string `json:"tags"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Title == "" {
return "", fmt.Errorf("title is required")
}
folder := normalizePath(args.Folder)
tags := args.Tags
if tags == nil {
tags = []string{}
}
// source_channel_id links the note to the conversation that created it
var sourceChannelID *string
if execCtx.ChannelID != "" {
sourceChannelID = &execCtx.ChannelID
}
var id, createdAt string
err := database.DB.QueryRow(`
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at::text
`, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID,
).Scan(&id, &createdAt)
if err != nil {
return "", fmt.Errorf("failed to create note: %w", err)
}
result, _ := json.Marshal(map[string]interface{}{
"id": id,
"title": args.Title,
"folder": folder,
"tags": tags,
"created_at": createdAt,
"message": fmt.Sprintf("Note '%s' created successfully.", args.Title),
})
return string(result), nil
}
// ═══════════════════════════════════════════
// note_search
// ═══════════════════════════════════════════
type NoteSearchTool struct{}
func (t *NoteSearchTool) Definition() ToolDef {
return ToolDef{
Name: "note_search",
Description: "Search the user's notes by keyword. Returns matching notes ranked by relevance with highlighted excerpts. Use this to find previously saved information.",
Parameters: JSONSchema(map[string]interface{}{
"query": Prop("string", "Search query — natural language keywords"),
"limit": Prop("integer", "Maximum results to return (default 10, max 50)"),
}, []string{"query"}),
}
}
func (t *NoteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Query == "" {
return "", fmt.Errorf("query is required")
}
if args.Limit <= 0 || args.Limit > 50 {
args.Limit = 10
}
rows, err := database.DB.Query(`
SELECT id, title, folder_path, tags, LEFT(content, 500),
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
ts_headline('english', content, plainto_tsquery('english', $2),
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline
FROM notes
WHERE user_id = $1
AND search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC
LIMIT $3
`, execCtx.UserID, args.Query, args.Limit)
if err != nil {
return "", fmt.Errorf("search failed: %w", err)
}
defer rows.Close()
type result struct {
ID string `json:"id"`
Title string `json:"title"`
Folder string `json:"folder"`
Tags []string `json:"tags"`
Excerpt string `json:"excerpt"`
Headline string `json:"headline"`
Rank float64 `json:"rank"`
}
results := make([]result, 0)
for rows.Next() {
var r result
var dbTags pq.StringArray
if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil {
continue
}
r.Tags = []string(dbTags)
if r.Tags == nil {
r.Tags = []string{}
}
results = append(results, r)
}
out, _ := json.Marshal(map[string]interface{}{
"query": args.Query,
"count": len(results),
"results": results,
})
return string(out), nil
}
// ═══════════════════════════════════════════
// note_update
// ═══════════════════════════════════════════
type NoteUpdateTool struct{}
func (t *NoteUpdateTool) Definition() ToolDef {
return ToolDef{
Name: "note_update",
Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.",
Parameters: JSONSchema(map[string]interface{}{
"note_id": Prop("string", "UUID of the note to update (from note_search results)"),
"title": Prop("string", "New title (omit to keep current)"),
"content": Prop("string", "New content or text to append/prepend"),
"mode": PropEnum("How to apply content", "replace", "append", "prepend"),
"tags": PropArray("New tags (replaces existing tags)"),
}, []string{"note_id"}),
}
}
func (t *NoteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
NoteID string `json:"note_id"`
Title *string `json:"title"`
Content *string `json:"content"`
Mode string `json:"mode"`
Tags []string `json:"tags"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.NoteID == "" {
return "", fmt.Errorf("note_id is required")
}
// Build dynamic update
setClauses := []string{}
queryArgs := []interface{}{}
argIdx := 1
if args.Title != nil {
setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx))
queryArgs = append(queryArgs, *args.Title)
argIdx++
}
if args.Content != nil {
mode := strings.ToLower(args.Mode)
switch mode {
case "append":
setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx))
case "prepend":
setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx))
default:
setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx))
}
queryArgs = append(queryArgs, *args.Content)
argIdx++
}
if args.Tags != nil {
setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx))
queryArgs = append(queryArgs, pq.Array(args.Tags))
argIdx++
}
if len(setClauses) == 0 {
return "", fmt.Errorf("no fields to update — provide at least title, content, or tags")
}
queryArgs = append(queryArgs, args.NoteID, execCtx.UserID)
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) +
" RETURNING id, title, updated_at::text"
var id, title, updatedAt string
err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &updatedAt)
if err != nil {
return "", fmt.Errorf("note not found or update failed")
}
result, _ := json.Marshal(map[string]interface{}{
"id": id,
"title": title,
"updated_at": updatedAt,
"message": fmt.Sprintf("Note '%s' updated successfully.", title),
})
return string(result), nil
}
// ═══════════════════════════════════════════
// note_list
// ═══════════════════════════════════════════
type NoteListTool struct{}
func (t *NoteListTool) Definition() ToolDef {
return ToolDef{
Name: "note_list",
Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.",
Parameters: JSONSchema(map[string]interface{}{
"folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"),
"tag": Prop("string", "Filter by tag (omit for all)"),
"limit": Prop("integer", "Maximum results (default 20, max 100)"),
}, nil),
}
}
func (t *NoteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Folder string `json:"folder"`
Tag string `json:"tag"`
Limit int `json:"limit"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Limit <= 0 || args.Limit > 100 {
args.Limit = 20
}
query := `SELECT id, title, folder_path, tags, LEFT(content, 100),
created_at::text, updated_at::text
FROM notes WHERE user_id = $1`
queryArgs := []interface{}{execCtx.UserID}
argIdx := 2
if args.Folder != "" {
query += fmt.Sprintf(" AND folder_path = $%d", argIdx)
queryArgs = append(queryArgs, normalizePath(args.Folder))
argIdx++
}
if args.Tag != "" {
query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx)
queryArgs = append(queryArgs, args.Tag)
argIdx++
}
query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx)
queryArgs = append(queryArgs, args.Limit)
rows, err := database.DB.Query(query, queryArgs...)
if err != nil {
return "", fmt.Errorf("failed to list notes: %w", err)
}
defer rows.Close()
type item struct {
ID string `json:"id"`
Title string `json:"title"`
Folder string `json:"folder"`
Tags []string `json:"tags"`
Preview string `json:"preview"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
items := make([]item, 0)
for rows.Next() {
var n item
var dbTags pq.StringArray
if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview,
&n.CreatedAt, &n.UpdatedAt); err != nil {
continue
}
n.Tags = []string(dbTags)
if n.Tags == nil {
n.Tags = []string{}
}
items = append(items, n)
}
out, _ := json.Marshal(map[string]interface{}{
"count": len(items),
"notes": items,
})
return string(out), nil
}
// ── Path Helper ─────────────────────────────
func normalizePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
return p
}

203
server/tools/notes_test.go Normal file
View File

@@ -0,0 +1,203 @@
package tools
import (
"context"
"encoding/json"
"os"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
func TestMain(m *testing.M) {
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}
// ── Note Tool Execution (requires DB) ───────
func TestNoteCreateExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "tooluser", "tool@test.com")
channelID := database.SeedTestChannel(t, userID, "Tool Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
tool := Get("note_create")
if tool == nil {
t.Fatal("note_create not registered")
}
args := `{"title":"Test Note","content":"Created via tool","folder":"/tools","tags":["test","ci"]}`
result, err := tool.Execute(ctx, execCtx, args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("Invalid JSON result: %v\nRaw: %s", err, result)
}
if resp["id"] == nil || resp["id"] == "" {
t.Error("Expected id in result")
}
if resp["title"] != "Test Note" {
t.Errorf("Expected title='Test Note', got %v", resp["title"])
}
}
func TestNoteListExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "listuser", "list@test.com")
channelID := database.SeedTestChannel(t, userID, "List Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
// Create two notes first
createTool := Get("note_create")
createTool.Execute(ctx, execCtx, `{"title":"Note A","content":"Alpha","folder":"/a"}`)
createTool.Execute(ctx, execCtx, `{"title":"Note B","content":"Beta","folder":"/b"}`)
// List all
listTool := Get("note_list")
result, err := listTool.Execute(ctx, execCtx, `{}`)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
count := resp["count"].(float64)
if count != 2 {
t.Errorf("Expected count=2, got %.0f", count)
}
// List filtered by folder
result, err = listTool.Execute(ctx, execCtx, `{"folder":"/a"}`)
if err != nil {
t.Fatalf("Execute with folder: %v", err)
}
json.Unmarshal([]byte(result), &resp)
count = resp["count"].(float64)
if count != 1 {
t.Errorf("Expected count=1 for folder /a, got %.0f", count)
}
}
func TestNoteSearchExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "searchuser", "search@test.com")
channelID := database.SeedTestChannel(t, userID, "Search Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
createTool := Get("note_create")
createTool.Execute(ctx, execCtx, `{"title":"Kubernetes Guide","content":"How to deploy pods and services"}`)
createTool.Execute(ctx, execCtx, `{"title":"Cooking Tips","content":"Season your cast iron pan properly"}`)
searchTool := Get("note_search")
result, err := searchTool.Execute(ctx, execCtx, `{"query":"kubernetes pods"}`)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
count := resp["count"].(float64)
if count != 1 {
t.Errorf("Expected 1 search result for 'kubernetes pods', got %.0f", count)
}
}
func TestNoteUpdateExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "updateuser", "update@test.com")
channelID := database.SeedTestChannel(t, userID, "Update Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
// Create a note
createTool := Get("note_create")
result, _ := createTool.Execute(ctx, execCtx, `{"title":"Original","content":"Original content"}`)
var created map[string]interface{}
json.Unmarshal([]byte(result), &created)
noteID := created["id"].(string)
// Update title
updateTool := Get("note_update")
result, err := updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","title":"Renamed"}`)
if err != nil {
t.Fatalf("Update title: %v", err)
}
var updated map[string]interface{}
json.Unmarshal([]byte(result), &updated)
if updated["title"] != "Renamed" {
t.Errorf("Expected title='Renamed', got %v", updated["title"])
}
// Append content
result, err = updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","content":"\nNew line","mode":"append"}`)
if err != nil {
t.Fatalf("Append: %v", err)
}
// Verify via direct DB read
var row string
database.DB.QueryRow("SELECT content FROM notes WHERE id = $1", noteID).Scan(&row)
if row != "Original content\nNew line" {
t.Errorf("Append: expected concatenated content, got %q", row)
}
}
func TestNoteCreateMissingRequiredField(t *testing.T) {
database.RequireTestDB(t)
ctx := context.Background()
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
tool := Get("note_create")
// Missing content
result, err := tool.Execute(ctx, execCtx, `{"title":"No Content"}`)
if err == nil {
// Tools return errors in content, not as Go errors
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
if resp["error"] == nil {
t.Error("Expected error for missing content")
}
}
}
func TestNoteUpdateNonexistent(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "ghostuser", "ghost@test.com")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: "test"}
tool := Get("note_update")
_, err := tool.Execute(ctx, execCtx, `{"note_id":"00000000-0000-0000-0000-000000000000","title":"Nope"}`)
if err == nil {
t.Log("Update of nonexistent note should return error or empty result")
}
}

92
server/tools/registry.go Normal file
View File

@@ -0,0 +1,92 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"log"
)
// ── Registry ────────────────────────────────
var registry = map[string]Tool{}
// Register adds a tool to the global registry. Called at init time.
func Register(t Tool) {
def := t.Definition()
if _, exists := registry[def.Name]; exists {
panic("tools.Register: duplicate tool name: " + def.Name)
}
registry[def.Name] = t
log.Printf("🔧 Registered tool: %s", def.Name)
}
// Get returns a tool by name, or nil if not found.
func Get(name string) Tool {
return registry[name]
}
// AllDefinitions returns tool definitions for all registered tools.
// Used to populate the `tools` field in LLM requests.
func AllDefinitions() []ToolDef {
defs := make([]ToolDef, 0, len(registry))
for _, t := range registry {
defs = append(defs, t.Definition())
}
return defs
}
// ── Execution ───────────────────────────────
// ExecuteCall runs a single tool call and returns a ToolResult.
// Never returns an error — failures are encoded in the result content
// so the LLM can see what went wrong and adjust.
func ExecuteCall(ctx context.Context, execCtx ExecutionContext, call ToolCall) ToolResult {
tool := Get(call.Name)
if tool == nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: jsonError(fmt.Sprintf("unknown tool: %s", call.Name)),
IsError: true,
}
}
result, err := tool.Execute(ctx, execCtx, call.Arguments)
if err != nil {
log.Printf("Tool %s error: %v", call.Name, err)
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: jsonError(err.Error()),
IsError: true,
}
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result,
}
}
// ExecuteAll runs multiple tool calls and returns all results.
func ExecuteAll(ctx context.Context, execCtx ExecutionContext, calls []ToolCall) []ToolResult {
results := make([]ToolResult, len(calls))
for i, call := range calls {
results[i] = ExecuteCall(ctx, execCtx, call)
}
return results
}
// HasTools returns true if any tools are registered.
func HasTools() bool {
return len(registry) > 0
}
// ── Helpers ─────────────────────────────────
func jsonError(msg string) string {
b, _ := json.Marshal(map[string]string{"error": msg})
return string(b)
}

194
server/tools/tools_test.go Normal file
View File

@@ -0,0 +1,194 @@
package tools
import (
"context"
"encoding/json"
"testing"
)
// ── Registry Tests ──────────────────────────
func TestRegistryHasTools(t *testing.T) {
// init() in notes.go registers 4 tools
if !HasTools() {
t.Fatal("Expected HasTools() == true after init registration")
}
}
func TestRegistryAllDefinitions(t *testing.T) {
defs := AllDefinitions()
if len(defs) < 4 {
t.Errorf("Expected at least 4 tool definitions, got %d", len(defs))
}
expected := map[string]bool{
"note_create": false,
"note_search": false,
"note_update": false,
"note_list": false,
}
for _, d := range defs {
if _, ok := expected[d.Name]; ok {
expected[d.Name] = true
}
// Every tool must have a description and parameters
if d.Description == "" {
t.Errorf("Tool %q has empty description", d.Name)
}
if len(d.Parameters) == 0 {
t.Errorf("Tool %q has empty parameters", d.Name)
}
}
for name, found := range expected {
if !found {
t.Errorf("Expected tool %q not found in registry", name)
}
}
}
func TestRegistryGetKnown(t *testing.T) {
tool := Get("note_create")
if tool == nil {
t.Fatal("Get(note_create) returned nil")
}
def := tool.Definition()
if def.Name != "note_create" {
t.Errorf("Expected name 'note_create', got %q", def.Name)
}
}
func TestRegistryGetUnknown(t *testing.T) {
tool := Get("nonexistent_tool")
if tool != nil {
t.Error("Get(nonexistent_tool) should return nil")
}
}
func TestExecuteCallUnknownTool(t *testing.T) {
ctx := context.Background()
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
call := ToolCall{ID: "call_1", Name: "nonexistent", Arguments: "{}"}
result := ExecuteCall(ctx, execCtx, call)
if !result.IsError {
t.Error("Expected is_error=true for unknown tool")
}
if result.ToolCallID != "call_1" {
t.Errorf("Expected tool_call_id='call_1', got %q", result.ToolCallID)
}
}
func TestExecuteAll(t *testing.T) {
ctx := context.Background()
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
calls := []ToolCall{
{ID: "call_1", Name: "nonexistent_a", Arguments: "{}"},
{ID: "call_2", Name: "nonexistent_b", Arguments: "{}"},
}
results := ExecuteAll(ctx, execCtx, calls)
if len(results) != 2 {
t.Fatalf("Expected 2 results, got %d", len(results))
}
for i, r := range results {
if !r.IsError {
t.Errorf("Result %d: expected is_error=true", i)
}
}
}
// ── Tool Definition Schema Tests ────────────
func TestToolDefinitionsHaveValidJSON(t *testing.T) {
for _, def := range AllDefinitions() {
var schema map[string]interface{}
if err := json.Unmarshal(def.Parameters, &schema); err != nil {
t.Errorf("Tool %q has invalid JSON schema: %v", def.Name, err)
}
// Should be a JSON Schema object with "type" and "properties"
if schema["type"] != "object" {
t.Errorf("Tool %q schema type should be 'object', got %v", def.Name, schema["type"])
}
if _, ok := schema["properties"]; !ok {
t.Errorf("Tool %q schema missing 'properties'", def.Name)
}
}
}
// ── JSON Schema Helper Tests ────────────────
func TestPropString(t *testing.T) {
p := Prop("string", "a description")
if p["type"] != "string" {
t.Errorf("Expected type=string, got %v", p["type"])
}
if p["description"] != "a description" {
t.Errorf("Expected description, got %v", p["description"])
}
}
func TestPropEnum(t *testing.T) {
p := PropEnum("content mode", "replace", "append", "prepend")
if p["type"] != "string" {
t.Error("Expected type=string")
}
enums := p["enum"].([]string)
if len(enums) != 3 {
t.Errorf("Expected 3 enum values, got %d", len(enums))
}
}
func TestPropArray(t *testing.T) {
p := PropArray("list of tags")
if p["type"] != "array" {
t.Error("Expected type=array")
}
items := p["items"].(map[string]string)
if items["type"] != "string" {
t.Error("Expected items.type=string")
}
}
func TestJSONSchema(t *testing.T) {
props := map[string]interface{}{
"title": Prop("string", "note title"),
"content": Prop("string", "note body"),
}
schema := JSONSchema(props, []string{"title"})
var m map[string]interface{}
if err := json.Unmarshal(schema, &m); err != nil {
t.Fatalf("Invalid JSON: %v", err)
}
if m["type"] != "object" {
t.Error("Expected type=object")
}
required := m["required"].([]interface{})
if len(required) != 1 || required[0] != "title" {
t.Errorf("Expected required=[title], got %v", required)
}
mProps := m["properties"].(map[string]interface{})
if _, ok := mProps["title"]; !ok {
t.Error("Missing property 'title'")
}
if _, ok := mProps["content"]; !ok {
t.Error("Missing property 'content'")
}
}
func TestMustJSON(t *testing.T) {
result := MustJSON(map[string]string{"key": "value"})
var m map[string]string
if err := json.Unmarshal(result, &m); err != nil {
t.Fatalf("MustJSON produced invalid JSON: %v", err)
}
if m["key"] != "value" {
t.Errorf("Expected value, got %q", m["key"])
}
}

105
server/tools/types.go Normal file
View File

@@ -0,0 +1,105 @@
package tools
import (
"context"
"encoding/json"
)
// ── Tool Definition (sent to LLM) ──────────
// ToolDef describes a tool the LLM can call. Serialized to OpenAI-compatible
// format by providers. The Parameters field uses JSON Schema.
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"` // JSON Schema object
}
// ── Tool Call (from LLM response) ───────────
// ToolCall represents a single tool invocation requested by the LLM.
type ToolCall struct {
ID string `json:"id"` // provider-assigned call ID
Name string `json:"name"` // tool function name
Arguments string `json:"arguments"` // JSON string of arguments
}
// ── Tool Result (fed back to LLM) ───────────
// ToolResult is the output of executing a tool, sent back to the LLM
// as a message with role "tool".
type ToolResult struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Content string `json:"content"` // JSON-encoded result
IsError bool `json:"is_error,omitempty"` // true if execution failed
}
// ── Tool Interface ──────────────────────────
// ExecutionContext provides tool implementations with the info they need.
type ExecutionContext struct {
UserID string
ChannelID string
}
// Tool is the interface every built-in tool implements.
type Tool interface {
// Definition returns the tool's schema for the LLM.
Definition() ToolDef
// Execute runs the tool with the given JSON arguments.
// Returns a JSON string result or an error.
Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error)
}
// ── Helpers ─────────────────────────────────
// MustJSON marshals v to a json.RawMessage, panicking on error.
// Used for static parameter schemas at init time.
func MustJSON(v interface{}) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic("tools.MustJSON: " + err.Error())
}
return json.RawMessage(b)
}
// JSONSchema builds a JSON Schema object type with the given properties.
// Convenience for defining tool parameters.
func JSONSchema(properties map[string]interface{}, required []string) json.RawMessage {
schema := map[string]interface{}{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
return MustJSON(schema)
}
// Prop builds a JSON Schema property definition.
func Prop(typ, description string) map[string]interface{} {
return map[string]interface{}{
"type": typ,
"description": description,
}
}
// PropEnum builds a JSON Schema string property with enum values.
func PropEnum(description string, values ...string) map[string]interface{} {
return map[string]interface{}{
"type": "string",
"description": description,
"enum": values,
}
}
// PropArray builds a JSON Schema array property with string items.
func PropArray(description string) map[string]interface{} {
return map[string]interface{}{
"type": "array",
"description": description,
"items": map[string]string{"type": "string"},
}
}