From 5db3eb5138c010efe63b8183b9d948021224a6ee Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 29 Jul 2026 19:25:14 +0200 Subject: [PATCH] session: remove Session interface, export concrete struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session is now an exported struct with unexported fields. Methods ARE the API — no interface declaration in the package. Consumers that need mockability define their own interface (implicit satisfaction, standard Go pattern). --- session/commands.go | 2 +- session/core_test.go | 14 ++-- session/harness.go | 119 +++++++++++++++-------------- session/session.go | 151 +------------------------------------ session/waitchange_test.go | 4 +- 5 files changed, 70 insertions(+), 220 deletions(-) diff --git a/session/commands.go b/session/commands.go index be8a6de..adc3365 100644 --- a/session/commands.go +++ b/session/commands.go @@ -12,7 +12,7 @@ import ( ) -func (a *harness) handleCommand(ctx context.Context, input string) bool { +func (a *Session) handleCommand(ctx context.Context, input string) bool { if !strings.HasPrefix(input, "/") { return false } diff --git a/session/core_test.go b/session/core_test.go index 59608b0..4cd5489 100644 --- a/session/core_test.go +++ b/session/core_test.go @@ -83,7 +83,7 @@ func defaultBE() *mockBackend { // newCore builds a minimal *agent for tests, bypassing loadSystemPrompt // by directly setting Preamble on Runtime. -func newCore(t *testing.T, be backend.Backend, hooks Hooks) *harness { +func newCore(t *testing.T, be backend.Backend, hooks Hooks) *Session { t.Helper() t.Setenv("OLLIE", "") if be == nil { @@ -107,11 +107,11 @@ func newCore(t *testing.T, be backend.Backend, hooks Hooks) *harness { NewDispatcher: tools.NewDispatcher, }) t.Cleanup(c.Close) - return c.(*harness) + return c } // collectEvents runs Submit synchronously and returns all emitted events. -func collectEvents(ctx context.Context, c Session, input string) []Event { +func collectEvents(ctx context.Context, c *Session, input string) []Event { var mu sync.Mutex var evs []Event sub := c.Bus().Subscribe("event", func(ev Event) { @@ -138,7 +138,7 @@ func byRole(evs []Event, role string) []string { } // waitState blocks until c.State() == want, failing after 2 s. -func waitState(t *testing.T, c Session, want string) { +func waitState(t *testing.T, c *Session, want string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -1788,7 +1788,7 @@ func TestSetSessionID_UpdatesPreamble(t *testing.T) { CWD: t.TempDir(), Runtime: env, NewDispatcher: tools.NewDispatcher, - }).(*harness) + }) t.Cleanup(c.Close) newID := NewSessionID() @@ -2270,7 +2270,7 @@ func (m *mockEnvServer) get(k string) string { return m.env[k] } -func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *harness { +func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *Session { t.Helper() t.Setenv("OLLIE", "") d := tools.NewDispatcher() @@ -2291,7 +2291,7 @@ func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *harness { NewDispatcher: tools.NewDispatcher, }) t.Cleanup(c.Close) - return c.(*harness) + return c } func TestSetEnv_PropagatestoExecuteServer(t *testing.T) { diff --git a/session/harness.go b/session/harness.go index 79c2fcb..bf09fcf 100644 --- a/session/harness.go +++ b/session/harness.go @@ -308,7 +308,7 @@ type Config struct { // harness is the Core implementation. It owns all harness and session state // but has no knowledge of how output is rendered. -type harness struct { +type Session struct { // Session-level state id string uname string @@ -350,12 +350,12 @@ type harness struct { // ToolCallCount returns the total number of tool calls executed in this // session. The counter is monotonically increasing and never resets. // Blocked calls (pre-tool hook exit 2) are not counted. -func (a *harness) ToolCallCount() int64 { +func (a *Session) ToolCallCount() int64 { return a.toolCallCount.Load() } // SetEnv stores a session-scoped variable and propagates it to the execute server. -func (a *harness) SetEnv(key, value string) { +func (a *Session) SetEnv(key, value string) { a.envMu.Lock() a.env[key] = value a.envMu.Unlock() @@ -370,7 +370,7 @@ func (a *harness) SetEnv(key, value string) { } // pushSessionEnv injects OLLIE_SESSION_ID into the execute server subprocess env. -func (a *harness) pushSessionEnv() { +func (a *Session) pushSessionEnv() { if a.r.runtime == nil || a.r.runtime.Dispatcher == nil || a.id == "" { return } @@ -385,14 +385,13 @@ func (a *harness) pushSessionEnv() { } // pushLockDir sets the flock directory on the execute server to the session tmpdir. -func (a *harness) pushLockDir() { +func (a *Session) pushLockDir() { if a.r.runtime == nil || a.r.runtime.Dispatcher == nil || a.id == "" { return } } -var _ Session = (*harness)(nil) // compile-time interface check var sweepTmpOnce sync.Once @@ -436,7 +435,7 @@ func sweepStaleTmpDirs() { } // New creates an agent from the given configuration. -func New(cfg Config) Session { +func New(cfg Config) *Session { sweepStaleTmpDirs() if cfg.ModelName != "" { cfg.Backend.SetModel(cfg.ModelName) @@ -468,7 +467,7 @@ func New(cfg Config) Session { log = olog.NewWriter("core", olog.LevelError+1, io.Discard, io.Discard) } - a := &harness{ + a := &Session{ id: cfg.SessionID, uname: cfg.Uname, cwd: paths.ExpandHome(cfg.CWD), @@ -511,7 +510,7 @@ func New(cfg Config) Session { } // Close releases resources for this session, including its tmpdir. -func (a *harness) Close() { +func (a *Session) Close() { a.log.Debug("Close() session=%q", a.id) a.flushSave() if a.r.runtime != nil && a.r.runtime.Dispatcher != nil { @@ -528,7 +527,7 @@ func (a *harness) Close() { } // execServer returns the execute server if available, or nil. -func (a *harness) execServer() interface{} { +func (a *Session) execServer() interface{} { if a.r.runtime == nil || a.r.runtime.Dispatcher == nil { return nil } @@ -536,7 +535,7 @@ func (a *harness) execServer() interface{} { return srv } -func (a *harness) Detach() bool { +func (a *Session) Detach() bool { if srv := a.execServer(); srv != nil { if d, ok := srv.(interface{ Detach() bool }); ok { return d.Detach() @@ -545,7 +544,7 @@ func (a *harness) Detach() bool { return false } -func (a *harness) ListDetached() []DetachedInfo { +func (a *Session) ListDetached() []DetachedInfo { if srv := a.execServer(); srv != nil { type listDetacher interface { ListDetachedRaw() []any @@ -580,7 +579,7 @@ func (a *harness) ListDetached() []DetachedInfo { return nil } -func (a *harness) SignalDetached(pid, signal int) error { +func (a *Session) SignalDetached(pid, signal int) error { if srv := a.execServer(); srv != nil { type signaler interface { SignalDetached(int, syscall.Signal) error @@ -592,7 +591,7 @@ func (a *harness) SignalDetached(pid, signal int) error { return fmt.Errorf("no execute server available") } -func (a *harness) GetDetachedOutput(pid int) (string, error) { +func (a *Session) GetDetachedOutput(pid int) (string, error) { if srv := a.execServer(); srv != nil { type outputGetter interface { GetDetachedOutput(int) (string, error) @@ -604,7 +603,7 @@ func (a *harness) GetDetachedOutput(pid int) (string, error) { return "", fmt.Errorf("no execute server available") } -func (a *harness) DismissDetached(pid int) bool { +func (a *Session) DismissDetached(pid int) bool { if srv := a.execServer(); srv != nil { type dismisser interface { DismissDetached(int) bool @@ -616,7 +615,7 @@ func (a *harness) DismissDetached(pid int) bool { return false } -func (a *harness) InjectSystemEvent(content string) { +func (a *Session) InjectSystemEvent(content string) { a.Queue("\n" + content + "\n") } @@ -638,7 +637,7 @@ func classifyReaction(emoji string) (category, description string, positive bool } } -func (a *harness) Reactions() map[string]string { +func (a *Session) Reactions() map[string]string { result := make(map[string]string) if a.r.history == nil { return result @@ -649,11 +648,11 @@ func (a *harness) Reactions() map[string]string { return result } -func (a *harness) React(emoji string) { +func (a *Session) React(emoji string) { _ = a.ReactTo("", emoji) } -func (a *harness) ReactTo(responseID, emoji string) error { +func (a *Session) ReactTo(responseID, emoji string) error { if a.r.history == nil { return fmt.Errorf("no active session") } @@ -703,33 +702,33 @@ func (a *harness) ReactTo(responseID, emoji string) error { return nil } -func (a *harness) AgentName() string { +func (a *Session) AgentName() string { v := a.r.agentName a.log.Debug("AgentName() = %q", v) return v } -func (a *harness) BackendName() string { +func (a *Session) BackendName() string { v := a.r.runtime.Backend.Name() a.log.Debug("BackendName() = %q", v) return v } -func (a *harness) ModelName() string { +func (a *Session) ModelName() string { v := a.r.runtime.Backend.Model() a.log.Debug("ModelName() = %q", v) return v } -func (a *harness) State() string { +func (a *Session) State() string { return a.state } -func (a *harness) notifyChange() { +func (a *Session) notifyChange() { a.changeMu.Lock() a.changeCond.Broadcast() a.changeMu.Unlock() } -func (a *harness) setState(state string) { +func (a *Session) setState(state string) { a.state = state a.log.Debug("state -> %q", state) a.notifyChange() @@ -737,7 +736,7 @@ func (a *harness) setState(state string) { // WaitChange blocks until the named field changes from current, then returns // the new value. Returns ("", false) if ctx is cancelled. -func (a *harness) WaitChange(ctx context.Context, field, current string) (string, bool) { +func (a *Session) WaitChange(ctx context.Context, field, current string) (string, bool) { read := func() string { switch field { case WatchState: @@ -775,14 +774,14 @@ func (a *harness) WaitChange(ctx context.Context, field, current string) (string } -func (a *harness) Reply() string { +func (a *Session) Reply() string { r := a.reply a.log.Debug("Reply() len=%d", len(r)) return r } // CWD returns the current working directory for tool execution. -func (a *harness) CWD() string { +func (a *Session) CWD() string { if a.cwd != "" { a.log.Debug("CWD() = %q", a.cwd) return a.cwd @@ -794,7 +793,7 @@ func (a *harness) CWD() string { // SetCWD changes the working directory for tool execution and updates the // system prompt. Returns an error if the path does not exist. -func (a *harness) SetCWD(dir string) error { +func (a *Session) SetCWD(dir string) error { a.log.Debug("SetCWD(%q)", dir) dir = paths.ExpandHome(dir) if dir != "" { @@ -822,7 +821,7 @@ func (a *harness) SetCWD(dir string) error { // SetSessionID renames the session. It updates the in-memory ID, renames // persisted files on disk, and propagates to the execute server env. -func (a *harness) SetSessionID(newID string) error { +func (a *Session) SetSessionID(newID string) error { a.log.Debug("SetSessionID(%q) old=%q", newID, a.id) oldID := a.id if oldID == newID { @@ -859,7 +858,7 @@ const defaultContextLength = 128000 const defaultToolResultMaxBytes = 131072 // autoCompactLimit returns the token threshold for auto-compaction (75%). -func (a *harness) autoCompactLimit(ctx context.Context) int { +func (a *Session) autoCompactLimit(ctx context.Context) int { ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength @@ -868,7 +867,7 @@ func (a *harness) autoCompactLimit(ctx context.Context) int { } // autoWarnLimit returns the token threshold for a context-usage warning (60%). -func (a *harness) autoWarnLimit(ctx context.Context) int { +func (a *Session) autoWarnLimit(ctx context.Context) int { ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength @@ -879,7 +878,7 @@ func (a *harness) autoWarnLimit(ctx context.Context) int { // spawnContext assembles the agent context injected at each session refresh // point (session start, post-clear, post-compaction). It combines the // agent-specific prompt with any agentSpawn hook output. -func (a *harness) spawnContext(ctx context.Context) string { +func (a *Session) spawnContext(ctx context.Context) string { result := a.r.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ "session_id": a.id, "agent": a.r.agentName, @@ -902,7 +901,7 @@ func (a *harness) spawnContext(ctx context.Context) string { // runCompact executes a full compaction cycle: pre-hook, compact, spawn-context // re-injection, post-hook. Returns (n compacted, error). Returns (0, nil) if // the pre-hook blocked or there was nothing to compact. Caller manages setState. -func (a *harness) runCompact(ctx context.Context, trigger string) (int, error) { +func (a *Session) runCompact(ctx context.Context, trigger string) (int, error) { payload := map[string]string{"session_id": a.id, "trigger": trigger, "cwd": a.CWD()} pre := a.r.runtime.Hooks.Run(ctx, HookPreCompact, payload, a.log) if pre.Warning != "" { @@ -949,11 +948,11 @@ func (a *harness) runCompact(ctx context.Context, trigger string) (int, error) { return n, nil } -func (a *harness) activeSessionPath(id, suffix string) string { +func (a *Session) activeSessionPath(id, suffix string) string { return filepath.Join(a.sessionsDir, "active", id+suffix) } -func (a *harness) saveSession() { +func (a *Session) saveSession() { a.saveMu.Lock() a.saveDirty = true if a.saveTimer == nil { @@ -963,7 +962,7 @@ func (a *harness) saveSession() { } // flushSave immediately persists the session if dirty. -func (a *harness) flushSave() { +func (a *Session) flushSave() { a.saveMu.Lock() dirty := a.saveDirty a.saveDirty = false @@ -991,7 +990,7 @@ func (a *harness) flushSave() { // SaveSession writes the current session state to the given path, including // backend and model metadata for external restore. -func (a *harness) SaveSession(path string) error { +func (a *Session) SaveSession(path string) error { a.mu.RLock() defer a.mu.RUnlock() if a.r.history == nil { @@ -1001,7 +1000,7 @@ func (a *harness) SaveSession(path string) error { a.r.runtime.Backend.Name(), a.r.runtime.Backend.Model(), a.CWD(), a.remote) } -func (a *harness) getActionCancel() context.CancelCauseFunc { +func (a *Session) getActionCancel() context.CancelCauseFunc { if a := a.r.currentAction.Load(); a != nil { return a.cancel } @@ -1010,7 +1009,7 @@ func (a *harness) getActionCancel() context.CancelCauseFunc { // Interrupt cancels the current in-progress agent turn. // Returns true if an action was running and was cancelled. -func (a *harness) Interrupt(cause error) bool { +func (a *Session) Interrupt(cause error) bool { a.log.Debug("Interrupt() cause=%v", cause) if cancel := a.getActionCancel(); cancel != nil { cancel(cause) @@ -1019,7 +1018,7 @@ func (a *harness) Interrupt(cause error) bool { return false } -func (a *harness) Inject(prompt string) { +func (a *Session) Inject(prompt string) { // If an inject is already pending, fall back to the normal FIFO so nothing // is lost. Use CompareAndSwap to avoid a race between the nil check and store. if !a.pendingInject.CompareAndSwap(nil, &prompt) { @@ -1030,42 +1029,42 @@ func (a *harness) Inject(prompt string) { a.emit(Event{Role: "user", Content: prompt}) } -func (a *harness) injectRewrite(prompt string) { +func (a *Session) injectRewrite(prompt string) { a.pendingInject.Store(&prompt) a.emit(Event{Role: "info", Content: "\n"}) a.emit(Event{Role: "user", Content: prompt}) } -func (a *harness) Queue(prompt string) { +func (a *Session) Queue(prompt string) { a.fifo.Push(prompt) a.bus.Publish("queued", prompt) } -func (a *harness) drainQueue() { +func (a *Session) drainQueue() { if prompt, ok := a.fifo.Pop(); ok { a.Submit(context.Background(), prompt) } } -func (a *harness) Bus() *pubsub.Bus { +func (a *Session) Bus() *pubsub.Bus { return a.bus } -func (a *harness) emit(ev Event) { +func (a *Session) emit(ev Event) { a.bus.Publish("event", ev) } -func (a *harness) PopQueue() (string, bool) { +func (a *Session) PopQueue() (string, bool) { return a.fifo.Pop() } -func (a *harness) IsRunning() bool { +func (a *Session) IsRunning() bool { v := a.r.currentAction.Load() != nil a.log.Debug("IsRunning() = %v", v) return v } -func (a *harness) CtxSz() string { +func (a *Session) CtxSz() string { if a.r.history == nil { a.log.Debug("CtxSz() no session") return "no active session" @@ -1081,7 +1080,7 @@ func (a *harness) CtxSz() string { return v } -func (a *harness) Cost() string { +func (a *Session) Cost() string { if a.r.history == nil { return "no active session" } @@ -1089,7 +1088,7 @@ func (a *harness) Cost() string { a.r.history.LastTurnCostUSD, a.r.history.SessionCostUSD) } -func (a *harness) Usage() string { +func (a *Session) Usage() string { if a.r.history == nil { a.log.Debug("Usage() no session") return "no active session" @@ -1107,7 +1106,7 @@ func (a *harness) Usage() string { return str } -func (a *harness) Context() []backend.Message { +func (a *Session) Context() []backend.Message { a.mu.RLock() var msgs []backend.Message if a.r.history != nil { @@ -1120,30 +1119,30 @@ func (a *harness) Context() []backend.Message { return msgs } -func (a *harness) SystemPrompt() string { +func (a *Session) SystemPrompt() string { a.log.Debug("SystemPrompt() len=%d", len(a.r.runtime.Preamble)) return a.r.runtime.Preamble } -func (a *harness) GenerationParams() backend.GenerationParams { +func (a *Session) GenerationParams() backend.GenerationParams { a.mu.RLock() defer a.mu.RUnlock() return a.r.runtime.GenParams } -func (a *harness) CompactionModel() string { +func (a *Session) CompactionModel() string { a.mu.RLock() defer a.mu.RUnlock() return a.r.runtime.CompactionModel } -func (a *harness) SetCompactionModel(model string) { +func (a *Session) SetCompactionModel(model string) { a.mu.Lock() defer a.mu.Unlock() a.r.runtime.CompactionModel = model } -func (a *harness) SetGenerationParams(params backend.GenerationParams) error { +func (a *Session) SetGenerationParams(params backend.GenerationParams) error { if a.IsRunning() { return fmt.Errorf("cannot change params while agent is running") } @@ -1153,7 +1152,7 @@ func (a *harness) SetGenerationParams(params backend.GenerationParams) error { return nil } -func (a *harness) ListModels() string { +func (a *Session) ListModels() string { a.log.Debug("ListModels()") models := a.r.runtime.Backend.Models(context.Background()) slices.Sort(models) @@ -1182,7 +1181,7 @@ func firstSentence(s string) string { // // Continuations (post-turn hook context, unconsumed inject, FIFO drain) are // handled via an explicit loop rather than recursion to avoid stack growth. -func (a *harness) Submit(ctx context.Context, input string) { +func (a *Session) Submit(ctx context.Context, input string) { defer func() { if r := recover(); r != nil { a.log.Error("panic: %v\n%s", r, debug.Stack()) @@ -1229,7 +1228,7 @@ func (a *harness) Submit(ctx context.Context, input string) { // executeTurn runs a single agent turn and returns the next prompt to execute, // or "" if there is nothing more to do. -func (a *harness) executeTurn(ctx context.Context, input string) string { +func (a *Session) executeTurn(ctx context.Context, input string) string { a.emit(Event{Role: "user", Content: input}) hookResult := a.r.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ diff --git a/session/session.go b/session/session.go index 81411a0..bf52e6b 100644 --- a/session/session.go +++ b/session/session.go @@ -1,14 +1,10 @@ package session import ( - "context" "errors" - - "github.com/simonfxr/pubsub" - "ollie/backend" ) -// WatchField names supported by Core.WaitChange. +// WatchField names supported by Session.WaitChange. const ( WatchState = "state" WatchUsage = "usage" @@ -32,151 +28,6 @@ type Event struct { // EventHandler receives events from the agent. type EventHandler func(Event) -// Session is the interface between a frontend (TUI, HTTP handler, etc.) and the -// agent engine. All output from the agent is delivered via the event bus. -type Session interface { - // Submit processes one line of user input. Slash commands and shell - // shortcuts are dispatched synchronously; any other input starts an agent - // turn that publishes events to the bus until the turn is complete. - // After the turn, any queued prompts are drained sequentially. - Submit(ctx context.Context, input string) - - // Interrupt cancels the current in-progress agent turn. - // Returns true if an action was running and was cancelled. - Interrupt(cause error) bool - - // Inject sends a message that will be appended to the next tool result - // as a user interruption. If no turn is running, it is silently dropped. - Inject(prompt string) - - // Queue pushes a prompt onto the FIFO for execution after the current - // turn completes. - Queue(prompt string) - - // Bus returns the session event bus. - Bus() *pubsub.Bus - - // PopQueue removes and returns the next queued prompt. - // Returns ("", false) if the queue is empty. - PopQueue() (string, bool) - - // IsRunning returns true if an agent turn is currently in progress. - IsRunning() bool - - // State returns the current agent state: "idle", "thinking", or "calling: ". - State() string - - // Reply returns the assistant text from the most recently completed turn. - // Cleared when a new prompt is submitted. - Reply() string - - // AgentName returns the name of the active agent. - AgentName() string - - // BackendName returns the name of the active backend (e.g. "anthropic", "ollama"). - BackendName() string - - // ModelName returns the name of the active model. - ModelName() string - - // CtxSz returns the estimated context size as a one-line summary. - CtxSz() string - - // Usage returns billed token counts as a one-line summary. - Usage() string - - // Cost returns per-turn and session cost as a two-line key=value summary. - Cost() string - - // ListModels returns available model names, one per line. - ListModels() string - - // CWD returns the current working directory used for tool execution. - CWD() string - - // SetCWD changes the working directory for tool execution and - // updates the system prompt. Returns an error if the path does not exist. - SetCWD(dir string) error - - // SetSessionID renames the session: updates the in-memory ID, renames - // persisted files on disk, and propagates to the execute server env. - SetSessionID(newID string) error - - // Context returns the current message history as it would be sent to the - // backend: system prompt prepended, stale reads pruned. Does not include - // tool definitions (see Tools) or generation params (see GenerationParams). - Context() []backend.Message - - // SystemPrompt returns the fully rendered system prompt for this session. - SystemPrompt() string - - // GenerationParams returns the current sampling parameters. - GenerationParams() backend.GenerationParams - - // SetGenerationParams replaces the current sampling parameters. - // Returns an error if the agent is currently running. - SetGenerationParams(params backend.GenerationParams) error - - // CompactionModel returns the model override used for context compaction. - CompactionModel() string - - // SetCompactionModel changes the model used for context compaction. - SetCompactionModel(model string) - - // SetEnv injects a session-scoped environment variable into the shell - // subprocesses. Does not affect the daemon process environment. - SetEnv(key, value string) - - // WaitChange blocks until the named field changes from current, then returns - // the new value. Returns ("", false) if ctx is cancelled before a change. - // Supported fields: WatchState, WatchUsage, WatchCtxSz, WatchCWD. - WaitChange(ctx context.Context, field, current string) (string, bool) - - // ToolCallCount returns the total number of tool calls executed in this - // session since the agent was created. The counter is monotonically - // increasing and never resets. Blocked calls (pre-tool hook exit 2) are - // not counted. Timed-out or erroring calls are counted because execution - // was attempted. Use modulo arithmetic in hooks to fire every N calls: - // [ $(($(cat tcct) % 10)) -eq 0 ] && ... - ToolCallCount() int64 - - // SaveSession writes the current session state to the given path. - // The file includes all messages, agent/backend/model metadata, and task state. - SaveSession(path string) error - - // Close releases resources associated with the session, including its - // temporary directory under /tmp/ollie/. - Close() - - // Detach detaches the currently running process from the agent. - // The process continues running; returns false if nothing is executing. - Detach() bool - - // ListDetached returns info about all detached processes. - ListDetached() []DetachedInfo - - // SignalDetached sends a signal to a detached process by PID. - SignalDetached(pid, signal int) error - - // GetDetachedOutput returns the ring buffer output for a detached process. - GetDetachedOutput(pid int) (string, error) - - // DismissDetached removes an exited process from the list. - DismissDetached(pid int) bool - - // InjectSystemEvent appends a system-originated message to the session - // context and emits it on the event bus. The agent sees it on its next - // turn; it appears in the chat log immediately. - InjectSystemEvent(content string) - - // React records an emoji reaction to the most recent assistant response. - React(emoji string) - // ReactTo records an emoji reaction to a specific assistant response. - ReactTo(responseID, emoji string) error - // Reactions returns the current response ID to emoji mapping. - Reactions() map[string]string -} - // DetachedInfo describes a detached process for external consumers. type DetachedInfo struct { PID int diff --git a/session/waitchange_test.go b/session/waitchange_test.go index fe24f66..6d30157 100644 --- a/session/waitchange_test.go +++ b/session/waitchange_test.go @@ -13,8 +13,8 @@ import ( ) // newTestCore returns a minimal harness for testing. -func newTestCore(initialState string) *harness { - a := &harness{ +func newTestCore(initialState string) *Session { + a := &Session{ id: "test", state: initialState, bus: pubsub.NewBus(),