1234 lines
33 KiB
Go
1234 lines
33 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
agent "ollie/session"
|
|
"ollie/backend"
|
|
olog "ollie/log"
|
|
"olliesrv/fs"
|
|
"olliesrv/session"
|
|
)
|
|
|
|
// --- test helpers ---
|
|
|
|
func testSink() *olog.Sink { return olog.NewSink(io.Discard, io.Discard, olog.LevelError) }
|
|
|
|
// newNoopCore creates a real *agent.Session with a noop backend.
|
|
func newNoopCore(id string) *agent.Session {
|
|
be := backend.NewNoop("stub", "m")
|
|
return agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: "default",
|
|
CWD: "/tmp",
|
|
SessionID: id,
|
|
NewBackend: func(name string) (backend.Backend, error) {
|
|
return backend.NewNoop(name, "default"), nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// newBlockingCore creates a real *agent.Session whose backend blocks until ctx is cancelled.
|
|
func newBlockingCore(id string) *agent.Session {
|
|
be := backend.NewNoop("stub", "m")
|
|
be.ChatStreamFunc = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
go func() {
|
|
<-ctx.Done()
|
|
ch <- backend.StreamEvent{Done: true, StopReason: "interrupted"}
|
|
close(ch)
|
|
}()
|
|
return ch, nil
|
|
}
|
|
return agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: "default",
|
|
CWD: "/tmp",
|
|
SessionID: id,
|
|
NewBackend: func(name string) (backend.Backend, error) {
|
|
return backend.NewNoop(name, "default"), nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// newContentCore creates a real *agent.Session whose backend emits specific content.
|
|
func newContentCore(id, content string) *agent.Session {
|
|
be := backend.NewNoop("stub", "m")
|
|
be.ChatStreamFunc = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
ch := make(chan backend.StreamEvent, 2)
|
|
ch <- backend.StreamEvent{Content: content}
|
|
ch <- backend.StreamEvent{Done: true, StopReason: "end_turn"}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
return agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: "default",
|
|
CWD: "/tmp",
|
|
SessionID: id,
|
|
NewBackend: func(name string) (backend.Backend, error) {
|
|
return backend.NewNoop(name, "default"), nil
|
|
},
|
|
})
|
|
}
|
|
|
|
func testSession(id string) *session.Session {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
core := newNoopCore(id)
|
|
return session.NewSession(id, core, ctx, cancel)
|
|
}
|
|
|
|
func testBlockingSession(id string) *session.Session {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
core := newBlockingCore(id)
|
|
return session.NewSession(id, core, ctx, cancel)
|
|
}
|
|
|
|
func newTestSessionManager(t *testing.T) *session.Manager {
|
|
t.Helper()
|
|
sink := testSink()
|
|
return session.NewManager(session.ManagerConfig{
|
|
Log: sink.NewLogger("test"),
|
|
Sink: sink,
|
|
ReadFile: func(string) ([]byte, error) { return []byte("#!/bin/sh\n"), nil },
|
|
MkdirAll: func(string, os.FileMode) error { return nil },
|
|
})
|
|
}
|
|
|
|
func newTestSessionManagerWithCore(t *testing.T) *session.Manager {
|
|
t.Helper()
|
|
sink := testSink()
|
|
return session.NewManager(session.ManagerConfig{
|
|
Log: sink.NewLogger("test"),
|
|
Sink: sink,
|
|
ReadFile: func(string) ([]byte, error) { return []byte("#!/bin/sh\n"), nil },
|
|
MkdirAll: func(string, os.FileMode) error { return nil },
|
|
NewCore: func(sessionID, agentName, cwd string) (*agent.Session, error) {
|
|
be := backend.NewNoop("stub", "m")
|
|
return agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: agentName,
|
|
CWD: cwd,
|
|
SessionID: sessionID,
|
|
NewBackend: func(name string) (backend.Backend, error) {
|
|
return backend.NewNoop(name, "default"), nil
|
|
},
|
|
}), nil
|
|
},
|
|
})
|
|
}
|
|
|
|
func testStoreRead(t *testing.T, s fs.FileTree, name string) []byte {
|
|
t.Helper()
|
|
e, err := s.Open(name)
|
|
if err != nil {
|
|
t.Fatalf("Open(%s): %v", name, err)
|
|
}
|
|
data, err := e.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read(%s): %v", name, err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func testStoreWrite(t *testing.T, s fs.FileTree, name string, data []byte) {
|
|
t.Helper()
|
|
e, err := s.Open(name)
|
|
if err != nil {
|
|
t.Fatalf("Open(%s): %v", name, err)
|
|
}
|
|
if err := e.Write(data); err != nil {
|
|
t.Fatalf("Write(%s): %v", name, err)
|
|
}
|
|
}
|
|
|
|
var _ = time.Second // suppress unused import
|
|
var _ = strings.Contains // suppress unused import
|
|
var _ = fmt.Sprintf // suppress unused import
|
|
var _ = os.Remove // suppress unused import
|
|
var _ = filepath.Join // suppress unused import
|
|
var _ sync.Mutex // suppress unused import
|
|
|
|
func newTestSessionFileStore(t *testing.T, sess *session.Session) *fs.Tree {
|
|
t.Helper()
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {},
|
|
func(id string) error { return nil },
|
|
func(data []byte) error { return nil },
|
|
func() {},
|
|
nil,
|
|
nil,
|
|
)
|
|
return sf
|
|
}
|
|
|
|
func newTestSessionFileStoreWith(t *testing.T, sess *session.Session, kill func(), rename func(string) error, save func([]byte) error) *fs.Tree {
|
|
t.Helper()
|
|
sink := testSink()
|
|
return session.NewSessionTree(sess, sink.NewLogger("test"), kill, rename, save, func() {}, nil, nil)
|
|
}
|
|
|
|
// ===== session.Session =====
|
|
|
|
func TestSessionAppendLog(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
|
|
sess.AppendLog([]byte("hello"))
|
|
sess.AppendLog(nil) // no-op
|
|
sess.AppendLog([]byte(" world"))
|
|
|
|
l, v := sess.LogInfo()
|
|
if l != 11 {
|
|
t.Errorf("ChatInfo length = %d; want 11", l)
|
|
}
|
|
if v != 2 {
|
|
t.Errorf("ChatInfo vers = %d; want 2", v)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerFileMode(t *testing.T) {
|
|
if m, ok := session.FileMode("new"); !ok || m != 0666 {
|
|
t.Errorf("session.FileMode(new) = %o, %v", m, ok)
|
|
}
|
|
if _, ok := session.FileMode("bogus"); ok {
|
|
t.Error("session.FileMode(bogus) should be false")
|
|
}
|
|
}
|
|
|
|
// ===== session.Manager =====
|
|
|
|
func TestSessionManagerReadableContract(t *testing.T) {
|
|
}
|
|
|
|
func TestSessionManagerGetIdx(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testSession("abc")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
|
|
data := testStoreRead(t, s.Tree(), "idx")
|
|
if !strings.Contains(string(data), "abc") {
|
|
t.Errorf("idx = %q; want to contain abc", data)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerGetScript(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
data := testStoreRead(t, s.Tree(), "ls")
|
|
if string(data) != "#!/bin/sh\n" {
|
|
t.Errorf("Read(ls) = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerStatSession(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
|
|
fi, err := s.Tree().Stat("s1")
|
|
if err != nil {
|
|
t.Fatalf("Stat(s1): %v", err)
|
|
}
|
|
if !fi.IsDir() {
|
|
t.Error("session stat should be dir")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerListIncludesSessions(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
|
|
entries, _ := s.Tree().List()
|
|
found := false
|
|
for _, e := range entries {
|
|
if e.Name() == "s1" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("List() missing session s1")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerWriteNotWritable(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
e, err := s.Tree().Open("idx")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if err := e.Write(nil); err == nil {
|
|
t.Error("Write(idx) should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateErrors(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
if err := s.Tree().Create("x"); err == nil {
|
|
t.Error("Create should always error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerDeleteAndKill(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testSession("s1")
|
|
s.AddSession(sess)
|
|
|
|
if err := s.Tree().Delete("s1"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if s.Session("s1") != nil {
|
|
t.Error("session should be gone after Delete")
|
|
}
|
|
// Verify core was closed by checking ctx is cancelled
|
|
if sess.Ctx.Err() == nil {
|
|
t.Error("session ctx should be cancelled after Delete")
|
|
}
|
|
if err := s.Tree().Delete("nope"); err == nil {
|
|
t.Error("Delete(nonexistent) should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerSession(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
if s.Session("nope") != nil {
|
|
t.Error("session.Session(nonexistent) should be nil")
|
|
}
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
if s.Session("s1") == nil {
|
|
t.Error("session.Session(s1) should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerInterruptAll(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testBlockingSession("s1")
|
|
defer sess.Cancel()
|
|
|
|
// Submit in background to make it "running"
|
|
go sess.Core.Submit(sess.Ctx, "hello")
|
|
time.Sleep(50 * time.Millisecond) // let it enter thinking state
|
|
s.AddSession(sess)
|
|
|
|
s.InterruptAll() // should not panic
|
|
}
|
|
|
|
func TestSessionManagerShutdown(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testSession("s1")
|
|
s.AddSession(sess)
|
|
|
|
s.Shutdown()
|
|
if s.Session("s1") != nil {
|
|
t.Error("session should be gone after Shutdown")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerRename(t *testing.T) {
|
|
sink := testSink()
|
|
s := session.NewManager(session.ManagerConfig{
|
|
Log: sink.NewLogger("test"),
|
|
Sink: sink,
|
|
ReadFile: func(string) ([]byte, error) { return nil, nil },
|
|
MkdirAll: func(string, os.FileMode) error { return nil },
|
|
})
|
|
sess := testSession("old")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
|
|
if err := s.Tree().Rename("old", "new"); err != nil {
|
|
t.Fatalf("Rename: %v", err)
|
|
}
|
|
if s.Session("old") != nil {
|
|
t.Error("old session should be gone")
|
|
}
|
|
if s.Session("new") == nil {
|
|
t.Error("new session should exist")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerRenameErrors(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
// nonexistent
|
|
if err := s.Tree().Rename("nope", "x"); err == nil {
|
|
t.Error("Rename(nonexistent) should error")
|
|
}
|
|
// duplicate
|
|
s.AddSession(testSession("a"))
|
|
s.AddSession(testSession("b"))
|
|
if err := s.Tree().Rename("a", "b"); err == nil {
|
|
t.Error("Rename to existing should error")
|
|
}
|
|
// running
|
|
sess := testBlockingSession("r")
|
|
go sess.Core.Submit(sess.Ctx, "hello")
|
|
time.Sleep(50 * time.Millisecond)
|
|
s.AddSession(sess)
|
|
if err := s.Tree().Rename("r", "r2"); err == nil {
|
|
t.Error("Rename while running should error")
|
|
}
|
|
sess.Cancel() // cleanup
|
|
}
|
|
|
|
// ===== fs.Tree =====
|
|
|
|
func TestSessionFileStoreReadableContract(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sink := testSink()
|
|
_ = session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
}
|
|
|
|
func TestSessionFileStoreList(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
|
|
entries, err := sf.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(entries) != len(session.FileList) {
|
|
t.Errorf("List() returned %d entries; want %d", len(entries), len(session.FileList))
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreStatChat(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sess.AppendLog([]byte("hello"))
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
|
|
fi, err := sf.Stat("chat")
|
|
if err != nil {
|
|
t.Fatalf("Stat(chat): %v", err)
|
|
}
|
|
if fi.Size() != 5 {
|
|
t.Errorf("Stat(chat).Size() = %d; want 5", fi.Size())
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreGetChat(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sess.AppendLog([]byte("hello"))
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
|
|
data := testStoreRead(t, sf, "chat")
|
|
if string(data) != "hello" {
|
|
t.Errorf("Read(chat) = %q; want hello", data)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreGetContent(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
|
|
for _, name := range []string{"cfg", "offset", "usage", "ctxsz", "models", "systemprompt"} {
|
|
if _, err := sf.Open(name); err != nil {
|
|
t.Errorf("Get(%q): %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStorePutCwd(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
|
|
// Create the directory first so SetCWD validates it
|
|
os.MkdirAll("/tmp/newpath", 0755)
|
|
defer os.Remove("/tmp/newpath")
|
|
testStoreWrite(t, sf, "cfg", []byte("cwd=/tmp/newpath"))
|
|
if sess.Core.CWD() != "/tmp/newpath" {
|
|
t.Errorf("cwd = %q; want /tmp/newpath", sess.Core.CWD())
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStorePutEmpty(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sink := testSink()
|
|
sf := session.NewSessionTree(sess, sink.NewLogger("test"),
|
|
func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil, nil)
|
|
|
|
// Empty write is a no-op
|
|
e, err := sf.Open("cfg")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if err := e.Write([]byte("")); err != nil {
|
|
t.Fatalf("Write(spec, empty): %v", err)
|
|
}
|
|
}
|
|
|
|
// ===== fs.Tree: content, writeFile, handleCtl, blockingRead, makePublish =====
|
|
|
|
func TestSessionFileStoreContentAllFields(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sess.ChatOffset = 5
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// individual metric files
|
|
for _, tc := range []struct {
|
|
name string
|
|
}{
|
|
{"usage"},
|
|
{"ctxsz"},
|
|
{"models"},
|
|
{"systemprompt"},
|
|
{"offset"},
|
|
} {
|
|
data := testStoreRead(t, sf, tc.name)
|
|
if len(data) == 0 && tc.name == "offset" {
|
|
t.Errorf("Read(%q) should not be empty", tc.name)
|
|
}
|
|
}
|
|
|
|
// cfg contains all config in KV form
|
|
spec := string(testStoreRead(t, sf, "cfg"))
|
|
for _, want := range []string{
|
|
"name=s1\n", "backend=stub\n", "model=m\n",
|
|
"agent=default\n", "cwd=/tmp\n",
|
|
} {
|
|
if !strings.Contains(spec, want) {
|
|
t.Errorf("spec missing %q; got:\n%s", want, spec)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreReadFifoOut(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sess.Core.Queue("queued-item")
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
data := testStoreRead(t, sf, "fifo.out")
|
|
if string(data) != "queued-item" {
|
|
t.Errorf("Read(fifo.out) = %q; want queued-item", data)
|
|
}
|
|
// Empty queue returns nil
|
|
data2 := testStoreRead(t, sf, "fifo.out")
|
|
if len(data2) != 0 {
|
|
t.Errorf("Read(fifo.out) empty queue = %q; want empty", data2)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreReadNotFound(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
if _, err := sf.Open("__bogus__"); err == nil {
|
|
t.Error("Open(bogus) should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWritePrompt(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// Writing to prompt triggers Submit. With noop backend it completes immediately.
|
|
testStoreWrite(t, sf, "prompt", []byte("hello agent"))
|
|
// Verify state is back to idle (Submit completed)
|
|
time.Sleep(50 * time.Millisecond)
|
|
if sess.Core.State() != "idle" {
|
|
t.Errorf("state = %q; want idle after submit", sess.Core.State())
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWriteFifoIn(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
testStoreWrite(t, sf, "fifo.in", []byte("inject this"))
|
|
item, ok := sess.Core.PopQueue()
|
|
if !ok || item != "inject this" {
|
|
t.Errorf("PopQueue() = %q, %v; want inject this, true", item, ok)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWriteChat(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
var saved []byte
|
|
sf := newTestSessionFileStoreWith(t, sess,
|
|
func() {}, func(string) error { return nil },
|
|
func(data []byte) error { saved = data; return nil })
|
|
|
|
testStoreWrite(t, sf, "chat", []byte("transcript data"))
|
|
if string(saved) != "transcript data" {
|
|
t.Errorf("saved = %q; want transcript data", saved)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWriteBackendModelAgent(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// Writing backend= to cfg triggers Submit with /backend command
|
|
// With real session + NewBackend func, it switches backend
|
|
testStoreWrite(t, sf, "cfg", []byte("backend=openai"))
|
|
time.Sleep(50 * time.Millisecond)
|
|
if sess.Core.BackendName() != "openai" {
|
|
t.Errorf("BackendName() = %q; want openai", sess.Core.BackendName())
|
|
}
|
|
|
|
testStoreWrite(t, sf, "cfg", []byte("model=gpt-4"))
|
|
time.Sleep(50 * time.Millisecond)
|
|
if sess.Core.ModelName() != "gpt-4" {
|
|
t.Errorf("ModelName() = %q; want gpt-4", sess.Core.ModelName())
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWriteBackendWhileRunning(t *testing.T) {
|
|
sess := testBlockingSession("s1")
|
|
defer sess.Cancel()
|
|
|
|
// Make session running
|
|
go sess.Core.Submit(sess.Ctx, "hello")
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
e, _ := sf.Open("cfg")
|
|
if err := e.Write([]byte("backend=openai")); err == nil {
|
|
t.Error("Write spec backend= while running should error")
|
|
}
|
|
e2, _ := sf.Open("cfg")
|
|
if err := e2.Write([]byte("model=gpt-4")); err == nil {
|
|
t.Error("Write spec model= while running should error")
|
|
}
|
|
e3, _ := sf.Open("cfg")
|
|
if err := e3.Write([]byte("agent=coder")); err == nil {
|
|
t.Error("Write spec agent= while running should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWriteParams(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
testStoreWrite(t, sf, "cfg", []byte("maxTokens=2048"))
|
|
p := sess.Core.GenerationParams()
|
|
if p.MaxTokens != 2048 {
|
|
t.Errorf("MaxTokens = %d; want 2048", p.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreWriteParamsWhileRunning(t *testing.T) {
|
|
sess := testBlockingSession("s1")
|
|
defer sess.Cancel()
|
|
|
|
go sess.Core.Submit(sess.Ctx, "hello")
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
e, _ := sf.Open("cfg")
|
|
if err := e.Write([]byte("maxTokens=2048")); err == nil {
|
|
t.Error("Write spec maxTokens= while running should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreHandleCtl(t *testing.T) {
|
|
sess := testBlockingSession("s1")
|
|
defer sess.Cancel()
|
|
|
|
// Make session running for interrupt test
|
|
go sess.Core.Submit(sess.Ctx, "hello")
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// stop — interrupts the running session
|
|
testStoreWrite(t, sf, "ctl", []byte("stop"))
|
|
time.Sleep(50 * time.Millisecond)
|
|
if sess.Core.IsRunning() {
|
|
t.Error("ctl stop should interrupt (session should not be running)")
|
|
}
|
|
|
|
// kill
|
|
var killed bool
|
|
sf2 := newTestSessionFileStoreWith(t, sess,
|
|
func() { killed = true },
|
|
func(string) error { return nil },
|
|
func([]byte) error { return nil })
|
|
testStoreWrite(t, sf2, "ctl", []byte("kill"))
|
|
if !killed {
|
|
t.Error("ctl kill should call kill callback")
|
|
}
|
|
|
|
// rn (rename)
|
|
var renamed string
|
|
sf3 := newTestSessionFileStoreWith(t, sess,
|
|
func() {},
|
|
func(id string) error { renamed = id; return nil },
|
|
func([]byte) error { return nil })
|
|
testStoreWrite(t, sf3, "ctl", []byte("rn newname"))
|
|
if renamed != "newname" {
|
|
t.Errorf("renamed = %q; want newname", renamed)
|
|
}
|
|
|
|
// save
|
|
sess.AppendLog([]byte("log data"))
|
|
var saved []byte
|
|
sf4 := newTestSessionFileStoreWith(t, sess,
|
|
func() {},
|
|
func(string) error { return nil },
|
|
func(data []byte) error { saved = data; return nil })
|
|
testStoreWrite(t, sf4, "ctl", []byte("save"))
|
|
if !strings.Contains(string(saved), "log data") {
|
|
t.Errorf("saved = %q; want to contain log data", saved)
|
|
}
|
|
|
|
// slash commands forwarded to Submit
|
|
sess5 := testSession("s5")
|
|
defer sess5.Cancel()
|
|
sf5 := newTestSessionFileStore(t, sess5)
|
|
for _, cmd := range []string{"compact", "clear", "help", "history", "tools", "skills"} {
|
|
testStoreWrite(t, sf5, "ctl", []byte(cmd))
|
|
}
|
|
// These are slash commands that run via Submit. With noop backend, they complete.
|
|
// Just verify no panic/error occurred (already verified by testStoreWrite not failing).
|
|
}
|
|
|
|
func TestSessionFileStoreHandleCtlErrors(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// unknown command
|
|
e, _ := sf.Open("ctl")
|
|
if err := e.Write([]byte("boguscmd")); err == nil {
|
|
t.Error("unknown ctl command should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreBlockingRead(t *testing.T) {
|
|
sess := testBlockingSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// statewait uses BlockingRead, not Read. It blocks until state changes.
|
|
done := make(chan string, 1)
|
|
go func() {
|
|
e, err := sf.Open("statewait")
|
|
if err != nil {
|
|
done <- ""
|
|
return
|
|
}
|
|
// Use BlockingRead which invokes the Wait function
|
|
data, _, _ := e.BlockingRead(sess.Ctx, "")
|
|
done <- string(data)
|
|
}()
|
|
|
|
// Give the goroutine time to enter the blocking read
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Submit triggers state change: idle → thinking
|
|
go sess.Core.Submit(sess.Ctx, "trigger")
|
|
|
|
select {
|
|
case v := <-done:
|
|
if v == "" {
|
|
t.Error("statewait returned empty")
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Error("statewait timed out")
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreBlockingReadCancel(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// Reading statewait with short timeout should return when ctx expires
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e, err := sf.Open("statewait")
|
|
if err != nil {
|
|
close(done)
|
|
return
|
|
}
|
|
e.Read() // blocks until session state changes or ctx done
|
|
close(done)
|
|
}()
|
|
|
|
// Cancel session to unblock
|
|
time.Sleep(50 * time.Millisecond)
|
|
sess.Cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
// good
|
|
case <-time.After(2 * time.Second):
|
|
t.Error("statewait did not unblock after cancel")
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreBlockingReadNotWaitFile(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// Non-wait files should return immediately
|
|
data := testStoreRead(t, sf, "state")
|
|
if string(data) != "idle\n" {
|
|
t.Errorf("Read(state) = %q; want idle", data)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreBlockingReadAllWaitFiles(t *testing.T) {
|
|
// Verify that statewait is the only blocking file; state returns immediately
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
for _, name := range []string{"state", "cfg", "usage", "ctxsz"} {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
testStoreRead(t, sf, name)
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
// good, returned immediately
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Errorf("Read(%q) blocked unexpectedly", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreBlockingReadIdleTimeout(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// statewait on an idle session should block until something changes
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e, _ := sf.Open("statewait")
|
|
e.Read()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// This is fine if something triggered it
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Expected — still blocking. Cancel to cleanup.
|
|
sess.Cancel()
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreMakePublish(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
core := newContentCore("s1", "hello world")
|
|
sess := session.NewSession("s1", core, ctx, cancel)
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// Submit triggers the content backend
|
|
testStoreWrite(t, sf, "prompt", []byte("say hello"))
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Chat log should contain the assistant response
|
|
sess.Mu().RLock()
|
|
log := string(sess.Log())
|
|
sess.Mu().RUnlock()
|
|
if !strings.Contains(log, "hello world") {
|
|
t.Errorf("log = %q; want to contain 'hello world'", log)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreMakePublishMultipleEvents(t *testing.T) {
|
|
be := backend.NewNoop("stub", "m")
|
|
be.ChatStreamFunc = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
ch := make(chan backend.StreamEvent, 3)
|
|
ch <- backend.StreamEvent{Content: "thinking..."}
|
|
ch <- backend.StreamEvent{Content: " done"}
|
|
ch <- backend.StreamEvent{Done: true, StopReason: "end_turn"}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
core := agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: "default",
|
|
CWD: "/tmp",
|
|
SessionID: "s1",
|
|
})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
sess := session.NewSession("s1", core, ctx, cancel)
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
testStoreWrite(t, sf, "prompt", []byte("multi"))
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
sess.Mu().RLock()
|
|
log := string(sess.Log())
|
|
sess.Mu().RUnlock()
|
|
if !strings.Contains(log, "thinking...") || !strings.Contains(log, " done") {
|
|
t.Errorf("log = %q; want to contain streamed content", log)
|
|
}
|
|
}
|
|
|
|
func TestSessionFileStoreEntryStat(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// All known files should be stat-able
|
|
entries, err := sf.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
for _, ent := range entries {
|
|
fi, err := sf.Stat(ent.Name())
|
|
if err != nil {
|
|
t.Errorf("Stat(%q): %v", ent.Name(), err)
|
|
continue
|
|
}
|
|
if fi.Name() != ent.Name() {
|
|
t.Errorf("Stat(%q).Name() = %q", ent.Name(), fi.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionReactFile(t *testing.T) {
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
sf := newTestSessionFileStore(t, sess)
|
|
|
|
// React requires a prior response. Just verify the file is openable.
|
|
e, err := sf.Open("react")
|
|
if err != nil {
|
|
t.Fatalf("Open(react): %v", err)
|
|
}
|
|
// Writing a reaction without a prior response may error — that's fine.
|
|
// We're testing the file layer exists, not the business logic.
|
|
_ = e.Write([]byte("thumbsup"))
|
|
}
|
|
|
|
func TestSessionManagerOpenStore(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
sess := testSession("s1")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
|
|
// Access a session file through the manager
|
|
e, err := s.Tree().Open("s1/cfg")
|
|
if err != nil {
|
|
t.Fatalf("Open(s1/cfg): %v", err)
|
|
}
|
|
data, err := e.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read(s1/cfg): %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "name=s1") {
|
|
t.Errorf("cfg = %q; want to contain name=s1", data)
|
|
}
|
|
}
|
|
|
|
func TestLoadAgentConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
data := []byte(`{"prompt":"test prompt","maxTokens":1024}`)
|
|
os.WriteFile(filepath.Join(dir, "test.json"), data, 0644)
|
|
|
|
cfg := session.LoadAgentConfig(dir, "test", nil)
|
|
if cfg == nil {
|
|
t.Fatal("LoadAgentConfig returned nil")
|
|
}
|
|
if cfg.MaxTokens != 1024 {
|
|
t.Errorf("MaxTokens = %d; want 1024", cfg.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestFormatEvent(t *testing.T) {
|
|
tests := []struct {
|
|
ev agent.Event
|
|
want string
|
|
}{
|
|
{agent.Event{Role: "user", Content: "hello"}, "[user]\nhello\n"},
|
|
{agent.Event{Role: "assistant", Content: "hi"}, "hi"},
|
|
{agent.Event{Role: "call", Name: "fn", Content: "args"}, "[call:fn]\nargs\n"},
|
|
{agent.Event{Role: "tool", Name: "fn", Content: "result"}, "[tool:fn]\nresult\n"},
|
|
{agent.Event{Role: "info", Content: "msg\n"}, "[info]\nmsg\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
got := string(session.FormatEvent(tc.ev))
|
|
if got != tc.want {
|
|
t.Errorf("FormatEvent(%v) = %q; want %q", tc.ev.Role, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSyntheticFileInfo(t *testing.T) {
|
|
fi := &fs.SyntheticFileInfo{Name_: "test", Mode_: 0644, Size_: 42}
|
|
if fi.Name() != "test" {
|
|
t.Errorf("Name() = %q", fi.Name())
|
|
}
|
|
if fi.Size() != 42 {
|
|
t.Errorf("Size() = %d", fi.Size())
|
|
}
|
|
if fi.Mode() != 0644 {
|
|
t.Errorf("Mode() = %o", fi.Mode())
|
|
}
|
|
}
|
|
|
|
func TestFileEntryDirEntry(t *testing.T) {
|
|
e := fs.FileEntry("test", 0644)
|
|
if e.Name() != "test" {
|
|
t.Errorf("Name() = %q", e.Name())
|
|
}
|
|
if e.IsDir() {
|
|
t.Error("FileEntry should not be dir")
|
|
}
|
|
d := fs.DirEntry("dir", 0755)
|
|
if d.Name() != "dir" {
|
|
t.Errorf("Name() = %q", d.Name())
|
|
}
|
|
if !d.IsDir() {
|
|
t.Error("DirEntry should be dir")
|
|
}
|
|
}
|
|
|
|
func TestFormatParamsRoundTrip(t *testing.T) {
|
|
temp := 0.7
|
|
topP := 0.9
|
|
p := backend.GenerationParams{
|
|
MaxTokens: 4096,
|
|
Temperature: &temp,
|
|
TopP: &topP,
|
|
}
|
|
text := session.FormatParams(p)
|
|
if !strings.Contains(text, "maxTokens=4096") {
|
|
t.Errorf("FormatParams missing maxTokens; got: %s", text)
|
|
}
|
|
if !strings.Contains(text, "temperature=0.7") {
|
|
t.Errorf("FormatParams missing temperature; got: %s", text)
|
|
}
|
|
|
|
parsed, err := session.ParseParams(text, backend.GenerationParams{})
|
|
if err != nil {
|
|
t.Fatalf("ParseParams: %v", err)
|
|
}
|
|
if parsed.MaxTokens != 4096 {
|
|
t.Errorf("parsed.MaxTokens = %d; want 4096", parsed.MaxTokens)
|
|
}
|
|
if parsed.Temperature == nil || *parsed.Temperature != 0.7 {
|
|
t.Errorf("parsed.Temperature = %v; want 0.7", parsed.Temperature)
|
|
}
|
|
}
|
|
|
|
func TestFormatParamsNilOptionals(t *testing.T) {
|
|
p := backend.GenerationParams{MaxTokens: 100}
|
|
text := session.FormatParams(p)
|
|
// Real FormatParams lists all fields; nil optionals appear as empty values
|
|
if !strings.Contains(text, "maxTokens=100") {
|
|
t.Errorf("FormatParams missing maxTokens=100; got: %s", text)
|
|
}
|
|
// Temperature is nil, so it should appear as "temperature=" (empty value)
|
|
if !strings.Contains(text, "temperature=\n") {
|
|
t.Errorf("nil temperature should appear as empty; got: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestParseParamsClearWithEmpty(t *testing.T) {
|
|
temp := 0.7
|
|
base := backend.GenerationParams{Temperature: &temp, MaxTokens: 100}
|
|
parsed, err := session.ParseParams("temperature=\nmaxTokens=200", base)
|
|
if err != nil {
|
|
t.Fatalf("ParseParams: %v", err)
|
|
}
|
|
if parsed.Temperature != nil {
|
|
t.Error("empty temperature= should clear it")
|
|
}
|
|
if parsed.MaxTokens != 200 {
|
|
t.Errorf("MaxTokens = %d; want 200", parsed.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestParseParamsErrors(t *testing.T) {
|
|
_, err := session.ParseParams("maxTokens=notanumber", backend.GenerationParams{})
|
|
if err == nil {
|
|
t.Error("non-numeric maxTokens should error")
|
|
}
|
|
_, err = session.ParseParams("temperature=notafloat", backend.GenerationParams{})
|
|
if err == nil {
|
|
t.Error("non-numeric temperature should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionInterrupt(t *testing.T) {
|
|
sess := testBlockingSession("s1")
|
|
defer sess.Cancel()
|
|
|
|
go sess.Core.Submit(sess.Ctx, "hello")
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
sess.Interrupt()
|
|
time.Sleep(50 * time.Millisecond)
|
|
if sess.Core.IsRunning() {
|
|
t.Error("session should not be running after interrupt")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionViaWrite(t *testing.T) {
|
|
s := newTestSessionManagerWithCore(t)
|
|
e, err := s.Tree().Open("new")
|
|
if err != nil {
|
|
t.Fatalf("Open(new): %v", err)
|
|
}
|
|
if err := e.Write([]byte("cwd=/home/test")); err != nil {
|
|
t.Fatalf("Write(new): %v", err)
|
|
}
|
|
// Should have created a session
|
|
entries, _ := s.Tree().List()
|
|
found := false
|
|
for _, ent := range entries {
|
|
if ent.IsDir() {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("no session directory found after Write(new)")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionNoCwd(t *testing.T) {
|
|
s := newTestSessionManagerWithCore(t)
|
|
e, err := s.Tree().Open("new")
|
|
if err != nil {
|
|
t.Fatalf("Open(new): %v", err)
|
|
}
|
|
// Empty write (no cwd) should error
|
|
if err := e.Write([]byte("")); err == nil {
|
|
t.Error("Write(new) with no cwd should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionDuplicate(t *testing.T) {
|
|
s := newTestSessionManagerWithCore(t)
|
|
sess := testSession("dup")
|
|
defer sess.Cancel()
|
|
s.AddSession(sess)
|
|
|
|
e, _ := s.Tree().Open("new")
|
|
if err := e.Write([]byte("name=dup\ncwd=/tmp")); err == nil {
|
|
t.Error("creating duplicate session should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionBadOption(t *testing.T) {
|
|
s := newTestSessionManagerWithCore(t)
|
|
e, _ := s.Tree().Open("new")
|
|
// Malformed line (no =)
|
|
if err := e.Write([]byte("badline\ncwd=/tmp")); err == nil {
|
|
t.Error("malformed option should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionUnknownKey(t *testing.T) {
|
|
s := newTestSessionManagerWithCore(t)
|
|
e, _ := s.Tree().Open("new")
|
|
if err := e.Write([]byte("unknownkey=val\ncwd=/tmp")); err == nil {
|
|
t.Error("unknown key should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionEnvExpansion(t *testing.T) {
|
|
t.Setenv("TEST_CWD", "/expanded/path")
|
|
sink := testSink()
|
|
s := session.NewManager(session.ManagerConfig{
|
|
Log: sink.NewLogger("test"),
|
|
Sink: sink,
|
|
ReadFile: func(string) ([]byte, error) { return []byte("#!/bin/sh\n"), nil },
|
|
MkdirAll: func(string, os.FileMode) error { return nil },
|
|
NewCore: func(sessionID, agentName, cwd string) (*agent.Session, error) {
|
|
be := backend.NewNoop("stub", "m")
|
|
return agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: agentName,
|
|
CWD: cwd,
|
|
SessionID: sessionID,
|
|
}), nil
|
|
},
|
|
})
|
|
e, _ := s.Tree().Open("new")
|
|
if err := e.Write([]byte("cwd=$TEST_CWD")); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerCreateSessionTildeExpansion(t *testing.T) {
|
|
sink := testSink()
|
|
s := session.NewManager(session.ManagerConfig{
|
|
Log: sink.NewLogger("test"),
|
|
Sink: sink,
|
|
ReadFile: func(string) ([]byte, error) { return []byte("#!/bin/sh\n"), nil },
|
|
MkdirAll: func(string, os.FileMode) error { return nil },
|
|
NewCore: func(sessionID, agentName, cwd string) (*agent.Session, error) {
|
|
be := backend.NewNoop("stub", "m")
|
|
return agent.New(agent.Config{
|
|
Backend: be,
|
|
AgentName: agentName,
|
|
CWD: cwd,
|
|
SessionID: sessionID,
|
|
}), nil
|
|
},
|
|
})
|
|
e, _ := s.Tree().Open("new")
|
|
if err := e.Write([]byte("cwd=~/projects")); err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerOpenEntryNotFound(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
if _, err := s.Tree().Open("nonexistent/cfg"); err == nil {
|
|
t.Error("Open(nonexistent/cfg) should error")
|
|
}
|
|
}
|
|
|
|
func TestSessionManagerOpenStoreNotFound(t *testing.T) {
|
|
s := newTestSessionManager(t)
|
|
if _, err := s.Tree().Open("nosess/chat"); err == nil {
|
|
t.Error("Open for nonexistent session should error")
|
|
}
|
|
}
|