Files
pulse/internal/ai/chat/agentic_question_tool_test.go
T
rcourtman 952fccf8df feat(ai): enforce look-before-asking — refuse first-action elicitations in the agentic loop
Completes the first-session elicitation fix that a3f8b18bf started at
the tool layer. The second observed failure mode ("Are there any alerts
I should look at?" on Ollama qwen3:8b) had the model calling
pulse_question BEFORE any tool call, so no tool error copy and no tool
description can reach it — the resolve-before-asking prompt policy
(945ed2198) needs runtime teeth for small local models.

The agentic loop now refuses an interactive pulse_question issued
before the run has attempted any real tool call: the model gets an
error tool result steering it to read-only enumeration
(pulse_summarize {"action":"fleet"} and the alert tools need no
parameters), no question card or clarify event reaches the stream,
sibling tool calls from the same provider turn keep processing instead
of tripping the interactive-set skip path, any queued tool attempt
satisfies the gate for the rest of the run, and it fails open after 2
refusals so an unanswerable prompt cannot livelock. Non-interactive
profiles keep their existing separate block. The system prompt
discloses the gate; question-flow tests now open with a look turn the
way real runs must; a corpus scenario pins the stream promise (a
natural first question produces an answer, never a clarification card).

Live-verified on a real Ollama qwen3:8b scratch instance (real local
agent, not mock): both natural openers stream tool-backed answers with
zero question events. Full ./internal/ai/... suite green. ai-runtime
contract updated in-commit.
2026-07-18 00:18:35 +01:00

213 lines
6.1 KiB
Go

package chat
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// scriptLookTurn scripts one provider turn that issues a read-only
// pulse_query call. The look-before-asking gate refuses a pulse_question
// issued before any tool attempt in the run, so question-flow tests open
// with a look turn the way real runs now must.
func scriptLookTurn(mockProvider *MockProvider, matcher func(req providers.ChatRequest) bool) {
queryInput := map[string]interface{}{"action": "topology"}
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(matcher), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "t0", Name: "pulse_query", Input: queryInput},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{ID: "t0", Name: "pulse_query", Input: queryInput}},
},
})
}).Once()
}
// hasToolResult reports whether the request carries a tool result for the
// given provider tool-call ID.
func hasToolResult(req providers.ChatRequest, toolUseID string) bool {
for _, m := range req.Messages {
if m.ToolResult != nil && m.ToolResult.ToolUseID == toolUseID {
return true
}
}
return false
}
func TestAgenticLoop_PulseQuestionToolInteractiveFlow(t *testing.T) {
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
mockProvider := &MockProvider{}
loop := NewAgenticLoop(mockProvider, executor, "You are a helper")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionID := "question-session"
messages := []Message{{Role: "user", Content: "Do something but ask me first"}}
// Turn 1: model looks with a read tool (satisfies the gate).
scriptLookTurn(mockProvider, func(req providers.ChatRequest) bool {
return len(req.Messages) == 1
})
// Turn 2: model requests pulse_question
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
return hasToolResult(req, "t0") && !hasToolResult(req, "t1")
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{
ID: "t1",
Name: pulseQuestionToolName,
Input: map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
map[string]interface{}{"label": "B", "value": "b"},
},
},
},
},
},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{
{
ID: "t1",
Name: pulseQuestionToolName,
Input: map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{
"id": "q1",
"type": "select",
"question": "Pick one",
"options": []interface{}{
map[string]interface{}{"label": "A", "value": "a"},
map[string]interface{}{"label": "B", "value": "b"},
},
},
},
},
},
},
},
})
}).Once()
// Turn 3: model sees the answered question and replies.
mockProvider.On("ChatStream", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
// Expect tool result message for t1 to be present
for _, m := range req.Messages {
if m.ToolResult != nil && m.ToolResult.ToolUseID == "t1" {
var payload struct {
QuestionID string `json:"question_id"`
Answers []QuestionAnswer `json:"answers"`
}
if err := json.Unmarshal([]byte(m.ToolResult.Content), &payload); err != nil {
return false
}
if payload.QuestionID == "" || len(payload.Answers) != 1 {
return false
}
if payload.Answers[0].ID != "q1" || payload.Answers[0].Value != "b" {
return false
}
return true
}
}
return false
}), mock.Anything).Return(nil).Run(func(args mock.Arguments) {
cb := args.Get(2).(providers.StreamCallback)
cb(providers.StreamEvent{
Type: "content",
Data: providers.ContentEvent{Text: "Thanks, you picked B."},
})
cb(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{},
})
}).Once()
var (
mu sync.Mutex
events []StreamEvent
questionEvt *QuestionData
)
callback := func(event StreamEvent) {
mu.Lock()
defer mu.Unlock()
events = append(events, event)
if event.Type == "question" && questionEvt == nil {
var qd QuestionData
_ = json.Unmarshal(event.Data, &qd)
questionEvt = &qd
}
}
// Run loop in background; answer as soon as we see the question event.
var (
results []Message
err error
doneCh = make(chan struct{})
)
go func() {
defer close(doneCh)
results, err = loop.Execute(ctx, sessionID, messages, callback)
}()
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return questionEvt != nil && questionEvt.QuestionID != ""
}, 2*time.Second, 10*time.Millisecond, "expected question event")
mu.Lock()
qID := questionEvt.QuestionID
mu.Unlock()
require.NoError(t, loop.AnswerQuestion(qID, []QuestionAnswer{{ID: "q1", Value: "b"}}))
select {
case <-doneCh:
case <-ctx.Done():
t.Fatalf("agentic loop did not complete: %v", ctx.Err())
}
require.NoError(t, err)
require.NotEmpty(t, results)
assert.Equal(t, "Thanks, you picked B.", results[len(results)-1].Content)
// Ensure pulse_question tool_start is suppressed (UI uses question card).
mu.Lock()
defer mu.Unlock()
for _, ev := range events {
if ev.Type == "tool_start" {
var ts ToolStartData
_ = json.Unmarshal(ev.Data, &ts)
assert.NotEqual(t, pulseQuestionToolName, ts.Name)
}
}
mockProvider.AssertExpectations(t)
}