Files
multica/server/pkg/agent/cursor_integration_test.go
Bohan Jiang a92bd5387e MUL-5231 fix(agent): parse Cursor top-level thinking and tool_call events (#5846)
Cursor stream-json emits reasoning and tool calls as top-level `thinking` /
`tool_call` events; the parser only looked for them inside assistant messages,
so transcripts showed a single step. Match the top-level events, taking the
tool name from the nested `<name>ToolCall` key and normalizing the packed
call_id. Subtypes are matched explicitly — only `started` opens a tool, only
`completed` closes it, only `delta` carries reasoning — so an unknown/missing
subtype is ignored rather than synthesizing a fake result (which would decrement
the daemon in-flight tool count early and misfire the watchdog) or polluting
reasoning. Covered by a recorded-stream fixture test, an unknown-subtype
regression test, and an opt-in real-CLI smoke test.
2026-07-24 01:52:15 +08:00

100 lines
2.9 KiB
Go

//go:build agentintegration
package agent
import (
"context"
"log/slog"
"os/exec"
"strings"
"testing"
"time"
)
// TestCursorRealStreamObservability drives the real cursor-agent binary with a
// task that must use tools, and asserts the run is observable: reasoning and
// each tool call/result reach the message stream, not just the final answer.
//
// The fixture test pins the parser against a recorded stream; this one is what
// catches the upstream protocol moving on (MUL-5231).
func TestCursorRealStreamObservability(t *testing.T) {
requireRealAgentSmoke(t)
if testing.Short() {
t.Skip("skipping real-binary smoke test in -short mode")
}
path, err := exec.LookPath("cursor-agent")
if err != nil {
t.Skip("cursor-agent not on PATH; skipping real-binary smoke test")
}
if version, err := exec.Command(path, "--version").CombinedOutput(); err == nil {
t.Logf("cursor-agent CLI version: %s", strings.TrimSpace(string(version)))
} else {
t.Logf("cursor-agent CLI version unavailable: %v (%s)", err, strings.TrimSpace(string(version)))
}
backend, err := New("cursor", Config{ExecutablePath: path, Logger: slog.Default()})
if err != nil {
t.Fatalf("new cursor backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
session, err := backend.Execute(ctx,
"Create a file named ping.txt in this workspace containing exactly the word pong, then read it back and tell me what it says.",
ExecOptions{Cwd: t.TempDir(), Timeout: 170 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
var thinking strings.Builder
var toolUses, toolResults []Message
done := make(chan struct{})
go func() {
defer close(done)
for msg := range session.Messages {
switch msg.Type {
case MessageThinking:
thinking.WriteString(msg.Content)
case MessageToolUse:
toolUses = append(toolUses, msg)
case MessageToolResult:
toolResults = append(toolResults, msg)
}
}
}()
result := <-session.Result
<-done
if result.Status != "completed" {
t.Fatalf("real cursor run did not complete: status=%q error=%q", result.Status, result.Error)
}
if thinking.Len() == 0 {
t.Error("no reasoning observed from the real cursor stream")
}
if len(toolUses) == 0 {
t.Fatal("no tool calls observed from the real cursor stream")
}
if len(toolResults) != len(toolUses) {
t.Errorf("tool results = %d, tool uses = %d; every call must report a result", len(toolResults), len(toolUses))
}
for _, msg := range toolUses {
if msg.Tool == "" {
t.Errorf("tool call without a name: %+v", msg)
}
if strings.ContainsAny(msg.CallID, "\r\n") {
t.Errorf("tool call id spans multiple lines: %q", msg.CallID)
}
}
t.Logf("real cursor smoke OK: thinking=%d bytes, tools=%v, output=%q",
thinking.Len(), toolNames(toolUses), result.Output)
}
func toolNames(msgs []Message) []string {
names := make([]string, 0, len(msgs))
for _, msg := range msgs {
names = append(names, msg.Tool)
}
return names
}