Files
MyGoNavi/internal/ai/provider/cursor_agent_test.go
Syngnat 06dd9507ee feat(ai): 补齐 Cursor 与 CodeBuddy 会话态聊天链路
- 新增 SessionChatProvider 接口,补齐非流式对话的会话态复用能力
- 为 Cursor Agent 和 CodeBuddy CLI 同步实现流式与非流式会话续接及状态持久化
- CustomProvider 补充会话态透传,统一 custom provider 的会话复用行为
- Service 新增 AIChatSendInSession,聊天主链路非流式回退改走带 session 的发送接口
- 保留原 AIChatSend 无状态语义,避免标题生成和记忆压缩污染主会话上下文
- 补充前后端定向测试,覆盖会话恢复、续接发送和前端回退分流
2026-06-18 13:35:08 +08:00

352 lines
11 KiB
Go

package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestCursorAgentProviderChat_PollsUntilFinished(t *testing.T) {
var (
receivedAuthorization string
receivedPromptText string
pollCount int32
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents":
receivedAuthorization = r.Header.Get("Authorization")
var body struct {
Prompt struct {
Text string `json:"text"`
} `json:"prompt"`
Model *struct {
ID string `json:"id"`
} `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode create agent body: %v", err)
}
receivedPromptText = body.Prompt.Text
if body.Model == nil || body.Model.ID != "composer-latest" {
t.Fatalf("expected model to be forwarded, got %#v", body.Model)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"agent": map[string]any{"id": "bc-1"},
"run": map[string]any{"id": "run-1", "agentId": "bc-1"},
})
case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-1/runs/run-1":
next := atomic.AddInt32(&pollCount, 1)
if next == 1 {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "run-1",
"agentId": "bc-1",
"status": "RUNNING",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "run-1",
"agentId": "bc-1",
"status": "FINISHED",
"result": "done from cursor",
"durationMs": 1234,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
provider, err := NewCursorAgentProvider(ai.ProviderConfig{
Name: "Cursor",
BaseURL: server.URL + "/v1",
APIKey: "cursor-key",
Model: "composer-latest",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resp, err := provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "hello cursor"},
},
})
if err != nil {
t.Fatalf("chat failed: %v", err)
}
if receivedAuthorization != "Bearer cursor-key" {
t.Fatalf("expected bearer auth header, got %q", receivedAuthorization)
}
if !strings.Contains(receivedPromptText, "You are helpful") || !strings.Contains(receivedPromptText, "hello cursor") {
t.Fatalf("expected prompt text to include flattened history, got %q", receivedPromptText)
}
if resp.Content != "done from cursor" {
t.Fatalf("expected final result content, got %q", resp.Content)
}
if atomic.LoadInt32(&pollCount) < 2 {
t.Fatalf("expected provider to poll until terminal status, got %d polls", pollCount)
}
}
func TestCursorAgentProviderChatWithState_UsesFollowUpRunsAndPreservesAgent(t *testing.T) {
var (
createAgentCalls int32
createRunCalls int32
receivedPrompt string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents":
atomic.AddInt32(&createAgentCalls, 1)
t.Fatalf("expected follow-up request to avoid creating a new agent")
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-existing/runs":
atomic.AddInt32(&createRunCalls, 1)
var body struct {
Prompt struct {
Text string `json:"text"`
} `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode follow-up run body: %v", err)
}
receivedPrompt = body.Prompt.Text
_ = json.NewEncoder(w).Encode(map[string]any{
"run": map[string]any{"id": "run-next"},
})
case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-existing/runs/run-next":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "run-next",
"agentId": "bc-existing",
"status": "FINISHED",
"result": "done from follow-up",
"durationMs": 456,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
providerInstance, err := NewCursorAgentProvider(ai.ProviderConfig{
Name: "Cursor",
BaseURL: server.URL + "/v1",
APIKey: "cursor-key",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resp, nextState, err := providerInstance.(SessionChatProvider).ChatWithState(
context.Background(),
json.RawMessage(`{"agentId":"bc-existing","lastRunId":"run-old"}`),
ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "follow this up"},
},
},
)
if err != nil {
t.Fatalf("follow-up chat failed: %v", err)
}
if atomic.LoadInt32(&createAgentCalls) != 0 {
t.Fatalf("expected no create-agent calls, got %d", createAgentCalls)
}
if atomic.LoadInt32(&createRunCalls) != 1 {
t.Fatalf("expected exactly one follow-up run call, got %d", createRunCalls)
}
if !strings.Contains(receivedPrompt, "follow this up") {
t.Fatalf("expected follow-up prompt text, got %q", receivedPrompt)
}
if resp == nil || resp.Content != "done from follow-up" {
t.Fatalf("unexpected response: %#v", resp)
}
if string(nextState) != `{"agentId":"bc-existing","lastRunId":"run-next"}` {
t.Fatalf("unexpected next session state: %s", string(nextState))
}
}
func TestCursorAgentProviderChatStream_MapsAssistantAndThinkingEvents(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents":
_ = json.NewEncoder(w).Encode(map[string]any{
"agent": map[string]any{"id": "bc-2"},
"run": map[string]any{"id": "run-2", "agentId": "bc-2"},
})
case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-2/runs/run-2/stream":
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: status\n"))
_, _ = w.Write([]byte("data: {\"runId\":\"run-2\",\"status\":\"RUNNING\"}\n\n"))
_, _ = w.Write([]byte("event: thinking\n"))
_, _ = w.Write([]byte("data: {\"text\":\"plan first\"}\n\n"))
_, _ = w.Write([]byte("event: tool_call\n"))
_, _ = w.Write([]byte("data: {\"callId\":\"tool-1\",\"name\":\"shell\",\"status\":\"running\"}\n\n"))
_, _ = w.Write([]byte("event: assistant\n"))
_, _ = w.Write([]byte("data: {\"text\":\"partial answer\"}\n\n"))
_, _ = w.Write([]byte("event: result\n"))
_, _ = w.Write([]byte("data: {\"runId\":\"run-2\",\"status\":\"FINISHED\",\"text\":\"final answer\"}\n\n"))
_, _ = w.Write([]byte("event: done\n"))
_, _ = w.Write([]byte("data: {}\n\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
provider, err := NewCursorAgentProvider(ai.ProviderConfig{
Name: "Cursor",
BaseURL: server.URL + "/v1",
APIKey: "cursor-key",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "stream this"},
},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("chat stream failed: %v", err)
}
if len(chunks) < 3 {
t.Fatalf("expected multiple stream chunks, got %d", len(chunks))
}
if chunks[0].Thinking != "plan first" {
t.Fatalf("expected thinking chunk, got %#v", chunks[0])
}
if chunks[1].Content != "partial answer" {
t.Fatalf("expected assistant content chunk, got %#v", chunks[1])
}
if len(chunks[1].ToolCalls) != 0 {
t.Fatalf("expected cursor tool_call events to stay unmapped, got %#v", chunks[1].ToolCalls)
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected final done chunk, got %#v", chunks[len(chunks)-1])
}
}
func TestCursorAgentProviderChatStreamWithState_UsesFollowUpRunsAndPreservesAgent(t *testing.T) {
var (
createAgentCalls int32
createRunCalls int32
receivedPrompt string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents":
atomic.AddInt32(&createAgentCalls, 1)
t.Fatalf("expected follow-up request to avoid creating a new agent")
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-existing/runs":
atomic.AddInt32(&createRunCalls, 1)
var body struct {
Prompt struct {
Text string `json:"text"`
} `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode follow-up run body: %v", err)
}
receivedPrompt = body.Prompt.Text
_ = json.NewEncoder(w).Encode(map[string]any{
"run": map[string]any{"id": "run-next"},
})
case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-existing/runs/run-next/stream":
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: assistant\n"))
_, _ = w.Write([]byte("data: {\"text\":\"done\"}\n\n"))
_, _ = w.Write([]byte("event: done\n"))
_, _ = w.Write([]byte("data: {}\n\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
providerInstance, err := NewCursorAgentProvider(ai.ProviderConfig{
Name: "Cursor",
BaseURL: server.URL + "/v1",
APIKey: "cursor-key",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
sessionState := json.RawMessage(`{"agentId":"bc-existing","lastRunId":"run-old"}`)
var chunks []ai.StreamChunk
nextState, err := providerInstance.(SessionStreamProvider).ChatStreamWithState(
context.Background(),
sessionState,
ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "follow this up"},
},
},
func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
},
)
if err != nil {
t.Fatalf("follow-up stream failed: %v", err)
}
if atomic.LoadInt32(&createAgentCalls) != 0 {
t.Fatalf("expected no create-agent calls, got %d", createAgentCalls)
}
if atomic.LoadInt32(&createRunCalls) != 1 {
t.Fatalf("expected exactly one follow-up run call, got %d", createRunCalls)
}
if !strings.Contains(receivedPrompt, "follow this up") {
t.Fatalf("expected follow-up prompt text, got %q", receivedPrompt)
}
if string(nextState) != `{"agentId":"bc-existing","lastRunId":"run-next"}` {
t.Fatalf("unexpected next session state: %s", string(nextState))
}
if len(chunks) == 0 || chunks[0].Content != "done" {
t.Fatalf("expected streamed assistant content, got %#v", chunks)
}
}
func TestCursorAgentProviderCreateAgentRequest_IncludesImageInputs(t *testing.T) {
requestBody, err := buildCursorCreateAgentRequest(ai.ChatRequest{
Messages: []ai.Message{
{
Role: "user",
Content: "look at this",
Images: []string{"data:image/png;base64,aGVsbG8="},
},
},
}, "composer-latest")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(requestBody.Prompt.Images) != 1 {
t.Fatalf("expected one image payload, got %#v", requestBody.Prompt.Images)
}
if requestBody.Prompt.Images[0].Data != "aGVsbG8=" || requestBody.Prompt.Images[0].MimeType != "image/png" {
t.Fatalf("unexpected image payload: %#v", requestBody.Prompt.Images[0])
}
if requestBody.Model == nil || requestBody.Model.ID != "composer-latest" {
t.Fatalf("expected model selection to be preserved, got %#v", requestBody.Model)
}
}