Core features: - Microkernel architecture with Actor model - Session management with JSONL persistence - Tool system (5 built-in tools) - Skill system with SKILL.md parsing - Sandbox security execution - Ollama integration with gemma4:e4b - Prompt-based tool calling (compatible with native function calling) - REPL interface 11 packages, all tests passing
551 lines
13 KiB
Go
551 lines
13 KiB
Go
package session
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orca/orca/pkg/bus"
|
|
)
|
|
|
|
// ============================================================
|
|
// JSONL Store Tests
|
|
// ============================================================
|
|
|
|
func setupTestStore(t *testing.T) (*JSONLStore, func()) {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "orca-session-test-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
|
|
store, err := NewJSONLStore(dir)
|
|
if err != nil {
|
|
os.RemoveAll(dir)
|
|
t.Fatalf("NewJSONLStore failed: %v", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
os.RemoveAll(dir)
|
|
}
|
|
return store, cleanup
|
|
}
|
|
|
|
func TestNewJSONLStore(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
if store == nil {
|
|
t.Fatal("NewJSONLStore returned nil")
|
|
}
|
|
if store.StorageDir() == "" {
|
|
t.Error("StorageDir should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreSaveAndLoad(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
msg := SessionMessage{
|
|
Role: RoleUser,
|
|
Content: "Hello, world!",
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
if err := store.Save("session-1", msg); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
messages, err := store.Load("session-1")
|
|
if err != nil {
|
|
t.Fatalf("Load failed: %v", err)
|
|
}
|
|
if len(messages) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(messages))
|
|
}
|
|
if messages[0].Role != RoleUser {
|
|
t.Errorf("expected RoleUser, got %s", messages[0].Role)
|
|
}
|
|
if messages[0].Content != "Hello, world!" {
|
|
t.Errorf("expected content 'Hello, world!', got %q", messages[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreAppendMultiple(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
roles := []MessageRole{RoleUser, RoleAssistant, RoleUser, RoleSystem}
|
|
for i, role := range roles {
|
|
msg := SessionMessage{
|
|
Role: role,
|
|
Content: "message " + string(rune('0'+i)),
|
|
Timestamp: time.Now(),
|
|
}
|
|
if err := store.Save("session-append", msg); err != nil {
|
|
t.Fatalf("Save %d failed: %v", i, err)
|
|
}
|
|
}
|
|
|
|
messages, err := store.Load("session-append")
|
|
if err != nil {
|
|
t.Fatalf("Load failed: %v", err)
|
|
}
|
|
if len(messages) != len(roles) {
|
|
t.Fatalf("expected %d messages, got %d", len(roles), len(messages))
|
|
}
|
|
for i, msg := range messages {
|
|
if msg.Role != roles[i] {
|
|
t.Errorf("message %d: expected role %s, got %s", i, roles[i], msg.Role)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreLoadNonexistent(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
_, err := store.Load("nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error loading nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreExists(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
exists, err := store.Exists("nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Exists failed: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("expected nonexistent session to return false")
|
|
}
|
|
|
|
store.Save("session-exists", SessionMessage{Role: RoleUser, Content: "test"})
|
|
exists, err = store.Exists("session-exists")
|
|
if err != nil {
|
|
t.Fatalf("Exists failed: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("expected existing session to return true")
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreList(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
ids := []string{"sess-a", "sess-b", "sess-c"}
|
|
for _, id := range ids {
|
|
store.Save(id, SessionMessage{Role: RoleUser, Content: "test"})
|
|
}
|
|
|
|
list, err := store.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
if len(list) != len(ids) {
|
|
t.Fatalf("expected %d sessions, got %d", len(ids), len(list))
|
|
}
|
|
|
|
found := make(map[string]bool)
|
|
for _, id := range list {
|
|
found[id] = true
|
|
}
|
|
for _, id := range ids {
|
|
if !found[id] {
|
|
t.Errorf("missing session %q in list", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreArchiveAndLoad(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
msg := SessionMessage{Role: RoleUser, Content: "archive test"}
|
|
store.Save("sess-archive", msg)
|
|
|
|
if err := store.Archive("sess-archive"); err != nil {
|
|
t.Fatalf("Archive failed: %v", err)
|
|
}
|
|
|
|
// Should still be loadable (archived files are in .archived suffix)
|
|
messages, err := store.Load("sess-archive")
|
|
if err != nil {
|
|
t.Fatalf("Load after archive failed: %v", err)
|
|
}
|
|
if len(messages) != 1 {
|
|
t.Errorf("expected 1 message after archive, got %d", len(messages))
|
|
}
|
|
|
|
// Should not appear in List
|
|
list, _ := store.List()
|
|
for _, id := range list {
|
|
if id == "sess-archive" {
|
|
t.Error("archived session should not appear in List")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreArchiveNonexistent(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
err := store.Archive("nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error archiving nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreDelete(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
store.Save("sess-delete", SessionMessage{Role: RoleUser, Content: "delete me"})
|
|
if err := store.Delete("sess-delete"); err != nil {
|
|
t.Fatalf("Delete failed: %v", err)
|
|
}
|
|
|
|
exists, _ := store.Exists("sess-delete")
|
|
if exists {
|
|
t.Error("expected deleted session to not exist")
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreDeleteNonexistent(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
err := store.Delete("nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Delete nonexistent should succeed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreConcurrentWrites(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
done := make(chan struct{}, 2)
|
|
go func() {
|
|
for i := 0; i < 50; i++ {
|
|
store.Save("concurrent", SessionMessage{Role: RoleUser, Content: "from-a"})
|
|
}
|
|
done <- struct{}{}
|
|
}()
|
|
go func() {
|
|
for i := 0; i < 50; i++ {
|
|
store.Save("concurrent", SessionMessage{Role: RoleAssistant, Content: "from-b"})
|
|
}
|
|
done <- struct{}{}
|
|
}()
|
|
|
|
<-done
|
|
<-done
|
|
|
|
messages, err := store.Load("concurrent")
|
|
if err != nil {
|
|
t.Fatalf("Load failed: %v", err)
|
|
}
|
|
if len(messages) != 100 {
|
|
t.Errorf("expected 100 messages, got %d", len(messages))
|
|
}
|
|
}
|
|
|
|
func TestJSONLStoreEmptyFile(t *testing.T) {
|
|
store, cleanup := setupTestStore(t)
|
|
defer cleanup()
|
|
|
|
dir := store.StorageDir()
|
|
// Create an empty file
|
|
f, _ := os.Create(filepath.Join(dir, "empty.jsonl"))
|
|
f.Close()
|
|
|
|
messages, err := store.Load("empty")
|
|
if err != nil {
|
|
t.Fatalf("Load empty session failed: %v", err)
|
|
}
|
|
if len(messages) != 0 {
|
|
t.Errorf("expected 0 messages from empty file, got %d", len(messages))
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Session Manager Tests
|
|
// ============================================================
|
|
|
|
func setupTestManager(t *testing.T) (*Manager, func()) {
|
|
t.Helper()
|
|
store, cleanup := setupTestStore(t)
|
|
mb := bus.New()
|
|
mgr := NewManager(store, mb)
|
|
return mgr, func() {
|
|
mb.Close()
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
func TestNewManager(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
if mgr == nil {
|
|
t.Fatal("NewManager returned nil")
|
|
}
|
|
}
|
|
|
|
func TestManagerCreateSession(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
session, err := mgr.CreateSession("sess-1", map[string]string{"key": "value"})
|
|
if err != nil {
|
|
t.Fatalf("CreateSession failed: %v", err)
|
|
}
|
|
if session.ID != "sess-1" {
|
|
t.Errorf("expected ID 'sess-1', got %q", session.ID)
|
|
}
|
|
if session.Status != SessionActive {
|
|
t.Errorf("expected SessionActive, got %s", session.Status)
|
|
}
|
|
if session.Metadata["key"] != "value" {
|
|
t.Errorf("expected metadata key 'value', got %q", session.Metadata["key"])
|
|
}
|
|
if session.MessageCount() != 0 {
|
|
t.Errorf("expected 0 messages, got %d", session.MessageCount())
|
|
}
|
|
if session.CreatedAt.IsZero() {
|
|
t.Error("CreatedAt should not be zero")
|
|
}
|
|
}
|
|
|
|
func TestManagerCreateDuplicate(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
mgr.CreateSession("dup", nil)
|
|
_, err := mgr.CreateSession("dup", nil)
|
|
if err == nil {
|
|
t.Error("expected error creating duplicate session")
|
|
}
|
|
}
|
|
|
|
func TestManagerAddMessage(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
msg, err := mgr.AddMessage("sess-add", RoleUser, "Hello!", map[string]string{"source": "test"})
|
|
if err != nil {
|
|
t.Fatalf("AddMessage failed: %v", err)
|
|
}
|
|
if msg.Role != RoleUser {
|
|
t.Errorf("expected RoleUser, got %s", msg.Role)
|
|
}
|
|
if msg.Content != "Hello!" {
|
|
t.Errorf("expected 'Hello!', got %q", msg.Content)
|
|
}
|
|
if msg.Metadata["source"] != "test" {
|
|
t.Errorf("expected metadata source 'test', got %q", msg.Metadata["source"])
|
|
}
|
|
if msg.Timestamp.IsZero() {
|
|
t.Error("Timestamp should not be zero")
|
|
}
|
|
|
|
// Verify it was persisted
|
|
messages, _ := mgr.GetContext("sess-add", 10)
|
|
if len(messages) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(messages))
|
|
}
|
|
}
|
|
|
|
func TestManagerAddMessageAutoCreatesSession(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
mgr.AddMessage("auto-session", RoleUser, "auto create", nil)
|
|
|
|
session, err := mgr.GetSession("auto-session")
|
|
if err != nil {
|
|
t.Fatalf("GetSession failed: %v", err)
|
|
}
|
|
if session.MessageCount() != 1 {
|
|
t.Errorf("expected 1 message, got %d", session.MessageCount())
|
|
}
|
|
}
|
|
|
|
func TestManagerGetContextWindow(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
// Add 10 messages
|
|
for i := 0; i < 10; i++ {
|
|
mgr.AddMessage("window-test", RoleUser, "msg", nil)
|
|
}
|
|
|
|
// Get last 3
|
|
messages, err := mgr.GetContext("window-test", 3)
|
|
if err != nil {
|
|
t.Fatalf("GetContext failed: %v", err)
|
|
}
|
|
if len(messages) != 3 {
|
|
t.Errorf("expected 3 messages, got %d", len(messages))
|
|
}
|
|
|
|
// Get all (window larger than total)
|
|
all, _ := mgr.GetContext("window-test", 100)
|
|
if len(all) != 10 {
|
|
t.Errorf("expected 10 messages, got %d", len(all))
|
|
}
|
|
|
|
// Get with window <= 0
|
|
all2, _ := mgr.GetContext("window-test", 0)
|
|
if len(all2) != 10 {
|
|
t.Errorf("expected 10 messages with window=0, got %d", len(all2))
|
|
}
|
|
}
|
|
|
|
func TestManagerGetContextNonexistent(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
_, err := mgr.GetContext("nonexistent", 10)
|
|
if err == nil {
|
|
t.Error("expected error getting context for nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestManagerArchiveSession(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
mgr.CreateSession("archivable", nil)
|
|
mgr.AddMessage("archivable", RoleUser, "test", nil)
|
|
|
|
if err := mgr.ArchiveSession("archivable"); err != nil {
|
|
t.Fatalf("ArchiveSession failed: %v", err)
|
|
}
|
|
|
|
session, _ := mgr.GetSession("archivable")
|
|
if session.Status != SessionArchived {
|
|
t.Errorf("expected SessionArchived, got %s", session.Status)
|
|
}
|
|
if session.IsArchived() != true {
|
|
t.Error("expected IsArchived to return true")
|
|
}
|
|
}
|
|
|
|
func TestManagerDeleteSession(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
mgr.CreateSession("deletable", nil)
|
|
if err := mgr.DeleteSession("deletable"); err != nil {
|
|
t.Fatalf("DeleteSession failed: %v", err)
|
|
}
|
|
|
|
_, err := mgr.GetSession("deletable")
|
|
if err == nil {
|
|
t.Error("expected error getting deleted session")
|
|
}
|
|
}
|
|
|
|
func TestManagerListSessions(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
mgr.AddMessage("list-a", RoleUser, "a", nil)
|
|
mgr.AddMessage("list-b", RoleUser, "b", nil)
|
|
|
|
sessions, err := mgr.ListSessions()
|
|
if err != nil {
|
|
t.Fatalf("ListSessions failed: %v", err)
|
|
}
|
|
if len(sessions) != 2 {
|
|
t.Errorf("expected 2 sessions, got %d", len(sessions))
|
|
}
|
|
}
|
|
|
|
func TestManagerMultipleMessagesOrder(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
contents := []string{"first", "second", "third"}
|
|
for i, c := range contents {
|
|
mgr.AddMessage("order-test", RoleUser, c, nil)
|
|
_ = i
|
|
}
|
|
|
|
messages, _ := mgr.GetContext("order-test", 10)
|
|
if len(messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(messages))
|
|
}
|
|
if messages[0].Content != "first" {
|
|
t.Errorf("expected first message content 'first', got %q", messages[0].Content)
|
|
}
|
|
if messages[2].Content != "third" {
|
|
t.Errorf("expected third message content 'third', got %q", messages[2].Content)
|
|
}
|
|
}
|
|
|
|
func TestManagerStoreAccess(t *testing.T) {
|
|
mgr, cleanup := setupTestManager(t)
|
|
defer cleanup()
|
|
|
|
store := mgr.Store()
|
|
if store == nil {
|
|
t.Error("Store() should not return nil")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Session Types Tests
|
|
// ============================================================
|
|
|
|
func TestSessionIsArchived(t *testing.T) {
|
|
s := &Session{Status: SessionActive}
|
|
if s.IsArchived() {
|
|
t.Error("active session should not be archived")
|
|
}
|
|
|
|
s.Status = SessionArchived
|
|
if !s.IsArchived() {
|
|
t.Error("archived session should be archived")
|
|
}
|
|
}
|
|
|
|
func TestSessionMessageCount(t *testing.T) {
|
|
s := &Session{Messages: make([]SessionMessage, 5)}
|
|
if n := s.MessageCount(); n != 5 {
|
|
t.Errorf("expected 5 messages, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestMessageRoleConstants(t *testing.T) {
|
|
if RoleUser != "user" {
|
|
t.Errorf("expected RoleUser 'user', got %q", RoleUser)
|
|
}
|
|
if RoleAssistant != "assistant" {
|
|
t.Errorf("expected RoleAssistant 'assistant', got %q", RoleAssistant)
|
|
}
|
|
if RoleSystem != "system" {
|
|
t.Errorf("expected RoleSystem 'system', got %q", RoleSystem)
|
|
}
|
|
if RoleTool != "tool" {
|
|
t.Errorf("expected RoleTool 'tool', got %q", RoleTool)
|
|
}
|
|
}
|
|
|
|
func TestSessionStatusConstants(t *testing.T) {
|
|
if SessionActive != "active" {
|
|
t.Errorf("expected SessionActive 'active', got %q", SessionActive)
|
|
}
|
|
if SessionArchived != "archived" {
|
|
t.Errorf("expected SessionArchived 'archived', got %q", SessionArchived)
|
|
}
|
|
}
|