tests: fill critical-path coverage gaps
- loop.go: rate-limit retry (retryCountdown 100%), pre-tool cancellation, ChatStream+ctx-cancel (recordInterruption "request"), exec-cancel with pending inject - executeTurn: startup messages, backend error event, auto-compact with preCompact/postCompact hook context injection - session: compact backend error, empty summary, tool-message flattening - handleCommand: /compact while running/nil-session/preHook-blocks, /clear while running, /agents with files - hooks: non-zero exit code (non-blocking), context cancellation - accessors: AgentName, BackendName, ModelName, SystemPrompt, GenerationParams (get+set), ListModels, CtxSz, Usage - SetSessionID rename and same-ID no-op - autoCompactLimit with zero ctxLen, agentSpawn with context output, hooksRan plural branch 55.2% → 73.7% total; executeTurn 87.7%→98.6%, run 62.7%→93.1%, retryCountdown 0%→100%, compact 84.2%→94.7% Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
faaf968f64
commit
8d4e265b60
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue