mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 11:45:52 +00:00
952fccf8df
Completes the first-session elicitation fix thata3f8b18bfstarted 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.
213 lines
6.1 KiB
Go
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)
|
|
}
|