diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index 411234c..b8c40bd 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -635,6 +635,367 @@ func TestQueuePopQueue(t *testing.T) { } } +// --- shell ! command --- + +func TestShellCommand_Basic(t *testing.T) { + c := newCore(t, nil, nil) + evs := collectEvents(context.Background(), c, "!echo shellout") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "shellout") { + found = true + } + } + if !found { + t.Errorf("!echo shellout: output not in info events: %v", byRole(evs, "info")) + } + if c.IsRunning() { + t.Error("IsRunning() = true after shell command; want false") + } +} + +func TestShellCommand_Empty(t *testing.T) { + c := newCore(t, nil, nil) + // "!" with no command must return without starting a turn. + evs := collectEvents(context.Background(), c, "!") + if c.session != nil { + t.Error("session created by empty '!' command; want nil") + } + _ = evs +} + +// --- /i and /irw --- + +func TestCommand_I_Empty(t *testing.T) { + c := newCore(t, nil, nil) + evs := collectEvents(context.Background(), c, "/i") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "error") { + found = true + } + } + if !found { + t.Errorf("/i with no args: expected error event, got: %v", byRole(evs, "info")) + } +} + +func TestCommand_I_SetsInject(t *testing.T) { + c := newCore(t, nil, nil) + collectEvents(context.Background(), c, "/i my inject") + p := c.pendingInject.Load() + if p == nil || *p != "my inject" { + t.Errorf("pendingInject = %v; want %q", p, "my inject") + } +} + +func TestCommand_I_FallsToFIFOWhenFull(t *testing.T) { + c := newCore(t, nil, nil) + existing := "first" + c.pendingInject.Store(&existing) + collectEvents(context.Background(), c, "/i second") + if got, ok := c.PopQueue(); !ok || got != "second" { + t.Errorf("FIFO after /i with full inject = %q, %v; want %q, true", got, ok, "second") + } +} + +func TestCommand_IRW_Empty(t *testing.T) { + c := newCore(t, nil, nil) + evs := collectEvents(context.Background(), c, "/irw") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "error") { + found = true + } + } + if !found { + t.Errorf("/irw with no args: expected error event, got: %v", byRole(evs, "info")) + } +} + +func TestCommand_IRW_OverwritesInject(t *testing.T) { + c := newCore(t, nil, nil) + existing := "old" + c.pendingInject.Store(&existing) + collectEvents(context.Background(), c, "/irw new inject") + p := c.pendingInject.Load() + if p == nil || *p != "new inject" { + t.Errorf("pendingInject after /irw = %v; want %q", p, "new inject") + } +} + +// --- /compact additional paths --- + +func TestCommand_Compact_WhileRunning(t *testing.T) { + unblock := make(chan struct{}) + be := defaultBE() + be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + return blockedStream(ctx, unblock), nil + } + c := newCore(t, be, nil) + + done := make(chan struct{}) + go func() { + defer close(done) + c.Submit(context.Background(), "hello", func(Event) {}) + }() + waitState(t, c, "thinking") + + evs := collectEvents(context.Background(), c, "/compact") + + close(unblock) + <-done + + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "error") { + found = true + } + } + if !found { + t.Errorf("/compact while running: expected error event, got: %v", byRole(evs, "info")) + } +} + +func TestCommand_Compact_NilSession(t *testing.T) { + c := newCore(t, nil, nil) + evs := collectEvents(context.Background(), c, "/compact") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "nothing to compact") { + found = true + } + } + if !found { + t.Errorf("/compact with nil session: expected 'nothing to compact', got: %v", byRole(evs, "info")) + } +} + +func TestCommand_Compact_PreHookBlocks(t *testing.T) { + c := newCore(t, nil, Hooks{HookPreCompact: []string{"exit 2"}}) + c.session = newSession("goal") + for i := range 5 { + c.session.messages = append(c.session.messages, + backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, + backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, + ) + } + evs := collectEvents(context.Background(), c, "/compact") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "cancelled") { + found = true + } + } + if !found { + t.Errorf("/compact with blocking preHook: expected 'cancelled', got: %v", byRole(evs, "info")) + } +} + +// --- /clear while running --- + +func TestCommand_Clear_WhileRunning(t *testing.T) { + unblock := make(chan struct{}) + be := defaultBE() + be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + return blockedStream(ctx, unblock), nil + } + c := newCore(t, be, nil) + + done := make(chan struct{}) + go func() { + defer close(done) + c.Submit(context.Background(), "hello", func(Event) {}) + }() + waitState(t, c, "thinking") + + evs := collectEvents(context.Background(), c, "/clear") + + close(unblock) + <-done + + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "error") { + found = true + } + } + if !found { + t.Errorf("/clear while running: expected error event, got: %v", byRole(evs, "info")) + } +} + +// --- executeTurn: backend error --- + +func TestSubmit_BackendError(t *testing.T) { + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + return nil, fmt.Errorf("backend unavailable") + } + c := newCore(t, be, nil) + evs := collectEvents(context.Background(), c, "hello") + found := false + for _, ev := range evs { + if ev.Role == "error" && strings.Contains(ev.Content, "backend unavailable") { + found = true + } + } + if !found { + t.Errorf("backend error: expected error event, got: %v", evs) + } + if got := c.State(); got != "idle" { + t.Errorf("State() = %q after backend error; want idle", got) + } +} + +// --- executeTurn: startup messages --- + +func TestSubmit_StartupMessages(t *testing.T) { + c := newCore(t, nil, nil) + c.startupMessages = []string{"startup msg 1", "startup msg 2"} + evs := collectEvents(context.Background(), c, "hello") + found := 0 + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "startup msg") { + found++ + } + } + if found != 2 { + t.Errorf("startup messages: found %d info events; want 2; got: %v", found, byRole(evs, "info")) + } + if c.startupMessages != nil { + t.Error("startupMessages not cleared after first turn") + } +} + +// --- loop: stream interrupted without Done --- + +func TestRun_StreamInterrupted(t *testing.T) { + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + ch := make(chan backend.StreamEvent) + close(ch) // close without sending Done=true + return ch, nil + } + c := newCore(t, be, nil) + evs := collectEvents(context.Background(), c, "hello") + found := false + for _, ev := range evs { + if ev.Role == "error" { + found = true + } + } + if !found { + t.Errorf("stream interrupted: expected error event, got: %v", evs) + } +} + +// --- loop: unknown stop reason --- + +func TestRun_UnknownStopReason(t *testing.T) { + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{Done: true, StopReason: "max_completion_tokens", Content: "partial"} + close(ch) + return ch, nil + } + c := newCore(t, be, nil) + evs := collectEvents(context.Background(), c, "hello") + found := false + for _, ev := range evs { + if ev.Role == "error" { + found = true + } + } + if !found { + t.Errorf("unknown stop reason: expected error event, got: %v", evs) + } +} + +// --- loop: tool call with empty name --- + +func TestRun_ToolEmptyName(t *testing.T) { + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, msgs []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + if callCount == 1 { + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{ + ToolCalls: []backend.ToolCall{{Name: "", Arguments: json.RawMessage(`{}`)}}, + Done: true, + StopReason: "tool_calls", + } + close(ch) + return ch, nil + } + return textStream("done"), nil + } + c := newCore(t, be, nil) + collectEvents(context.Background(), c, "run empty tool") + if callCount != 2 { + t.Errorf("backend called %d times; want 2 (empty-tool + follow-up)", callCount) + } + if got := c.State(); got != "idle" { + t.Errorf("State() = %q after empty-tool turn; want idle", got) + } +} + +// --- loop: no tool executor configured --- + +func TestRun_NoExec(t *testing.T) { + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + if callCount == 1 { + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{ + ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{}`)}}, + Done: true, + StopReason: "tool_calls", + } + close(ch) + return ch, nil + } + return textStream("done"), nil + } + c := newCore(t, be, nil) + c.loopcfg.Exec = nil // no executor + collectEvents(context.Background(), c, "run tool") + if callCount != 2 { + t.Errorf("backend called %d times; want 2", callCount) + } +} + +// --- hooks: non-zero non-two exit code --- + +func TestHook_NonZeroExitCode(t *testing.T) { + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + return textStream("ok"), nil + } + // exit 1 is a non-blocking warning: turn should still proceed. + c := newCore(t, be, Hooks{HookPreTurn: []string{"exit 1"}}) + collectEvents(context.Background(), c, "hello") + if callCount == 0 { + t.Error("backend not called after exit-1 preTurn hook; want call (non-blocking)") + } +} + +// --- hooks: context cancelled mid-hook --- + +func TestHook_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + // Sleep-10 hook is killed when ctx times out; must not deadlock. + c := newCore(t, nil, Hooks{HookPreTurn: []string{"sleep 10"}}) + collectEvents(ctx, c, "hello") // returns when ctx expires +} + // --- /backend --- func TestCommand_Backend_Show(t *testing.T) { @@ -1052,3 +1413,465 @@ func TestCommand_SP(t *testing.T) { t.Errorf("/sp: system prompt not in info events: %v", byRole(evs, "info")) } } + +// --- /agents with files --- + +func TestCommand_Agents_List(t *testing.T) { + c := newCore(t, nil, nil) + if err := os.WriteFile(c.agentsDir+"/myagent.json", []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + evs := collectEvents(context.Background(), c, "/agents") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "myagent") { + found = true + } + } + if !found { + t.Errorf("/agents with files: 'myagent' not in output: %v", byRole(evs, "info")) + } +} + +// --- hooksRan plural --- + +func TestHooksRan(t *testing.T) { + if got := hooksRan(1); got != "1 hook run" { + t.Errorf("hooksRan(1) = %q; want %q", got, "1 hook run") + } + if got := hooksRan(3); got != "3 hooks run" { + t.Errorf("hooksRan(3) = %q; want %q", got, "3 hooks run") + } +} + +// --- rate-limit retry --- + +func TestRun_RateLimitRetry(t *testing.T) { + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + if callCount == 1 { + return nil, &backend.RateLimitError{RetryAfter: time.Millisecond} + } + return textStream("ok"), nil + } + c := newCore(t, be, nil) + var retryEvents []Event + c.Submit(context.Background(), "hello", func(ev Event) { + if ev.Role == "retry" { + retryEvents = append(retryEvents, ev) + } + }) + if callCount != 2 { + t.Errorf("backend called %d times; want 2 (retry + success)", callCount) + } + if len(retryEvents) == 0 { + t.Error("no retry events emitted during rate-limit retry") + } +} + +// --- run: tool cancelled before execution --- + +func TestRun_ToolCancelledBeforeExec(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + cancel() // cancel before tool loop runs + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{ + ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{}`)}}, + Done: true, + StopReason: "tool_calls", + } + close(ch) + return ch, nil + } + c := newCore(t, be, nil) + c.Submit(ctx, "hello", func(Event) {}) + if callCount != 1 { + t.Errorf("backend called %d times; want 1 (no retry after cancellation)", callCount) + } + if got := c.State(); got != "idle" { + t.Errorf("State() = %q after tool cancellation; want idle", got) + } +} + +// --- run: ChatStream error while ctx cancelled (recordInterruption "request") --- + +func TestRun_RequestCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + cancel() + return nil, fmt.Errorf("request failed") + } + c := newCore(t, be, nil) + c.Submit(ctx, "hello", func(Event) {}) + if got := c.State(); got != "idle" { + t.Errorf("State() = %q after request cancellation; want idle", got) + } +} + +// --- run: Exec error while ctx cancelled, with pending inject --- + +func TestRun_ExecCancelledWithInject(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{ + ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{}`)}}, + Done: true, + StopReason: "tool_calls", + } + close(ch) + return ch, nil + } + c := newCore(t, be, nil) + inject := "user interrupt" + c.pendingInject.Store(&inject) + c.loopcfg.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, error) { + cancel() // ctx cancelled during exec + return "", fmt.Errorf("exec cancelled") + } + c.Submit(ctx, "hello", func(Event) {}) + if callCount != 1 { + t.Errorf("backend called %d times; want 1", callCount) + } + if got := c.State(); got != "idle" { + t.Errorf("State() = %q after exec cancellation; want idle", got) + } +} + +// --- auto-compact with hook context injection --- + +func TestAutoCompact_WithHookContext(t *testing.T) { + callCount := 0 + be := &mockBackend{name: "mock", model: "test", ctxLen: 10} + c := newCore(t, be, Hooks{ + HookPreCompact: []string{`echo "pre-compact context"`}, + HookPostCompact: []string{`echo "post-compact context"`}, + }) + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + if callCount == 1 { + return textStream("summary text for compaction"), nil + } + return textStream("answer"), nil + } + c.session = newSession("goal") + for range 3 { + c.session.messages = append(c.session.messages, + backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"}, + backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"}, + ) + } + evs := collectEvents(context.Background(), c, "next prompt") + if callCount < 2 { + t.Errorf("backend called %d times; want ≥2 (compact + turn)", callCount) + } + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "hook run") { + found = true + } + } + if !found { + t.Errorf("no 'hook run' info event from compact hooks; got: %v", byRole(evs, "info")) + } +} + +// --- Core method accessors --- + +func TestCore_AgentName(t *testing.T) { + c := newCore(t, nil, nil) + if got := c.AgentName(); got != "test" { + t.Errorf("AgentName() = %q; want %q", got, "test") + } +} + +func TestCore_BackendName(t *testing.T) { + c := newCore(t, nil, nil) + if got := c.BackendName(); got != "mock" { + t.Errorf("BackendName() = %q; want %q", got, "mock") + } +} + +func TestCore_ModelName(t *testing.T) { + c := newCore(t, nil, nil) + if got := c.ModelName(); got != "test" { + t.Errorf("ModelName() = %q; want %q", got, "test") + } +} + +func TestCore_SystemPrompt(t *testing.T) { + c := newCore(t, nil, nil) + if got := c.SystemPrompt(); got != "test system prompt" { + t.Errorf("SystemPrompt() = %q; want %q", got, "test system prompt") + } +} + +func TestCore_GenerationParams(t *testing.T) { + c := newCore(t, nil, nil) + p := c.GenerationParams() + if p.MaxTokens != 0 { + t.Errorf("GenerationParams().MaxTokens = %d; want 0 (default)", p.MaxTokens) + } +} + +func TestCore_SetGenerationParams_WhileRunning(t *testing.T) { + unblock := make(chan struct{}) + be := defaultBE() + be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + return blockedStream(ctx, unblock), nil + } + c := newCore(t, be, nil) + + done := make(chan struct{}) + go func() { + defer close(done) + c.Submit(context.Background(), "hello", func(Event) {}) + }() + waitState(t, c, "thinking") + + err := c.SetGenerationParams(backend.GenerationParams{}) + + close(unblock) + <-done + + if err == nil { + t.Error("SetGenerationParams while running: want error, got nil") + } +} + +func TestCore_ListModels(t *testing.T) { + be := &mockBackend{name: "mock", model: "test", ctxLen: 128000, models: []string{"a", "b"}} + c := newCore(t, be, nil) + got := c.ListModels() + if !strings.Contains(got, "a") || !strings.Contains(got, "b") { + t.Errorf("ListModels() = %q; want 'a' and 'b'", got) + } +} + +func TestCore_CtxSz_NoSession(t *testing.T) { + c := newCore(t, nil, nil) + if got := c.CtxSz(); got != "no active session" { + t.Errorf("CtxSz() with no session = %q; want 'no active session'", got) + } +} + +func TestCore_CtxSz_WithSession(t *testing.T) { + c := newCore(t, nil, nil) + collectEvents(context.Background(), c, "hello") + got := c.CtxSz() + if !strings.Contains(got, "/") { + t.Errorf("CtxSz() = %q; want token fraction like '10 / 128000 (0%%)'", got) + } +} + +func TestCore_Usage_NoSession(t *testing.T) { + c := newCore(t, nil, nil) + if got := c.Usage(); got != "no active session" { + t.Errorf("Usage() with no session = %q; want 'no active session'", got) + } +} + +func TestCore_Usage_WithSession(t *testing.T) { + c := newCore(t, nil, nil) + collectEvents(context.Background(), c, "hello") + got := c.Usage() + if !strings.Contains(got, "requests") { + t.Errorf("Usage() = %q; want string containing 'requests'", got) + } +} + +// --- SetSessionID --- + +func TestSetSessionID_Rename(t *testing.T) { + c := newCore(t, nil, nil) + collectEvents(context.Background(), c, "hello") // saves session file + oldID := c.sessionID + newID := NewSessionID() + if err := c.SetSessionID(newID); err != nil { + t.Fatalf("SetSessionID: %v", err) + } + if c.sessionID != newID { + t.Errorf("sessionID = %q; want %q", c.sessionID, newID) + } + if _, err := os.Stat(c.sessionsDir + "/" + oldID + ".json"); !os.IsNotExist(err) { + t.Errorf("old session file still exists after rename; err=%v", err) + } + if _, err := os.Stat(c.sessionsDir + "/" + newID + ".json"); err != nil { + t.Errorf("new session file not found after rename: %v", err) + } +} + +func TestSetSessionID_SameID(t *testing.T) { + c := newCore(t, nil, nil) + id := c.sessionID + if err := c.SetSessionID(id); err != nil { + t.Fatalf("SetSessionID with same ID: %v", err) + } + if c.sessionID != id { + t.Errorf("sessionID changed: got %q; want %q", c.sessionID, id) + } +} + +// --- SetGenerationParams success --- + +func TestCore_SetGenerationParams_Success(t *testing.T) { + c := newCore(t, nil, nil) + p := backend.GenerationParams{MaxTokens: 100} + if err := c.SetGenerationParams(p); err != nil { + t.Fatalf("SetGenerationParams: %v", err) + } + if got := c.GenerationParams().MaxTokens; got != 100 { + t.Errorf("GenerationParams().MaxTokens = %d; want 100", got) + } +} + +// --- autoCompactLimit with zero ctxLen --- + +func TestAutoCompactLimit_DefaultWhenZero(t *testing.T) { + be := &mockBackend{name: "mock", model: "test", ctxLen: 0} + c := newCore(t, be, nil) + limit := c.autoCompactLimit(context.Background()) + want := defaultContextLength * 3 / 4 + if limit != want { + t.Errorf("autoCompactLimit with ctxLen=0 = %d; want %d", limit, want) + } +} + +// --- agentSpawn hook with context output --- + +func TestAgentSpawn_WithContext(t *testing.T) { + c := newCore(t, nil, Hooks{HookAgentSpawn: []string{`echo "spawn context"`}}) + collectEvents(context.Background(), c, "first") + if !strings.Contains(c.loopcfg.systemPrompt, "spawn context") { + t.Errorf("system prompt does not contain spawn context: %q", c.loopcfg.systemPrompt) + } +} + +// --- retryCountdown: ctx cancelled during wait --- + +func TestRun_RateLimitRetry_Cancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + callCount := 0 + be := defaultBE() + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + return nil, &backend.RateLimitError{RetryAfter: 10 * time.Second} + } + c := newCore(t, be, nil) + c.Submit(ctx, "hello", func(Event) {}) + if callCount != 1 { + t.Errorf("backend called %d times after cancelled retry; want 1", callCount) + } +} + +// --- compact: backend error --- + +func TestManualCompact_BackendError(t *testing.T) { + callCount := 0 + be := defaultBE() + c := newCore(t, be, nil) + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + callCount++ + return nil, fmt.Errorf("compact backend error") + } + c.session = newSession("goal") + for i := range 5 { + c.session.messages = append(c.session.messages, + backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, + backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, + ) + } + evs := collectEvents(context.Background(), c, "/compact") + if callCount == 0 { + t.Fatal("backend not called for compact") + } + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "compact error") { + found = true + } + } + if !found { + t.Errorf("/compact backend error: expected 'compact error' event; got: %v", byRole(evs, "info")) + } +} + +// --- compact: empty summary from backend --- + +func TestManualCompact_EmptySummary(t *testing.T) { + be := defaultBE() + c := newCore(t, be, nil) + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{Done: true, StopReason: "stop", Content: " "} + close(ch) + return ch, nil + } + c.session = newSession("goal") + for i := range 5 { + c.session.messages = append(c.session.messages, + backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)}, + backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)}, + ) + } + evs := collectEvents(context.Background(), c, "/compact") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "compact error") { + found = true + } + } + if !found { + t.Errorf("/compact empty summary: expected 'compact error'; got: %v", byRole(evs, "info")) + } +} + +// --- compact: session with tool-call messages (exercises flattenToolMessages) --- + +func TestManualCompact_WithToolMessages(t *testing.T) { + be := defaultBE() + c := newCore(t, be, nil) + be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + return textStream("summary"), nil + } + c.session = newSession("goal") + c.session.messages = append(c.session.messages, + backend.Message{ + Role: "assistant", + Content: "calling tool", + ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{"key":"val"}`)}}, + }, + backend.Message{Role: "tool", Content: "tool result", ToolCallID: "1"}, + backend.Message{Role: "user", Content: "more stuff"}, + backend.Message{Role: "assistant", Content: "done"}, + backend.Message{Role: "user", Content: "follow up"}, + ) + evs := collectEvents(context.Background(), c, "/compact") + found := false + for _, s := range byRole(evs, "info") { + if strings.Contains(s, "compacted") { + found = true + } + } + if !found { + t.Errorf("/compact with tool messages: no 'compacted' event; got: %v", byRole(evs, "info")) + } +}