store: refactor to storeConfig architecture
- Replace FlatDir type with storeConfig (unexported) + NewFlatDir returning Store - Refactor SkillStore to skillState + storeConfig, NewSkillStore returns Store - SessionStore/BatchStore embed *storeConfig for Store interface methods - Add Runnable interface (RunnableID, Cancel, Interrupt, AppendLog, LogInfo) - Session and batchJob satisfy Runnable - Add Terminator-style Shutdown/InterruptAll on SessionStore and BatchStore - NewSession constructor replaces exported field access - Rename AppendChat->AppendLog, ChatInfo->LogInfo on Session
This commit is contained in:
parent
500f7fe96b
commit
24e4209a95
|
|
@ -50,6 +50,27 @@ func (job *batchJob) appendLog(data []byte) {
|
|||
job.mu.Unlock()
|
||||
}
|
||||
|
||||
func (job *batchJob) RunnableID() string { return job.id }
|
||||
|
||||
func (job *batchJob) Cancel() {
|
||||
job.mu.RLock()
|
||||
cancel := job.cancel
|
||||
job.mu.RUnlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (job *batchJob) Interrupt() { job.Cancel() }
|
||||
|
||||
func (job *batchJob) AppendLog(data []byte) { job.appendLog(data) }
|
||||
|
||||
func (job *batchJob) LogInfo() (int, uint32) {
|
||||
job.mu.RLock()
|
||||
defer job.mu.RUnlock()
|
||||
return len(job.log), job.logVers
|
||||
}
|
||||
|
||||
// batchSpec is the parsed result of a b/new write.
|
||||
type batchSpec struct {
|
||||
name string
|
||||
|
|
@ -70,19 +91,44 @@ type BatchStoreConfig struct {
|
|||
}
|
||||
|
||||
// BatchStore implements Store for batch job management.
|
||||
// Entries are job IDs (directories) plus the synthetic files "new" and "idx".
|
||||
// Put("new", data) creates jobs; Delete(id) cancels and removes one.
|
||||
type BatchStore struct {
|
||||
*storeConfig
|
||||
cfg BatchStoreConfig
|
||||
mu sync.RWMutex
|
||||
jobs map[string]*batchJob
|
||||
}
|
||||
|
||||
func NewBatchStore(cfg BatchStoreConfig) *BatchStore {
|
||||
return &BatchStore{cfg: cfg, jobs: make(map[string]*batchJob)}
|
||||
bs := &BatchStore{cfg: cfg, jobs: make(map[string]*batchJob)}
|
||||
bs.storeConfig = &storeConfig{
|
||||
StatFn: bs.stat,
|
||||
ListFn: bs.list,
|
||||
GetFn: bs.get,
|
||||
PutFn: bs.put,
|
||||
DeleteFn: bs.del,
|
||||
CreateFn: func(string) error { return fmt.Errorf("create not supported for batch jobs") },
|
||||
RenameFn: func(string, string) error { return fmt.Errorf("rename not supported for batch jobs") },
|
||||
}
|
||||
return bs
|
||||
}
|
||||
|
||||
func (s *BatchStore) List() ([]os.DirEntry, error) {
|
||||
// AddJob inserts a completed job into the store. This allows callers to
|
||||
// populate the store without going through the full agent execution path.
|
||||
func (s *BatchStore) AddJob(id, state, result, spec string) {
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
s.mu.Lock()
|
||||
s.jobs[id] = &batchJob{
|
||||
id: id,
|
||||
spec: spec,
|
||||
state: state,
|
||||
result: result,
|
||||
done: done,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *BatchStore) list() ([]os.DirEntry, error) {
|
||||
entries := []os.DirEntry{
|
||||
FileEntry("new", 0666),
|
||||
FileEntry("idx", 0444),
|
||||
|
|
@ -95,7 +141,7 @@ func (s *BatchStore) List() ([]os.DirEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *BatchStore) Stat(name string) (os.FileInfo, error) {
|
||||
func (s *BatchStore) stat(name string) (os.FileInfo, error) {
|
||||
switch name {
|
||||
case "new":
|
||||
return &SyntheticFileInfo{Name_: "new", Mode_: 0666, Size_: int64(len(batchNewTemplate))}, nil
|
||||
|
|
@ -111,7 +157,7 @@ func (s *BatchStore) Stat(name string) (os.FileInfo, error) {
|
|||
return nil, fmt.Errorf("%s: not found", name)
|
||||
}
|
||||
|
||||
func (s *BatchStore) Get(name string) ([]byte, error) {
|
||||
func (s *BatchStore) get(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "new":
|
||||
return []byte(batchNewTemplate), nil
|
||||
|
|
@ -121,14 +167,14 @@ func (s *BatchStore) Get(name string) ([]byte, error) {
|
|||
return nil, fmt.Errorf("%s: not a readable file", name)
|
||||
}
|
||||
|
||||
func (s *BatchStore) Put(name string, data []byte) error {
|
||||
func (s *BatchStore) put(name string, data []byte) error {
|
||||
if name != "new" {
|
||||
return fmt.Errorf("%s: not writable", name)
|
||||
}
|
||||
return s.handleNewBatch(strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
func (s *BatchStore) Delete(name string) error {
|
||||
func (s *BatchStore) del(name string) error {
|
||||
s.mu.Lock()
|
||||
job, ok := s.jobs[name]
|
||||
if ok {
|
||||
|
|
@ -138,24 +184,11 @@ func (s *BatchStore) Delete(name string) error {
|
|||
if !ok {
|
||||
return fmt.Errorf("batch job not found: %s", name)
|
||||
}
|
||||
job.mu.RLock()
|
||||
cancel := job.cancel
|
||||
job.mu.RUnlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
job.Cancel()
|
||||
s.cfg.Log.Info("removed batch job %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BatchStore) Create(name string) error {
|
||||
return fmt.Errorf("create not supported for batch jobs")
|
||||
}
|
||||
|
||||
func (s *BatchStore) Rename(oldName, newName string) error {
|
||||
return fmt.Errorf("rename not supported for batch jobs")
|
||||
}
|
||||
|
||||
// Shutdown cancels all running jobs.
|
||||
func (s *BatchStore) Shutdown() {
|
||||
s.mu.Lock()
|
||||
|
|
@ -169,6 +202,15 @@ func (s *BatchStore) Shutdown() {
|
|||
}
|
||||
}
|
||||
|
||||
// InterruptAll cancels all running jobs without removing them.
|
||||
func (s *BatchStore) InterruptAll() {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, job := range s.jobs {
|
||||
job.Interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// Job looks up a job by ID (nil if not found).
|
||||
func (s *BatchStore) Job(id string) *batchJob {
|
||||
s.mu.RLock()
|
||||
|
|
@ -290,7 +332,7 @@ func (s *BatchStore) executeJob(ctx context.Context, job *batchJob) (string, err
|
|||
be.SetModel(modelName)
|
||||
}
|
||||
|
||||
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName)
|
||||
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil)
|
||||
|
||||
newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{
|
||||
"execute": execute.Decl(cwd),
|
||||
|
|
@ -521,9 +563,12 @@ func (js *BatchJobStore) LogInfo() (length int, vers uint32) {
|
|||
|
||||
// LoadAgentConfig resolves and loads the config for a named agent.
|
||||
// Returns nil (not an error) if the config file does not exist;
|
||||
// BuildAgentEnv handles nil configs.
|
||||
func LoadAgentConfig(agentsDir, name string) *config.Config {
|
||||
f, err := os.Open(agentsDir + "/" + name + ".json")
|
||||
// BuildAgentEnv handles nil configs. open defaults to os.Open if nil.
|
||||
func LoadAgentConfig(agentsDir, name string, open func(string) (*os.File, error)) *config.Config {
|
||||
if open == nil {
|
||||
open = os.Open
|
||||
}
|
||||
f, err := open(agentsDir + "/" + name + ".json")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,31 +18,45 @@ import (
|
|||
// Session holds all state for one agent session.
|
||||
type Session struct {
|
||||
mu sync.RWMutex
|
||||
ID string
|
||||
id string
|
||||
Core agent.Core
|
||||
Ctx context.Context
|
||||
Cancel context.CancelFunc
|
||||
ChatLog []byte
|
||||
ChatVers uint32
|
||||
cancel context.CancelFunc
|
||||
log []byte
|
||||
logVers uint32
|
||||
ChatOffset int
|
||||
}
|
||||
|
||||
// AppendChat appends data to the session's chat log and bumps the version.
|
||||
func (sess *Session) AppendChat(data []byte) {
|
||||
func NewSession(id string, core agent.Core, ctx context.Context, cancel context.CancelFunc) *Session {
|
||||
return &Session{id: id, Core: core, Ctx: ctx, cancel: cancel}
|
||||
}
|
||||
|
||||
func (sess *Session) RunnableID() string { return sess.id }
|
||||
|
||||
func (sess *Session) Cancel() {
|
||||
sess.cancel()
|
||||
}
|
||||
|
||||
func (sess *Session) Interrupt() {
|
||||
sess.Core.Interrupt(agent.ErrInterrupted)
|
||||
}
|
||||
|
||||
// AppendLog appends data to the session's log and bumps the version.
|
||||
func (sess *Session) AppendLog(data []byte) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
sess.mu.Lock()
|
||||
sess.ChatLog = append(sess.ChatLog, data...)
|
||||
sess.ChatVers++
|
||||
sess.log = append(sess.log, data...)
|
||||
sess.logVers++
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
|
||||
// ChatInfo returns the current chat log length and version atomically.
|
||||
func (sess *Session) ChatInfo() (length int, vers uint32) {
|
||||
// LogInfo returns the current log length and version atomically.
|
||||
func (sess *Session) LogInfo() (length int, vers uint32) {
|
||||
sess.mu.RLock()
|
||||
defer sess.mu.RUnlock()
|
||||
return len(sess.ChatLog), sess.ChatVers
|
||||
return len(sess.log), sess.logVers
|
||||
}
|
||||
|
||||
// sessionStoreFiles maps fixed file entries in s/ to their permissions.
|
||||
|
|
@ -69,6 +83,8 @@ type SessionStoreConfig struct {
|
|||
SessionsDir string
|
||||
Log *olog.Logger
|
||||
Sink *olog.Sink
|
||||
ReadFile func(string) ([]byte, error)
|
||||
MkdirAll func(string, os.FileMode) error
|
||||
// OnRename is called after a session is renamed in the map,
|
||||
// for protocol-level fixups (e.g. fid path rewriting).
|
||||
OnRename func(oldID, newID string)
|
||||
|
|
@ -76,16 +92,40 @@ type SessionStoreConfig struct {
|
|||
|
||||
// SessionStore implements Store for session management.
|
||||
type SessionStore struct {
|
||||
*storeConfig
|
||||
cfg SessionStoreConfig
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
func NewSessionStore(cfg SessionStoreConfig) *SessionStore {
|
||||
return &SessionStore{cfg: cfg, sessions: make(map[string]*Session)}
|
||||
if cfg.ReadFile == nil {
|
||||
cfg.ReadFile = os.ReadFile
|
||||
}
|
||||
if cfg.MkdirAll == nil {
|
||||
cfg.MkdirAll = os.MkdirAll
|
||||
}
|
||||
ss := &SessionStore{cfg: cfg, sessions: make(map[string]*Session)}
|
||||
ss.storeConfig = &storeConfig{
|
||||
StatFn: ss.stat,
|
||||
ListFn: ss.list,
|
||||
GetFn: ss.get,
|
||||
PutFn: ss.put,
|
||||
DeleteFn: ss.del,
|
||||
CreateFn: func(string) error { return fmt.Errorf("create not supported for sessions") },
|
||||
RenameFn: ss.renameSession,
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
func (s *SessionStore) List() ([]os.DirEntry, error) {
|
||||
// AddSession inserts a pre-built session into the store.
|
||||
func (s *SessionStore) AddSession(sess *Session) {
|
||||
s.mu.Lock()
|
||||
s.sessions[sess.RunnableID()] = sess
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SessionStore) list() ([]os.DirEntry, error) {
|
||||
entries := make([]os.DirEntry, 0, len(sessionStoreOrder))
|
||||
for _, name := range sessionStoreOrder {
|
||||
entries = append(entries, FileEntry(name, sessionStoreFiles[name]))
|
||||
|
|
@ -98,7 +138,7 @@ func (s *SessionStore) List() ([]os.DirEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Stat(name string) (os.FileInfo, error) {
|
||||
func (s *SessionStore) stat(name string) (os.FileInfo, error) {
|
||||
if mode, ok := sessionStoreFiles[name]; ok {
|
||||
return &SyntheticFileInfo{Name_: name, Mode_: mode}, nil
|
||||
}
|
||||
|
|
@ -111,7 +151,7 @@ func (s *SessionStore) Stat(name string) (os.FileInfo, error) {
|
|||
return nil, fmt.Errorf("%s: not found", name)
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(name string) ([]byte, error) {
|
||||
func (s *SessionStore) get(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "new":
|
||||
return []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\n"), nil
|
||||
|
|
@ -119,13 +159,13 @@ func (s *SessionStore) Get(name string) ([]byte, error) {
|
|||
return s.index(), nil
|
||||
default:
|
||||
if _, ok := sessionStoreFiles[name]; ok {
|
||||
return os.ReadFile(paths.CfgDir() + "/scripts/s/" + name)
|
||||
return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: not a readable file", name)
|
||||
}
|
||||
|
||||
func (s *SessionStore) Put(name string, data []byte) error {
|
||||
func (s *SessionStore) put(name string, data []byte) error {
|
||||
if name != "new" {
|
||||
return fmt.Errorf("%s: not writable", name)
|
||||
}
|
||||
|
|
@ -133,7 +173,7 @@ func (s *SessionStore) Put(name string, data []byte) error {
|
|||
return s.createSession(args)
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(name string) error {
|
||||
func (s *SessionStore) del(name string) error {
|
||||
s.mu.RLock()
|
||||
_, ok := s.sessions[name]
|
||||
s.mu.RUnlock()
|
||||
|
|
@ -144,14 +184,6 @@ func (s *SessionStore) Delete(name string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) Create(name string) error {
|
||||
return fmt.Errorf("create not supported for sessions")
|
||||
}
|
||||
|
||||
func (s *SessionStore) Rename(oldName, newName string) error {
|
||||
return s.renameSession(oldName, newName)
|
||||
}
|
||||
|
||||
// Session returns the session for the given ID, or nil.
|
||||
func (s *SessionStore) Session(id string) *Session {
|
||||
s.mu.RLock()
|
||||
|
|
@ -263,11 +295,11 @@ func (s *SessionStore) createSession(args []string) error {
|
|||
be.SetModel(modelOverride)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(s.cfg.SessionsDir, 0700); err != nil {
|
||||
if err := s.cfg.MkdirAll(s.cfg.SessionsDir, 0700); err != nil {
|
||||
return fmt.Errorf("sessions dir: %w", err)
|
||||
}
|
||||
|
||||
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName)
|
||||
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil)
|
||||
|
||||
newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{
|
||||
"execute": execute.Decl(cwd),
|
||||
|
|
@ -299,12 +331,7 @@ func (s *SessionStore) createSession(args []string) error {
|
|||
NewDispatcher: newDisp,
|
||||
Log: s.cfg.Sink.NewLogger("core"),
|
||||
})
|
||||
sess := &Session{
|
||||
ID: sessID,
|
||||
Core: core,
|
||||
Ctx: ctx,
|
||||
Cancel: cancel,
|
||||
}
|
||||
sess := NewSession(sessID, core, ctx, cancel)
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessID] = sess
|
||||
|
|
@ -334,7 +361,7 @@ func (s *SessionStore) renameSession(oldID, newID string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
sess.ID = newID
|
||||
sess.id = newID
|
||||
s.sessions[newID] = sess
|
||||
delete(s.sessions, oldID)
|
||||
|
||||
|
|
@ -342,7 +369,7 @@ func (s *SessionStore) renameSession(oldID, newID string) error {
|
|||
s.cfg.OnRename(oldID, newID)
|
||||
}
|
||||
|
||||
sess.AppendChat([]byte(fmt.Sprintf("(session renamed: %s -> %s)\n", oldID, newID)))
|
||||
sess.AppendLog([]byte(fmt.Sprintf("(session renamed: %s -> %s)\n", oldID, newID)))
|
||||
s.cfg.Log.Info("renamed session %s -> %s", oldID, newID)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ func (s *SessionFileStore) Stat(name string) (os.FileInfo, error) {
|
|||
switch name {
|
||||
case "chat":
|
||||
s.sess.mu.RLock()
|
||||
size = int64(len(s.sess.ChatLog))
|
||||
size = int64(len(s.sess.log))
|
||||
s.sess.mu.RUnlock()
|
||||
case "statewait", "usagewait", "ctxszwait", "cwdwait":
|
||||
// Blocking reads; size is unknown until resolved.
|
||||
|
|
@ -85,8 +85,8 @@ func (s *SessionFileStore) Get(name string) ([]byte, error) {
|
|||
switch name {
|
||||
case "chat":
|
||||
s.sess.mu.RLock()
|
||||
data := make([]byte, len(s.sess.ChatLog))
|
||||
copy(data, s.sess.ChatLog)
|
||||
data := make([]byte, len(s.sess.log))
|
||||
copy(data, s.sess.log)
|
||||
s.sess.mu.RUnlock()
|
||||
return data, nil
|
||||
case "offset":
|
||||
|
|
@ -314,14 +314,14 @@ func (s *SessionFileStore) makePublish() func(agent.Event) {
|
|||
assistantStarted = false
|
||||
case "assistant":
|
||||
if !assistantStarted {
|
||||
s.sess.AppendChat([]byte("assistant: "))
|
||||
s.sess.AppendLog([]byte("assistant: "))
|
||||
assistantStarted = true
|
||||
}
|
||||
}
|
||||
s.sess.AppendChat(FormatEvent(ev))
|
||||
s.sess.AppendLog(FormatEvent(ev))
|
||||
if ev.Role == "user" {
|
||||
s.sess.mu.Lock()
|
||||
s.sess.ChatOffset = len(s.sess.ChatLog)
|
||||
s.sess.ChatOffset = len(s.sess.log)
|
||||
s.sess.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
|
@ -345,8 +345,8 @@ func (s *SessionFileStore) handleCtl(input string) error {
|
|||
}
|
||||
case "save":
|
||||
s.sess.mu.RLock()
|
||||
data := make([]byte, len(s.sess.ChatLog))
|
||||
copy(data, s.sess.ChatLog)
|
||||
data := make([]byte, len(s.sess.log))
|
||||
copy(data, s.sess.log)
|
||||
s.sess.mu.RUnlock()
|
||||
return s.saveTranscript(data)
|
||||
case "compact", "clear", "backend", "model", "models",
|
||||
|
|
|
|||
|
|
@ -11,18 +11,82 @@ import (
|
|||
"ollie/pkg/skills"
|
||||
)
|
||||
|
||||
// SkillStore is a Store backed by the skills package.
|
||||
// Skills are stored as directories containing a SKILL.md file, but are
|
||||
// exposed as flat "{name}.md" entries. The synthetic "idx" entry
|
||||
// lists all skills with their descriptions.
|
||||
type SkillStore struct{}
|
||||
|
||||
func NewSkillStore() *SkillStore {
|
||||
return &SkillStore{}
|
||||
// SkillStoreConfig holds optional dependencies for NewSkillStore.
|
||||
// Nil functions default to their os package equivalents.
|
||||
type SkillStoreConfig struct {
|
||||
Dirs []string
|
||||
ReadDir func(string) ([]os.DirEntry, error)
|
||||
Open func(string) (*os.File, error)
|
||||
ReadFile func(string) ([]byte, error)
|
||||
WriteFile func(string, []byte, os.FileMode) error
|
||||
MkdirAll func(string, os.FileMode) error
|
||||
RemoveAll func(string) error
|
||||
Rename func(string, string) error
|
||||
}
|
||||
|
||||
type skillState struct {
|
||||
dirs []string
|
||||
readDir func(string) ([]os.DirEntry, error)
|
||||
open func(string) (*os.File, error)
|
||||
readFile func(string) ([]byte, error)
|
||||
writeFile func(string, []byte, os.FileMode) error
|
||||
mkdirAll func(string, os.FileMode) error
|
||||
removeAll func(string) error
|
||||
rename func(string, string) error
|
||||
}
|
||||
|
||||
func NewSkillStore() Store {
|
||||
return NewSkillStoreWith(SkillStoreConfig{})
|
||||
}
|
||||
|
||||
func NewSkillStoreWith(cfg SkillStoreConfig) Store {
|
||||
if cfg.Dirs == nil {
|
||||
cfg.Dirs = skillDirs()
|
||||
}
|
||||
if cfg.ReadDir == nil {
|
||||
cfg.ReadDir = os.ReadDir
|
||||
}
|
||||
if cfg.Open == nil {
|
||||
cfg.Open = os.Open
|
||||
}
|
||||
if cfg.ReadFile == nil {
|
||||
cfg.ReadFile = os.ReadFile
|
||||
}
|
||||
if cfg.WriteFile == nil {
|
||||
cfg.WriteFile = os.WriteFile
|
||||
}
|
||||
if cfg.MkdirAll == nil {
|
||||
cfg.MkdirAll = os.MkdirAll
|
||||
}
|
||||
if cfg.RemoveAll == nil {
|
||||
cfg.RemoveAll = os.RemoveAll
|
||||
}
|
||||
if cfg.Rename == nil {
|
||||
cfg.Rename = os.Rename
|
||||
}
|
||||
|
||||
ss := &skillState{
|
||||
dirs: cfg.Dirs,
|
||||
readDir: cfg.ReadDir,
|
||||
open: cfg.Open,
|
||||
readFile: cfg.ReadFile,
|
||||
writeFile: cfg.WriteFile,
|
||||
mkdirAll: cfg.MkdirAll,
|
||||
removeAll: cfg.RemoveAll,
|
||||
rename: cfg.Rename,
|
||||
}
|
||||
|
||||
return &storeConfig{
|
||||
StatFn: ss.stat,
|
||||
ListFn: ss.list,
|
||||
GetFn: ss.get,
|
||||
PutFn: ss.put,
|
||||
DeleteFn: ss.del,
|
||||
CreateFn: ss.create,
|
||||
RenameFn: ss.ren,
|
||||
}
|
||||
}
|
||||
|
||||
// skillDirs returns the skill directories from OLLIE_SKILLS_PATH,
|
||||
// falling back to the default skills directory.
|
||||
func skillDirs() []string {
|
||||
if env := os.Getenv("OLLIE_SKILLS_PATH"); env != "" {
|
||||
return strings.Split(env, ":")
|
||||
|
|
@ -30,13 +94,11 @@ func skillDirs() []string {
|
|||
return []string{paths.CfgDir() + "/skills"}
|
||||
}
|
||||
|
||||
// listSkills scans all skill directories and returns deduplicated metadata.
|
||||
// First occurrence by directory order wins on name collision.
|
||||
func listSkills() []skills.Meta {
|
||||
func (ss *skillState) listSkills() []skills.Meta {
|
||||
seen := make(map[string]bool)
|
||||
var result []skills.Meta
|
||||
for _, dir := range skillDirs() {
|
||||
entries, err := os.ReadDir(dir)
|
||||
for _, dir := range ss.dirs {
|
||||
entries, err := ss.readDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -45,7 +107,7 @@ func listSkills() []skills.Meta {
|
|||
continue
|
||||
}
|
||||
skillDir := filepath.Join(dir, e.Name())
|
||||
f, err := os.Open(filepath.Join(skillDir, "SKILL.md"))
|
||||
f, err := ss.open(filepath.Join(skillDir, "SKILL.md"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -62,95 +124,94 @@ func listSkills() []skills.Meta {
|
|||
return result
|
||||
}
|
||||
|
||||
// readSkill returns the SKILL.md content for the named skill.
|
||||
func readSkill(name string) ([]byte, error) {
|
||||
for _, m := range listSkills() {
|
||||
func (ss *skillState) readSkill(name string) ([]byte, error) {
|
||||
for _, m := range ss.listSkills() {
|
||||
if m.Name == name {
|
||||
return os.ReadFile(filepath.Join(m.Dir, "SKILL.md"))
|
||||
return ss.readFile(filepath.Join(m.Dir, "SKILL.md"))
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *SkillStore) Stat(name string) (os.FileInfo, error) {
|
||||
func (ss *skillState) stat(name string) (os.FileInfo, error) {
|
||||
if name == "idx" {
|
||||
return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil
|
||||
}
|
||||
skillName := strings.TrimSuffix(name, ".md")
|
||||
if _, err := readSkill(skillName); err != nil {
|
||||
if _, err := ss.readSkill(skillName); err != nil {
|
||||
return nil, fmt.Errorf("%s: not found", name)
|
||||
}
|
||||
return &SyntheticFileInfo{Name_: name, Mode_: 0666}, nil
|
||||
}
|
||||
|
||||
func (s *SkillStore) List() ([]os.DirEntry, error) {
|
||||
func (ss *skillState) list() ([]os.DirEntry, error) {
|
||||
result := []os.DirEntry{FileEntry("idx", 0444)}
|
||||
for _, m := range listSkills() {
|
||||
for _, m := range ss.listSkills() {
|
||||
result = append(result, FileEntry(m.Name+".md", 0666))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SkillStore) Get(name string) ([]byte, error) {
|
||||
func (ss *skillState) get(name string) ([]byte, error) {
|
||||
if name == "idx" {
|
||||
return s.index()
|
||||
return ss.index()
|
||||
}
|
||||
skillName := strings.TrimSuffix(name, ".md")
|
||||
return readSkill(skillName)
|
||||
return ss.readSkill(skillName)
|
||||
}
|
||||
|
||||
func (s *SkillStore) Put(name string, data []byte) error {
|
||||
func (ss *skillState) put(name string, data []byte) error {
|
||||
skillName := strings.TrimSuffix(name, ".md")
|
||||
dir := ""
|
||||
for _, m := range listSkills() {
|
||||
for _, m := range ss.listSkills() {
|
||||
if m.Name == skillName {
|
||||
dir = m.Dir
|
||||
break
|
||||
}
|
||||
}
|
||||
if dir == "" {
|
||||
dir = filepath.Join(skillDirs()[0], skillName)
|
||||
dir = filepath.Join(ss.dirs[0], skillName)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
if err := ss.mkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "SKILL.md"), data, 0644)
|
||||
return ss.writeFile(filepath.Join(dir, "SKILL.md"), data, 0644)
|
||||
}
|
||||
|
||||
func (s *SkillStore) Delete(name string) error {
|
||||
func (ss *skillState) del(name string) error {
|
||||
skillName := strings.TrimSuffix(name, ".md")
|
||||
for _, m := range listSkills() {
|
||||
for _, m := range ss.listSkills() {
|
||||
if m.Name == skillName {
|
||||
return os.RemoveAll(m.Dir)
|
||||
return ss.removeAll(m.Dir)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("skill not found: %s", skillName)
|
||||
}
|
||||
|
||||
func (s *SkillStore) Rename(oldName, newName string) error {
|
||||
func (ss *skillState) ren(oldName, newName string) error {
|
||||
oldSkill := strings.TrimSuffix(oldName, ".md")
|
||||
newSkill := strings.TrimSuffix(newName, ".md")
|
||||
for _, m := range listSkills() {
|
||||
for _, m := range ss.listSkills() {
|
||||
if m.Name == oldSkill {
|
||||
newDir := filepath.Join(filepath.Dir(m.Dir), newSkill)
|
||||
return os.Rename(m.Dir, newDir)
|
||||
return ss.rename(m.Dir, newDir)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("skill not found: %s", oldSkill)
|
||||
}
|
||||
|
||||
func (s *SkillStore) Create(name string) error {
|
||||
func (ss *skillState) create(name string) error {
|
||||
skillName := strings.TrimSuffix(name, ".md")
|
||||
dir := filepath.Join(skillDirs()[0], skillName)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
dir := filepath.Join(ss.dirs[0], skillName)
|
||||
if err := ss.mkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "SKILL.md"), nil, 0644)
|
||||
return ss.writeFile(filepath.Join(dir, "SKILL.md"), nil, 0644)
|
||||
}
|
||||
|
||||
func (s *SkillStore) index() ([]byte, error) {
|
||||
func (ss *skillState) index() ([]byte, error) {
|
||||
var sb strings.Builder
|
||||
for _, m := range listSkills() {
|
||||
for _, m := range ss.listSkills() {
|
||||
fmt.Fprintf(&sb, "## %s\n", m.Name)
|
||||
fmt.Fprintf(&sb, "description: %s\n", m.Description)
|
||||
sb.WriteString("\n")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@ type ReadableStore interface {
|
|||
Get(name string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Runnable is a running agent that can be observed and controlled.
|
||||
type Runnable interface {
|
||||
RunnableID() string
|
||||
Cancel()
|
||||
Interrupt()
|
||||
AppendLog([]byte)
|
||||
LogInfo() (length int, vers uint32)
|
||||
}
|
||||
|
||||
// WritableStore supports writing blobs by name.
|
||||
type WritableStore interface {
|
||||
Put(name string, data []byte) error
|
||||
|
|
@ -39,6 +48,51 @@ type Store interface {
|
|||
Rename(oldName, newName string) error
|
||||
}
|
||||
|
||||
// storeConfig implements the store interfaces via function pointers.
|
||||
type storeConfig struct {
|
||||
StatFn func(string) (os.FileInfo, error)
|
||||
ListFn func() ([]os.DirEntry, error)
|
||||
GetFn func(string) ([]byte, error)
|
||||
PutFn func(string, []byte) error
|
||||
DeleteFn func(string) error
|
||||
CreateFn func(string) error
|
||||
RenameFn func(string, string) error
|
||||
}
|
||||
|
||||
func (s *storeConfig) Stat(name string) (os.FileInfo, error) { return s.StatFn(name) }
|
||||
func (s *storeConfig) List() ([]os.DirEntry, error) { return s.ListFn() }
|
||||
func (s *storeConfig) Get(name string) ([]byte, error) { return s.GetFn(name) }
|
||||
func (s *storeConfig) Put(name string, data []byte) error { return s.PutFn(name, data) }
|
||||
func (s *storeConfig) Delete(name string) error { return s.DeleteFn(name) }
|
||||
func (s *storeConfig) Create(name string) error { return s.CreateFn(name) }
|
||||
func (s *storeConfig) Rename(old, new string) error { return s.RenameFn(old, new) }
|
||||
|
||||
// NewFlatDir returns a Store backed by a directory on the local filesystem.
|
||||
func NewFlatDir(dir string, perm os.FileMode) Store {
|
||||
join := func(name string) string { return filepath.Join(dir, name) }
|
||||
ensureDir := func() error { return os.MkdirAll(dir, 0755) }
|
||||
|
||||
return &storeConfig{
|
||||
StatFn: func(name string) (os.FileInfo, error) { return os.Stat(join(name)) },
|
||||
ListFn: func() ([]os.DirEntry, error) { return os.ReadDir(dir) },
|
||||
GetFn: func(name string) ([]byte, error) { return os.ReadFile(join(name)) },
|
||||
PutFn: func(name string, data []byte) error {
|
||||
if err := ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(join(name), data, perm)
|
||||
},
|
||||
DeleteFn: func(name string) error { return os.Remove(join(name)) },
|
||||
CreateFn: func(name string) error {
|
||||
if err := ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(join(name), nil, perm)
|
||||
},
|
||||
RenameFn: func(old, new string) error { return os.Rename(join(old), join(new)) },
|
||||
}
|
||||
}
|
||||
|
||||
// SyntheticFileInfo implements os.FileInfo for entries with no backing file.
|
||||
type SyntheticFileInfo struct {
|
||||
Name_ string
|
||||
|
|
@ -82,49 +136,3 @@ func FileEntry(name string, mode os.FileMode) os.DirEntry {
|
|||
func DirEntry(name string, mode os.FileMode) os.DirEntry {
|
||||
return &SyntheticEntry{Name_: name, Mode_: mode, IsDir_: true}
|
||||
}
|
||||
|
||||
// FlatDir implements Store backed by a directory on the local filesystem.
|
||||
type FlatDir struct {
|
||||
dir string
|
||||
perm os.FileMode
|
||||
}
|
||||
|
||||
// NewFlatDir returns a FlatDir rooted at dir.
|
||||
// perm is applied to newly created or written files.
|
||||
func NewFlatDir(dir string, perm os.FileMode) *FlatDir {
|
||||
return &FlatDir{dir: dir, perm: perm}
|
||||
}
|
||||
|
||||
func (s *FlatDir) Stat(name string) (os.FileInfo, error) {
|
||||
return os.Stat(filepath.Join(s.dir, name))
|
||||
}
|
||||
|
||||
func (s *FlatDir) List() ([]os.DirEntry, error) {
|
||||
return os.ReadDir(s.dir)
|
||||
}
|
||||
|
||||
func (s *FlatDir) Get(name string) ([]byte, error) {
|
||||
return os.ReadFile(filepath.Join(s.dir, name))
|
||||
}
|
||||
|
||||
func (s *FlatDir) Put(name string, data []byte) error {
|
||||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.dir, name), data, s.perm)
|
||||
}
|
||||
|
||||
func (s *FlatDir) Delete(name string) error {
|
||||
return os.Remove(filepath.Join(s.dir, name))
|
||||
}
|
||||
|
||||
func (s *FlatDir) Create(name string) error {
|
||||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.dir, name), nil, s.perm)
|
||||
}
|
||||
|
||||
func (s *FlatDir) Rename(oldName, newName string) error {
|
||||
return os.Rename(filepath.Join(s.dir, oldName), filepath.Join(s.dir, newName))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,940 @@
|
|||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ollie/pkg/agent"
|
||||
"ollie/pkg/backend"
|
||||
olog "ollie/pkg/log"
|
||||
"ollie/pkg/store"
|
||||
)
|
||||
|
||||
// --- stub agent.Core ---
|
||||
|
||||
type stubCore struct {
|
||||
state string
|
||||
running bool
|
||||
backend string
|
||||
model string
|
||||
agentName string
|
||||
cwd string
|
||||
usage string
|
||||
ctxsz string
|
||||
models string
|
||||
sysprompt string
|
||||
reply string
|
||||
params backend.GenerationParams
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *stubCore) Submit(context.Context, string, agent.EventHandler) {}
|
||||
func (c *stubCore) Interrupt(error) bool { return c.running }
|
||||
func (c *stubCore) Inject(string) {}
|
||||
func (c *stubCore) Queue(string) {}
|
||||
func (c *stubCore) PopQueue() (string, bool) { return "", false }
|
||||
func (c *stubCore) IsRunning() bool { return c.running }
|
||||
func (c *stubCore) State() string { return c.state }
|
||||
func (c *stubCore) Reply() string { return c.reply }
|
||||
func (c *stubCore) AgentName() string { return c.agentName }
|
||||
func (c *stubCore) BackendName() string { return c.backend }
|
||||
func (c *stubCore) ModelName() string { return c.model }
|
||||
func (c *stubCore) CtxSz() string { return c.ctxsz }
|
||||
func (c *stubCore) Usage() string { return c.usage }
|
||||
func (c *stubCore) ListModels() string { return c.models }
|
||||
func (c *stubCore) CWD() string { return c.cwd }
|
||||
func (c *stubCore) SetCWD(dir string) error { c.cwd = dir; return nil }
|
||||
func (c *stubCore) SetSessionID(string) error { return nil }
|
||||
func (c *stubCore) SystemPrompt() string { return c.sysprompt }
|
||||
func (c *stubCore) GenerationParams() backend.GenerationParams { return c.params }
|
||||
func (c *stubCore) SetGenerationParams(p backend.GenerationParams) error { c.params = p; return nil }
|
||||
func (c *stubCore) SetEnv(string, string) {}
|
||||
func (c *stubCore) WaitChange(ctx context.Context, _, _ string) (string, bool) { <-ctx.Done(); return "", false }
|
||||
func (c *stubCore) Close() { c.closed = true }
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func testSink() *olog.Sink {
|
||||
return olog.NewSink(io.Discard, io.Discard, olog.LevelError)
|
||||
}
|
||||
|
||||
func testSession(id string) *store.Session {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return store.NewSession(id, &stubCore{state: "idle", backend: "stub", model: "m", agentName: "default", cwd: "/tmp"}, ctx, cancel)
|
||||
}
|
||||
|
||||
func seedSkill(t *testing.T, base, name string) {
|
||||
t.Helper()
|
||||
d := filepath.Join(base, name)
|
||||
os.MkdirAll(d, 0755)
|
||||
os.WriteFile(filepath.Join(d, "SKILL.md"), []byte("---\ndescription: test\n---\n"), 0644)
|
||||
}
|
||||
|
||||
func newSessionStore(t *testing.T) *store.SessionStore {
|
||||
t.Helper()
|
||||
sink := testSink()
|
||||
return store.NewSessionStore(store.SessionStoreConfig{
|
||||
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 newBatchStore(t *testing.T) *store.BatchStore {
|
||||
t.Helper()
|
||||
sink := testSink()
|
||||
return store.NewBatchStore(store.BatchStoreConfig{
|
||||
Log: sink.NewLogger("test"),
|
||||
Sink: sink,
|
||||
})
|
||||
}
|
||||
|
||||
// --- contract checks ---
|
||||
|
||||
func checkReadableContract(t *testing.T, s store.ReadableStore, name string) {
|
||||
t.Helper()
|
||||
fi, err := s.Stat(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(%q): %v", name, err)
|
||||
}
|
||||
if fi.Name() != name {
|
||||
t.Errorf("Stat(%q).Name() = %q", name, fi.Name())
|
||||
}
|
||||
if _, err := s.Stat("__nonexistent__"); err == nil {
|
||||
t.Error("Stat(nonexistent) should error")
|
||||
}
|
||||
entries, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Name() == name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("List() missing %q", name)
|
||||
}
|
||||
if _, err := s.Get(name); err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
if _, err := s.Get("__nonexistent__"); err == nil {
|
||||
t.Error("Get(nonexistent) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func checkReadWriteContract(t *testing.T, s store.ReadWriteStore, name string) {
|
||||
t.Helper()
|
||||
want := []byte("hello")
|
||||
if err := s.Put(name, want); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
got, err := s.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Put: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("Get = %q; want %q", got, want)
|
||||
}
|
||||
want2 := []byte("world")
|
||||
if err := s.Put(name, want2); err != nil {
|
||||
t.Fatalf("Put overwrite: %v", err)
|
||||
}
|
||||
got2, err := s.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after overwrite: %v", err)
|
||||
}
|
||||
if string(got2) != string(want2) {
|
||||
t.Errorf("Get after overwrite = %q; want %q", got2, want2)
|
||||
}
|
||||
checkReadableContract(t, s, name)
|
||||
}
|
||||
|
||||
func checkBlobStoreContract(t *testing.T, s store.BlobStore, name string) {
|
||||
t.Helper()
|
||||
if err := s.Create(name); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := s.Stat(name); err != nil {
|
||||
t.Fatalf("Stat after Create: %v", err)
|
||||
}
|
||||
if err := s.Delete(name); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := s.Stat(name); err == nil {
|
||||
t.Error("Stat after Delete should error")
|
||||
}
|
||||
if err := s.Delete("__nonexistent__"); err == nil {
|
||||
t.Error("Delete(nonexistent) should error")
|
||||
}
|
||||
checkReadWriteContract(t, s, name)
|
||||
}
|
||||
|
||||
func checkStoreContract(t *testing.T, s store.Store, name string) {
|
||||
t.Helper()
|
||||
renamed := name + "-renamed"
|
||||
if err := s.Create(name); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := s.Rename(name, renamed); err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
if _, err := s.Stat(name); err == nil {
|
||||
t.Error("Stat(old) should error after Rename")
|
||||
}
|
||||
if _, err := s.Stat(renamed); err != nil {
|
||||
t.Errorf("Stat(new) after Rename: %v", err)
|
||||
}
|
||||
s.Delete(renamed) //nolint:errcheck
|
||||
checkBlobStoreContract(t, s, name)
|
||||
}
|
||||
|
||||
// ===== FlatDir =====
|
||||
|
||||
func TestFlatDirContract(t *testing.T) {
|
||||
checkStoreContract(t, store.NewFlatDir(t.TempDir(), 0644), "test-file")
|
||||
}
|
||||
|
||||
func TestFlatDirCreateMkdirError(t *testing.T) {
|
||||
fd := store.NewFlatDir("/nonexistent/path", 0644)
|
||||
if err := fd.Create("f"); err == nil {
|
||||
t.Error("Create should fail when dir doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatDirPutMkdirError(t *testing.T) {
|
||||
fd := store.NewFlatDir("/nonexistent/path", 0644)
|
||||
if err := fd.Put("f", []byte("x")); err == nil {
|
||||
t.Error("Put should fail when dir doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SkillStore =====
|
||||
|
||||
func TestSkillStoreContract(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("OLLIE_SKILLS_PATH", dir)
|
||||
seedSkill(t, dir, "test-skill")
|
||||
|
||||
s := store.NewSkillStore()
|
||||
|
||||
// Rename (needs seeded skill with valid front matter)
|
||||
seedSkill(t, dir, "rename-src")
|
||||
if err := s.Rename("rename-src.md", "rename-dst.md"); err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
if _, err := s.Stat("rename-src.md"); err == nil {
|
||||
t.Error("Stat(old) should error after Rename")
|
||||
}
|
||||
if _, err := s.Stat("rename-dst.md"); err != nil {
|
||||
t.Errorf("Stat(new) after Rename: %v", err)
|
||||
}
|
||||
s.Delete("rename-dst.md") //nolint:errcheck
|
||||
|
||||
// Delete
|
||||
seedSkill(t, dir, "del-test")
|
||||
if err := s.Delete("del-test.md"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := s.Stat("del-test.md"); err == nil {
|
||||
t.Error("Stat after Delete should error")
|
||||
}
|
||||
if err := s.Delete("__nonexistent__.md"); err == nil {
|
||||
t.Error("Delete(nonexistent) should error")
|
||||
}
|
||||
|
||||
// Put/Get round-trip (needs valid front matter)
|
||||
content := []byte("---\ndescription: rw test\n---\nbody\n")
|
||||
if err := s.Put("rw-skill.md", content); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
got, err := s.Get("rw-skill.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Put: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Errorf("Get = %q; want %q", got, content)
|
||||
}
|
||||
|
||||
// Readable
|
||||
checkReadableContract(t, s, "test-skill.md")
|
||||
|
||||
// idx
|
||||
idx, err := s.Get("idx")
|
||||
if err != nil {
|
||||
t.Fatalf("Get(idx): %v", err)
|
||||
}
|
||||
if len(idx) == 0 {
|
||||
t.Error("idx should be non-empty with seeded skills")
|
||||
}
|
||||
|
||||
// Rename nonexistent
|
||||
if err := s.Rename("__nope__.md", "x.md"); err == nil {
|
||||
t.Error("Rename(nonexistent) should error")
|
||||
}
|
||||
|
||||
// Create calls through injected mkdirAll + writeFile
|
||||
var createdDir, createdFile string
|
||||
s2 := store.NewSkillStoreWith(store.SkillStoreConfig{
|
||||
Dirs: []string{dir},
|
||||
MkdirAll: func(p string, _ os.FileMode) error { createdDir = p; return nil },
|
||||
WriteFile: func(p string, _ []byte, _ os.FileMode) error { createdFile = p; return nil },
|
||||
})
|
||||
if err := s2.Create("newskill.md"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(createdDir, "newskill") {
|
||||
t.Errorf("mkdirAll path = %q; want suffix newskill", createdDir)
|
||||
}
|
||||
if !strings.HasSuffix(createdFile, "SKILL.md") {
|
||||
t.Errorf("writeFile path = %q; want suffix SKILL.md", createdFile)
|
||||
}
|
||||
|
||||
// Create mkdirAll error
|
||||
s3 := store.NewSkillStoreWith(store.SkillStoreConfig{
|
||||
Dirs: []string{dir},
|
||||
MkdirAll: func(string, os.FileMode) error { return fmt.Errorf("denied") },
|
||||
})
|
||||
if err := s3.Create("x.md"); err == nil {
|
||||
t.Error("Create should fail when mkdirAll errors")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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 TestSessionStoreFileMode(t *testing.T) {
|
||||
if m, ok := store.SessionStoreFileMode("new"); !ok || m != 0666 {
|
||||
t.Errorf("SessionStoreFileMode(new) = %o, %v", m, ok)
|
||||
}
|
||||
if _, ok := store.SessionStoreFileMode("bogus"); ok {
|
||||
t.Error("SessionStoreFileMode(bogus) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SessionStore =====
|
||||
|
||||
func TestSessionStoreReadableContract(t *testing.T) {
|
||||
checkReadableContract(t, newSessionStore(t), "new")
|
||||
}
|
||||
|
||||
func TestSessionStoreGetIdx(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("abc")
|
||||
defer sess.Cancel()
|
||||
s.AddSession(sess)
|
||||
|
||||
data, err := s.Get("idx")
|
||||
if err != nil {
|
||||
t.Fatalf("Get(idx): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "abc") {
|
||||
t.Errorf("idx = %q; want to contain abc", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreGetScript(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
data, err := s.Get("ls")
|
||||
if err != nil {
|
||||
t.Fatalf("Get(ls): %v", err)
|
||||
}
|
||||
if string(data) != "#!/bin/sh\n" {
|
||||
t.Errorf("Get(ls) = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreStatSession(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
s.AddSession(sess)
|
||||
|
||||
fi, err := s.Stat("s1")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(s1): %v", err)
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
t.Error("session stat should be dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreListIncludesSessions(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
s.AddSession(sess)
|
||||
|
||||
entries, _ := s.List()
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Name() == "s1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("List() missing session s1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStorePutNotWritable(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
if err := s.Put("idx", nil); err == nil {
|
||||
t.Error("Put(idx) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreCreateErrors(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
if err := s.Create("x"); err == nil {
|
||||
t.Error("Create should always error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreDeleteAndKill(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("s1")
|
||||
s.AddSession(sess)
|
||||
|
||||
if err := s.Delete("s1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if s.Session("s1") != nil {
|
||||
t.Error("session should be gone after Delete")
|
||||
}
|
||||
core := sess.Core.(*stubCore)
|
||||
if !core.closed {
|
||||
t.Error("core should be closed after Delete")
|
||||
}
|
||||
if err := s.Delete("nope"); err == nil {
|
||||
t.Error("Delete(nonexistent) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreSession(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
if s.Session("nope") != nil {
|
||||
t.Error("Session(nonexistent) should be nil")
|
||||
}
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
s.AddSession(sess)
|
||||
if s.Session("s1") == nil {
|
||||
t.Error("Session(s1) should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreInterruptAll(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sess.Core.(*stubCore).running = true
|
||||
s.AddSession(sess)
|
||||
|
||||
s.InterruptAll() // should not panic
|
||||
}
|
||||
|
||||
func TestSessionStoreShutdown(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("s1")
|
||||
s.AddSession(sess)
|
||||
|
||||
s.Shutdown()
|
||||
if s.Session("s1") != nil {
|
||||
t.Error("session should be gone after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStoreRename(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
sess := testSession("old")
|
||||
defer sess.Cancel()
|
||||
s.AddSession(sess)
|
||||
|
||||
if err := s.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 TestSessionStoreRenameErrors(t *testing.T) {
|
||||
s := newSessionStore(t)
|
||||
// nonexistent
|
||||
if err := s.Rename("nope", "x"); err == nil {
|
||||
t.Error("Rename(nonexistent) should error")
|
||||
}
|
||||
// duplicate
|
||||
s.AddSession(testSession("a"))
|
||||
s.AddSession(testSession("b"))
|
||||
if err := s.Rename("a", "b"); err == nil {
|
||||
t.Error("Rename to existing should error")
|
||||
}
|
||||
// running
|
||||
sess := testSession("r")
|
||||
sess.Core.(*stubCore).running = true
|
||||
s.AddSession(sess)
|
||||
if err := s.Rename("r", "r2"); err == nil {
|
||||
t.Error("Rename while running should error")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SessionFileStore =====
|
||||
|
||||
func TestSessionFileStoreReadableContract(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
checkReadableContract(t, sf, "state")
|
||||
}
|
||||
|
||||
func TestSessionFileStoreList(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
|
||||
entries, err := sf.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != len(store.SessionFileList) {
|
||||
t.Errorf("List() returned %d entries; want %d", len(entries), len(store.SessionFileList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFileStoreStatChat(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sess.AppendLog([]byte("hello"))
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return 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 := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
|
||||
data, err := sf.Get("chat")
|
||||
if err != nil {
|
||||
t.Fatalf("Get(chat): %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("Get(chat) = %q; want hello", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFileStoreGetContent(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
|
||||
for _, name := range []string{"backend", "agent", "model", "state", "cwd", "offset", "params"} {
|
||||
if _, err := sf.Get(name); err != nil {
|
||||
t.Errorf("Get(%q): %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFileStorePutCwd(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
|
||||
if err := sf.Put("cwd", []byte("/new/path")); err != nil {
|
||||
t.Fatalf("Put(cwd): %v", err)
|
||||
}
|
||||
core := sess.Core.(*stubCore)
|
||||
if core.cwd != "/new/path" {
|
||||
t.Errorf("cwd = %q; want /new/path", core.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFileStorePutEmpty(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
|
||||
// Empty write is a no-op
|
||||
if err := sf.Put("cwd", []byte("")); err != nil {
|
||||
t.Fatalf("Put(cwd, empty): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFileStoreCurrentWaitValue(t *testing.T) {
|
||||
sess := testSession("s1")
|
||||
defer sess.Cancel()
|
||||
sink := testSink()
|
||||
sf := store.NewSessionFileStore(sess, sink.NewLogger("test"),
|
||||
func() {}, func(string) error { return nil }, func([]byte) error { return nil })
|
||||
|
||||
if v := sf.CurrentWaitValue("statewait"); v != "idle" {
|
||||
t.Errorf("CurrentWaitValue(statewait) = %q; want idle", v)
|
||||
}
|
||||
if v := sf.CurrentWaitValue("cwdwait"); v != "/tmp" {
|
||||
t.Errorf("CurrentWaitValue(cwdwait) = %q; want /tmp", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== FormatParams / ParseParams =====
|
||||
|
||||
func TestFormatParseParamsRoundTrip(t *testing.T) {
|
||||
temp := 0.7
|
||||
p := backend.GenerationParams{MaxTokens: 1024, Temperature: &temp}
|
||||
s := store.FormatParams(p)
|
||||
got, err := store.ParseParams(s, backend.GenerationParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParams: %v", err)
|
||||
}
|
||||
if got.MaxTokens != 1024 {
|
||||
t.Errorf("MaxTokens = %d; want 1024", got.MaxTokens)
|
||||
}
|
||||
if got.Temperature == nil || *got.Temperature != 0.7 {
|
||||
t.Errorf("Temperature = %v; want 0.7", got.Temperature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParamsErrors(t *testing.T) {
|
||||
if _, err := store.ParseParams("maxTokens=abc", backend.GenerationParams{}); err == nil {
|
||||
t.Error("expected error for invalid maxTokens")
|
||||
}
|
||||
if _, err := store.ParseParams("temperature=abc", backend.GenerationParams{}); err == nil {
|
||||
t.Error("expected error for invalid temperature")
|
||||
}
|
||||
if _, err := store.ParseParams("frequencyPenalty=abc", backend.GenerationParams{}); err == nil {
|
||||
t.Error("expected error for invalid frequencyPenalty")
|
||||
}
|
||||
if _, err := store.ParseParams("presencePenalty=abc", backend.GenerationParams{}); err == nil {
|
||||
t.Error("expected error for invalid presencePenalty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParamsClearOptional(t *testing.T) {
|
||||
temp := 0.5
|
||||
p := backend.GenerationParams{Temperature: &temp}
|
||||
got, err := store.ParseParams("temperature=\nmaxTokens=", p)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParams: %v", err)
|
||||
}
|
||||
if got.Temperature != nil {
|
||||
t.Error("Temperature should be nil after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== BatchStore =====
|
||||
|
||||
func TestBatchStoreReadableContract(t *testing.T) {
|
||||
checkReadableContract(t, newBatchStore(t), "new")
|
||||
}
|
||||
|
||||
func TestBatchStoreStatJob(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "result", "spec")
|
||||
|
||||
fi, err := s.Stat("j1")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(j1): %v", err)
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
t.Error("job stat should be dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreListIncludesJobs(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "result", "spec")
|
||||
|
||||
entries, _ := s.List()
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Name() == "j1" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("List() missing job j1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreGetIdx(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "result", "spec")
|
||||
|
||||
data, err := s.Get("idx")
|
||||
if err != nil {
|
||||
t.Fatalf("Get(idx): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "j1") {
|
||||
t.Errorf("idx = %q; want to contain j1", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStorePutNotWritable(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
if err := s.Put("idx", nil); err == nil {
|
||||
t.Error("Put(idx) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreCreateErrors(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
if err := s.Create("x"); err == nil {
|
||||
t.Error("Create should always error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreRenameErrors(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
if err := s.Rename("a", "b"); err == nil {
|
||||
t.Error("Rename should always error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreDelete(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "result", "spec")
|
||||
|
||||
if err := s.Delete("j1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if s.Job("j1") != nil {
|
||||
t.Error("job should be gone after Delete")
|
||||
}
|
||||
if err := s.Delete("nope"); err == nil {
|
||||
t.Error("Delete(nonexistent) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreJob(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
if s.Job("nope") != nil {
|
||||
t.Error("Job(nonexistent) should be nil")
|
||||
}
|
||||
s.AddJob("j1", "done", "result", "spec")
|
||||
if s.Job("j1") == nil {
|
||||
t.Error("Job(j1) should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStoreShutdown(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "result", "spec")
|
||||
s.Shutdown()
|
||||
if s.Job("j1") != nil {
|
||||
t.Error("job should be gone after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== BatchJobStore =====
|
||||
|
||||
func TestBatchJobStoreReadableContract(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "the result", "the spec")
|
||||
js, ok := s.JobStore("j1")
|
||||
if !ok {
|
||||
t.Fatal("JobStore(j1) not found")
|
||||
}
|
||||
checkReadableContract(t, js, "state")
|
||||
}
|
||||
|
||||
func TestBatchJobStoreContent(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "the result", "the spec")
|
||||
js, _ := s.JobStore("j1")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, want string
|
||||
}{
|
||||
{"spec", "the spec"},
|
||||
{"state", "done\n"},
|
||||
{"result", "the result"},
|
||||
} {
|
||||
data, err := js.Get(tc.name)
|
||||
if err != nil {
|
||||
t.Errorf("Get(%q): %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
if string(data) != tc.want {
|
||||
t.Errorf("Get(%q) = %q; want %q", tc.name, data, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchJobStoreWait(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "", "")
|
||||
js, _ := s.JobStore("j1")
|
||||
|
||||
// Job is already done, so Wait returns immediately
|
||||
data, err := js.Wait(context.Background(), "statewait", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "done") {
|
||||
t.Errorf("Wait = %q; want to contain done", data)
|
||||
}
|
||||
|
||||
// Non-wait file
|
||||
if _, err := js.Wait(context.Background(), "spec", ""); err == nil {
|
||||
t.Error("Wait(spec) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchJobStoreLogInfo(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
s.AddJob("j1", "done", "", "")
|
||||
js, _ := s.JobStore("j1")
|
||||
|
||||
l, v := js.LogInfo()
|
||||
if l != 0 || v != 0 {
|
||||
t.Errorf("LogInfo = (%d, %d); want (0, 0)", l, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchJobStoreNotFound(t *testing.T) {
|
||||
s := newBatchStore(t)
|
||||
if _, ok := s.JobStore("nope"); ok {
|
||||
t.Error("JobStore(nonexistent) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ParseBatchSpec =====
|
||||
|
||||
func TestParseBatchSpec(t *testing.T) {
|
||||
input := "name=test\ncwd=/tmp\nagent=default\n---\ndo something"
|
||||
if _, err := store.ParseBatchSpec(input); err != nil {
|
||||
t.Fatalf("ParseBatchSpec: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBatchSpecErrors(t *testing.T) {
|
||||
// missing delimiter
|
||||
if _, err := store.ParseBatchSpec("no delimiter"); err == nil {
|
||||
t.Error("expected error for missing ---")
|
||||
}
|
||||
// missing cwd
|
||||
if _, err := store.ParseBatchSpec("name=x\n---\nprompt"); err == nil {
|
||||
t.Error("expected error for missing cwd")
|
||||
}
|
||||
// missing prompt
|
||||
if _, err := store.ParseBatchSpec("cwd=/tmp\n---\n"); err == nil {
|
||||
t.Error("expected error for missing prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LoadAgentConfig =====
|
||||
|
||||
func TestLoadAgentConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "test.json"), []byte(`{}`), 0644)
|
||||
|
||||
cfg := store.LoadAgentConfig(dir, "test", nil)
|
||||
if cfg == nil {
|
||||
t.Error("LoadAgentConfig should return non-nil for existing file")
|
||||
}
|
||||
|
||||
cfg = store.LoadAgentConfig(dir, "nonexistent", nil)
|
||||
if cfg != nil {
|
||||
t.Error("LoadAgentConfig should return nil for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== FormatEvent =====
|
||||
|
||||
func TestFormatEvent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
ev agent.Event
|
||||
want string
|
||||
}{
|
||||
{agent.Event{Role: "user", Content: "hi"}, "user: hi\n"},
|
||||
{agent.Event{Role: "assistant", Content: "hello"}, "hello"},
|
||||
{agent.Event{Role: "error", Content: "oops"}, "error: oops\n"},
|
||||
{agent.Event{Role: "call", Name: "fn", Content: "args"}, "-> fn(args)\n"},
|
||||
{agent.Event{Role: "tool", Content: "result\n"}, "result\n"},
|
||||
{agent.Event{Role: "retry", Content: "5"}, "retrying in 5s...\n"},
|
||||
{agent.Event{Role: "stalled"}, "agent stalled\n"},
|
||||
{agent.Event{Role: "unknown"}, ""},
|
||||
} {
|
||||
got := string(store.FormatEvent(tc.ev))
|
||||
if got != tc.want {
|
||||
t.Errorf("FormatEvent(%q) = %q; want %q", tc.ev.Role, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SyntheticFileInfo / SyntheticEntry / FileEntry / DirEntry =====
|
||||
|
||||
func TestSyntheticFileInfo(t *testing.T) {
|
||||
fi := &store.SyntheticFileInfo{Name_: "f", Mode_: 0644, Size_: 42, IsDir_: false}
|
||||
if fi.Name() != "f" || fi.Size() != 42 || fi.Mode() != 0644 || fi.IsDir() || fi.Sys() != nil {
|
||||
t.Error("SyntheticFileInfo field mismatch")
|
||||
}
|
||||
if !fi.ModTime().IsZero() {
|
||||
t.Error("ModTime should be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEntryDirEntry(t *testing.T) {
|
||||
fe := store.FileEntry("f", 0644)
|
||||
if fe.Name() != "f" || fe.IsDir() || fe.Type() != 0 {
|
||||
t.Error("FileEntry mismatch")
|
||||
}
|
||||
de := store.DirEntry("d", 0755)
|
||||
if de.Name() != "d" || !de.IsDir() || de.Type() != os.ModeDir {
|
||||
t.Error("DirEntry mismatch")
|
||||
}
|
||||
// Info()
|
||||
info, err := de.Info()
|
||||
if err != nil || info.Name() != "d" || !info.IsDir() {
|
||||
t.Error("DirEntry.Info() mismatch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue