Files
multica/server/pkg/agent/cursor_test.go
Multica Eve de3e0ae556 fix(agent): make cursor stream protocol drift loud instead of silent (MUL-5434) (#6089)
* fix(agent): make cursor stream protocol drift loud instead of silent (MUL-5434)

#6071 reports Cursor tasks that demonstrably read files, ran commands and
called the Multica CLI, yet showed a single blob of agent text with no
reasoning and no tool rows. The run still reported success and tools=0.

`switch evt.Type` in cursor.go had no `default` branch, so any top-level
event type we do not handle was dropped with no counter and no warning.
Renaming only the top-level types of a healthy stream
(`thinking`->`reasoning`, `tool_call`->`tool_calls`), leaving every nested
field untouched, reproduces the report exactly: status=completed, output =
the result text alone, tool_use=0, thinking=0, zero diagnostics. The
existing unknown-subtype warning cannot catch this — it only increments
once the type has already matched — so "no unknown-subtype warning" does
not rule out protocol drift.

Two diagnostic gaps are closed:

- Add the `default` branch with a bounded, content-free tally of unhandled
  top-level types, reported once per run as a warning and alongside
  tool_use_count in the protocol summary. Type names are normalized through
  observedCursorEventType and distinct names are capped at 16 plus an
  overflow bucket, so a hostile or noisy stream cannot grow the map or leak
  payload into logs. `user` (the CLI echoing our prompt, present in every
  recorded run) is explicitly benign so the warning does not fire always.
- Count assistant text separately from the terminal result text. The result
  event writes into the same builder, so last_assistant_bytes equalled
  result_bytes even when the assistant streamed nothing — erasing the
  signal that says "only the final answer arrived". This also aligns cursor
  with claude, which already reports assistant bytes only.

Unrecognized events are still never coerced into tool or reasoning
messages; guessing at upstream additions is the failure mode MUL-5231
already fixed once. This is diagnosis only and does not itself restore the
missing tool rows — identifying which upstream shape changed requires a
captured 2026.07.23 stream, and this warning is what makes that
identifiable from a single production log line.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): report cursor unhandled events as evidence, not as a verdict

Addresses the review's must-fix on MUL-5434. The diagnostic was described
as deciding WHY a transcript is empty, which it cannot do:

- "tools=0 with unhandled types" does not establish that a rename ate the
  tool rows. Cursor 2026.07.23 also emits transport/control frames, so an
  unhandled type only proves the stream carried events we do not parse.
- "tools=0 with no unhandled types" does not establish the agent used no
  tools. The CLI may execute tools without handing the updates to its
  stream serializer at all — the main branch #6071 has NOT ruled out — a
  new shape may be nested inside an event type we already recognize, or
  events may be lost to invalid framing or a scanner boundary.

Changes, no behaviour change to messages, status or output:

- Reword the tally doc, the switch default, the warning and the shared
  observation struct to state what a non-zero and a zero count each do and
  do not establish, and point at the branches that stay open.
- Rename unknown* to unhandled* (fields, log keys, warning text, the
  pre-existing subtype counter) so the diagnostic never implies the type is
  unrecognized upstream — only that this parser does not handle it.
- Classify `connection` and `retry` explicitly. They join `user` in
  cursorNonTranscriptEventTypes, with per-entry provenance recorded: `user`
  is confirmed in the recorded 2026.07.20 stream, the control frames are
  reported on newer builds and listed defensively. A build that does not
  emit them makes the entry inert; one that does must not have a known
  control frame reported as an unhandled protocol event.

Tests assert signal presence and that nothing is fabricated, not causality:
the healthy-stream test now carries `connection` / `retry` and additionally
asserts the real thinking/tool rows still arrive, so suppressing the warning
cannot silently suppress the transcript. TestCursorNonTranscriptEventType
also pins that no type the parser handles can enter the suppression list.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:15:41 +08:00

953 lines
30 KiB
Go

package agent
import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"testing"
)
func TestNewReturnsCursorBackend(t *testing.T) {
t.Parallel()
b, err := New("cursor", Config{ExecutablePath: "/nonexistent/cursor-agent"})
if err != nil {
t.Fatalf("New(cursor) error: %v", err)
}
if _, ok := b.(*cursorBackend); !ok {
t.Fatalf("expected *cursorBackend, got %T", b)
}
}
func TestBuildCursorArgs(t *testing.T) {
t.Parallel()
args := buildCursorArgs(ExecOptions{
Cwd: "/tmp/work",
Model: "composer-1.5",
}, slog.Default())
expected := []string{
"-p",
"--output-format", "stream-json",
"--yolo",
"--workspace", "/tmp/work",
"--model", "composer-1.5",
}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, want := range expected {
if args[i] != want {
t.Errorf("args[%d] = %q, want %q", i, args[i], want)
}
}
}
func TestBuildCursorArgsWithResume(t *testing.T) {
t.Parallel()
args := buildCursorArgs(ExecOptions{
ResumeSessionID: "sess-123",
}, slog.Default())
hasResume := false
for i, a := range args {
if a == "--resume" && i+1 < len(args) && args[i+1] == "sess-123" {
hasResume = true
}
}
if !hasResume {
t.Fatalf("expected --resume sess-123, got %v", args)
}
}
func TestBuildCursorArgsMinimal(t *testing.T) {
t.Parallel()
args := buildCursorArgs(ExecOptions{}, slog.Default())
expected := []string{"-p", "--output-format", "stream-json", "--yolo"}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
}
func TestBuildCursorArgsIgnoresSystemPromptAndMaxTurns(t *testing.T) {
t.Parallel()
// cursor-agent CLI does not support --system-prompt or --max-turns;
// verify they are NOT emitted even when set in ExecOptions.
args := buildCursorArgs(ExecOptions{
SystemPrompt: "You are helpful",
MaxTurns: 5,
}, slog.Default())
for _, a := range args {
if a == "--system-prompt" {
t.Fatalf("unexpected --system-prompt in args: %v", args)
}
if a == "--max-turns" {
t.Fatalf("unexpected --max-turns in args: %v", args)
}
}
}
func TestBuildCursorArgsCustomArgs(t *testing.T) {
t.Parallel()
args := buildCursorArgs(ExecOptions{
CustomArgs: []string{"--extra", "val", "--yolo", "--output-format", "text"},
}, slog.Default())
// --extra val should be present; --yolo and --output-format should be filtered out
hasExtra := false
hasBlockedYolo := false
hasBlockedFormat := false
for i, a := range args {
if a == "--extra" && i+1 < len(args) && args[i+1] == "val" {
hasExtra = true
}
}
// Count occurrences of --yolo (should be exactly 1 — the hardcoded one)
yoloCount := 0
for _, a := range args {
if a == "--yolo" {
yoloCount++
}
if a == "text" {
hasBlockedFormat = true
}
}
if yoloCount > 1 {
hasBlockedYolo = true
}
if !hasExtra {
t.Fatalf("expected --extra val in args, got %v", args)
}
if hasBlockedYolo {
t.Fatalf("--yolo from custom args should be filtered, got %v", args)
}
if hasBlockedFormat {
t.Fatalf("--output-format from custom args should be filtered, got %v", args)
}
}
func TestNormalizeCursorStreamLine(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{`stdout: {"type":"init"}`, `{"type":"init"}`},
{`stderr: {"type":"error"}`, `{"type":"error"}`},
{`stdout:{"type":"init"}`, `{"type":"init"}`},
{` {"type":"assistant"} `, `{"type":"assistant"}`},
{``, ``},
{` `, ``},
{`plain text`, `plain text`},
}
for _, tc := range tests {
got := normalizeCursorStreamLine(tc.input)
if got != tc.want {
t.Errorf("normalizeCursorStreamLine(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestObservedCursorEventTypeIsBoundedAndContentFree(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
}{
{name: "known type", value: "tool_result", want: "tool_result"},
{name: "trimmed", value: " step-finish ", want: "step-finish"},
{name: "empty", value: " ", want: "unknown"},
{name: "content-like", value: "result secret-value", want: "invalid"},
{name: "oversized", value: strings.Repeat("x", 65), want: "invalid"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := observedCursorEventType(tc.value); got != tc.want {
t.Fatalf("observedCursorEventType(%q) = %q, want %q", tc.value, got, tc.want)
}
})
}
}
func TestCursorHandleAssistantText(t *testing.T) {
t.Parallel()
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
evt := &cursorStreamEvent{
Type: "assistant",
Message: mustMarshal(t, cursorAssistantMessage{
Model: "composer-1.5",
Content: []cursorContentBlock{
{Type: "output_text", Text: "Hello from Cursor"},
},
Usage: &cursorUsage{
InputTokens: 100,
OutputTokens: 50,
},
}),
}
b.handleCursorAssistant(evt, ch, &output)
if output.String() != "Hello from Cursor" {
t.Fatalf("expected output 'Hello from Cursor', got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageText || m.Content != "Hello from Cursor" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestCursorHandleAssistantToolUse(t *testing.T) {
t.Parallel()
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
evt := &cursorStreamEvent{
Type: "assistant",
Message: mustMarshal(t, cursorAssistantMessage{
Content: []cursorContentBlock{
{
Type: "tool_use",
ID: "call-42",
Name: "file_edit",
Input: mustMarshal(t, map[string]any{"path": "/tmp/foo.go"}),
},
},
}),
}
b.handleCursorAssistant(evt, ch, &output)
select {
case m := <-ch:
if m.Type != MessageToolUse || m.Tool != "file_edit" || m.CallID != "call-42" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestCursorErrorText(t *testing.T) {
t.Parallel()
tests := []struct {
name string
evt cursorStreamEvent
want string
}{
{"error field", cursorStreamEvent{ErrorMsg: "bad request"}, "bad request"},
{"detail field", cursorStreamEvent{Detail: "not found"}, "not found"},
{"result field", cursorStreamEvent{ResultText: "failed"}, "failed"},
{"empty", cursorStreamEvent{}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := cursorErrorText(&tc.evt)
if got != tc.want {
t.Errorf("cursorErrorText = %q, want %q", got, tc.want)
}
})
}
}
func TestCursorUsageModelFallback(t *testing.T) {
t.Parallel()
tests := []struct {
name string
evtModel string
configuredModel string
want string
}{
{"event model wins", "gpt-5.3-codex", "composer-2.5", "gpt-5.3-codex"},
{"configured model fallback", "", "composer-2.5", "composer-2.5"},
{"default cursor", "", "", "cursor"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := cursorUsageModel(tc.evtModel, tc.configuredModel)
if got != tc.want {
t.Fatalf("cursorUsageModel(%q, %q) = %q, want %q", tc.evtModel, tc.configuredModel, got, tc.want)
}
})
}
}
func TestCursorAccumulateResultUsageUsesConfiguredModel(t *testing.T) {
t.Parallel()
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
usage := make(map[string]TokenUsage)
evt := &cursorStreamEvent{
InputTokens: 400,
OutputTokens: 200,
}
b.accumulateResultUsage(usage, evt, "composer-2.5")
u := usage["composer-2.5"]
if u.InputTokens != 400 || u.OutputTokens != 200 {
t.Fatalf("unexpected usage: %+v", u)
}
if _, ok := usage["cursor"]; ok {
t.Fatalf("expected configured model key, got cursor fallback: %+v", usage)
}
}
func TestCursorAccumulateResultUsage(t *testing.T) {
t.Parallel()
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
// Nested usage object (snake_case keys) — compatible with
// cursor-agent versions that wrap usage in a sub-object.
t.Run("nested_usage_object", func(t *testing.T) {
usage := make(map[string]TokenUsage)
evt := &cursorStreamEvent{
Model: "gpt-5.3",
Usage: &cursorUsage{
InputTokens: 200,
OutputTokens: 100,
CacheReadInputTokens: 50,
CacheWriteInputTokens: 25,
},
}
b.accumulateResultUsage(usage, evt, "")
u := usage["gpt-5.3"]
if u.InputTokens != 200 || u.OutputTokens != 100 || u.CacheReadTokens != 50 || u.CacheWriteTokens != 25 {
t.Fatalf("unexpected usage: %+v", u)
}
})
// Top-level camelCase fields (cursor-agent v0.46+) — the current
// default shape from the Cursor CLI. When present, they take
// precedence over any nested usage object.
t.Run("top_level_camelcase", func(t *testing.T) {
usage := make(map[string]TokenUsage)
evt := &cursorStreamEvent{
Model: "gpt-5.3",
InputTokens: 300,
OutputTokens: 150,
CacheReadTokens: 75,
CacheWriteTokens: 25,
}
b.accumulateResultUsage(usage, evt, "")
u := usage["gpt-5.3"]
if u.InputTokens != 300 || u.OutputTokens != 150 || u.CacheReadTokens != 75 || u.CacheWriteTokens != 25 {
t.Fatalf("unexpected usage: %+v (want input=300 output=150 cache_read=75 cache_write=25)", u)
}
})
// Top-level fields win when both shapes are present — this
// prevents double-counting from the nested fallback.
t.Run("top_level_wins_over_nested", func(t *testing.T) {
usage := make(map[string]TokenUsage)
evt := &cursorStreamEvent{
Model: "gpt-5.3",
InputTokens: 300,
OutputTokens: 150,
Usage: &cursorUsage{
InputTokens: 999,
OutputTokens: 888,
CacheReadInputTokens: 777,
},
}
b.accumulateResultUsage(usage, evt, "")
u := usage["gpt-5.3"]
if u.InputTokens != 300 || u.OutputTokens != 150 || u.CacheReadTokens != 0 {
t.Fatalf("unexpected usage: %+v (want input=300 output=150 cache=0)", u)
}
})
// No usage at all — early return, map unchanged.
t.Run("no_usage", func(t *testing.T) {
usage := make(map[string]TokenUsage)
evt := &cursorStreamEvent{
Model: "gpt-5.3",
}
b.accumulateResultUsage(usage, evt, "")
if _, ok := usage["gpt-5.3"]; ok {
t.Fatalf("expected no entry, got %+v", usage["gpt-5.3"])
}
})
// Empty model defaults to "cursor".
t.Run("default_model", func(t *testing.T) {
usage := make(map[string]TokenUsage)
evt := &cursorStreamEvent{
InputTokens: 50,
OutputTokens: 25,
}
b.accumulateResultUsage(usage, evt, "")
u := usage["cursor"]
if u.InputTokens != 50 || u.OutputTokens != 25 {
t.Fatalf("unexpected usage: %+v (want input=50 output=25)", u)
}
})
}
func TestCursorUsageOnlyFromResult(t *testing.T) {
t.Parallel()
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
evt := &cursorStreamEvent{
Type: "assistant",
Message: mustMarshal(t, cursorAssistantMessage{
Model: "gpt-5",
Content: []cursorContentBlock{
{Type: "text", Text: "hello"},
},
Usage: &cursorUsage{
InputTokens: 999,
OutputTokens: 888,
},
}),
}
b.handleCursorAssistant(evt, ch, &output)
if output.String() != "hello" {
t.Fatalf("expected 'hello', got %q", output.String())
}
// handleCursorAssistant should NOT have accumulated usage anywhere —
// usage is only taken from result events to avoid double-counting.
// (no usage map to check; this test documents the intent)
}
func TestCursorStepFinishParsing(t *testing.T) {
t.Parallel()
part := cursorStepFinishPart{}
// The trailing "cost" key is ignored: cursor-agent does not report per-step
// cost, and unknown keys must not break token parsing.
data := `{"tokens":{"input":500,"output":200,"cache":{"read":100}},"cost":0.01}`
if err := json.Unmarshal([]byte(data), &part); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if part.Tokens.Input != 500 || part.Tokens.Output != 200 || part.Tokens.Cache.Read != 100 {
t.Fatalf("unexpected part: %+v", part)
}
}
// TestCursorUsageNoDoubleCount verifies that step_finish and result usage
// are never double-counted. When a result event includes usage (session
// totals), step_finish values must be discarded entirely.
func TestCursorUsageNoDoubleCount(t *testing.T) {
t.Parallel()
type jsonlEvent struct {
raw string
}
tests := []struct {
name string
lines []string
want map[string]TokenUsage
}{
{
name: "result_only — use result usage",
lines: []string{
`{"type":"result","model":"gpt-5","usage":{"input_tokens":1000,"output_tokens":500,"cached_input_tokens":200}}`,
},
want: map[string]TokenUsage{
"gpt-5": {InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 200},
},
},
{
name: "step_finish_only — fallback to step usage",
lines: []string{
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`,
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`,
`{"type":"result","model":"gpt-5"}`,
},
want: map[string]TokenUsage{
"gpt-5": {InputTokens: 500, OutputTokens: 180, CacheReadTokens: 80},
},
},
{
name: "step_finish_then_result — result wins, no double count",
lines: []string{
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`,
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`,
`{"type":"result","model":"gpt-5","usage":{"input_tokens":500,"output_tokens":180,"cached_input_tokens":80}}`,
},
want: map[string]TokenUsage{
"gpt-5": {InputTokens: 500, OutputTokens: 180, CacheReadTokens: 80},
},
},
{
name: "multi_model — each model tracked independently",
lines: []string{
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":100,"output":50,"cache":{"read":10}}}}`,
`{"type":"step_finish","model":"sonnet-4","part":{"tokens":{"input":200,"output":80,"cache":{"read":20}}}}`,
`{"type":"result","model":"gpt-5","usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":10}}`,
},
want: map[string]TokenUsage{
// result had usage → use result only, discard all step_finish
"gpt-5": {InputTokens: 100, OutputTokens: 50, CacheReadTokens: 10},
},
},
{
name: "camelcase_result — top-level inputTokens/outputTokens (v0.46+)",
lines: []string{
`{"type":"result","model":"gpt-5","inputTokens":1000,"outputTokens":500,"cacheReadTokens":250,"cacheWriteTokens":50}`,
},
want: map[string]TokenUsage{
"gpt-5": {InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 250, CacheWriteTokens: 50},
},
},
{
name: "nested_camelcase_result — actual cursor-agent stream-json shape",
lines: []string{
`{"type":"result","subtype":"success","duration_ms":10606,"duration_api_ms":10606,"is_error":false,"result":"pong","session_id":"b729a81b-9825-471d-812d-377c547b91e4","request_id":"4126abbe-dbc7-4ea4-a83e-7fab284c559c","usage":{"inputTokens":26640,"outputTokens":40,"cacheReadTokens":467,"cacheWriteTokens":12}}`,
},
want: map[string]TokenUsage{
"cursor": {InputTokens: 26640, OutputTokens: 40, CacheReadTokens: 467, CacheWriteTokens: 12},
},
},
{
name: "camelcase_result_wins_over_step_finish — no double count",
lines: []string{
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`,
`{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`,
`{"type":"result","model":"gpt-5","inputTokens":500,"outputTokens":180}`,
},
want: map[string]TokenUsage{
"gpt-5": {InputTokens: 500, OutputTokens: 180},
},
},
{
name: "camelcase_default_model — no model field defaults to cursor",
lines: []string{
`{"type":"result","inputTokens":400,"outputTokens":200}`,
},
want: map[string]TokenUsage{
"cursor": {InputTokens: 400, OutputTokens: 200},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
stepUsage := make(map[string]TokenUsage)
resultUsage := make(map[string]TokenUsage)
hasResultUsage := false
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
for _, line := range tc.lines {
var evt cursorStreamEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
t.Fatalf("unmarshal %q: %v", line, err)
}
switch evt.Type {
case "result":
b.accumulateResultUsage(resultUsage, &evt, "")
if evt.hasResultUsage() {
hasResultUsage = true
}
case "step_finish":
if evt.Part != nil {
var part cursorStepFinishPart
_ = json.Unmarshal(evt.Part, &part)
model := evt.Model
if model == "" {
model = "cursor"
}
u := stepUsage[model]
u.InputTokens += int64(part.Tokens.Input)
u.OutputTokens += int64(part.Tokens.Output)
u.CacheReadTokens += int64(part.Tokens.Cache.Read)
stepUsage[model] = u
}
}
}
if !hasResultUsage {
resultUsage = stepUsage
}
if len(resultUsage) != len(tc.want) {
t.Fatalf("got %d models, want %d: %+v", len(resultUsage), len(tc.want), resultUsage)
}
for model, want := range tc.want {
got := resultUsage[model]
if got != want {
t.Errorf("model %q: got %+v, want %+v", model, got, want)
}
}
})
}
}
// TestCursorStreamEventUnmarshalTopLevelCamelCase verifies that the
// cursorStreamEvent struct correctly deserializes result events where token
// usage fields are top-level camelCase keys.
func TestCursorStreamEventUnmarshalTopLevelCamelCase(t *testing.T) {
t.Parallel()
raw := `{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"abc-123","model":"gpt-5.3","inputTokens":1500,"outputTokens":300,"cacheReadTokens":75,"cacheWriteTokens":25,"duration_ms":5234,"duration_api_ms":5100}`
var evt cursorStreamEvent
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if evt.Type != "result" {
t.Errorf("type = %q, want result", evt.Type)
}
if evt.SessionID != "abc-123" {
t.Errorf("session_id = %q, want abc-123", evt.SessionID)
}
if evt.Model != "gpt-5.3" {
t.Errorf("model = %q, want gpt-5.3", evt.Model)
}
if evt.InputTokens != 1500 {
t.Errorf("inputTokens = %d, want 1500", evt.InputTokens)
}
if evt.OutputTokens != 300 {
t.Errorf("outputTokens = %d, want 300", evt.OutputTokens)
}
if evt.CacheReadTokens != 75 {
t.Errorf("cacheReadTokens = %d, want 75", evt.CacheReadTokens)
}
if evt.CacheWriteTokens != 25 {
t.Errorf("cacheWriteTokens = %d, want 25", evt.CacheWriteTokens)
}
if evt.Usage != nil {
t.Errorf("usage = %+v, want nil", evt.Usage)
}
// Verify accumulateResultUsage processes the new shape.
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
usage := make(map[string]TokenUsage)
b.accumulateResultUsage(usage, &evt, "")
u := usage["gpt-5.3"]
if u.InputTokens != 1500 || u.OutputTokens != 300 || u.CacheReadTokens != 75 || u.CacheWriteTokens != 25 {
t.Fatalf("accumulated usage = %+v, want input=1500 output=300 cache_read=75 cache_write=25", u)
}
}
// TestCursorStreamEventUnmarshalNestedCamelCase verifies the stream-json shape
// emitted by the locally installed cursor-agent version.
func TestCursorStreamEventUnmarshalNestedCamelCase(t *testing.T) {
t.Parallel()
raw := `{"type":"result","subtype":"success","duration_ms":10606,"duration_api_ms":10606,"is_error":false,"result":"pong","session_id":"b729a81b-9825-471d-812d-377c547b91e4","request_id":"4126abbe-dbc7-4ea4-a83e-7fab284c559c","usage":{"inputTokens":26640,"outputTokens":40,"cacheReadTokens":467,"cacheWriteTokens":12}}`
var evt cursorStreamEvent
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if evt.Usage == nil {
t.Fatal("usage should be non-nil")
}
if evt.Usage.InputTokens != 26640 || evt.Usage.OutputTokens != 40 || evt.Usage.CacheReadInputTokens != 467 || evt.Usage.CacheWriteInputTokens != 12 {
t.Fatalf("usage = %+v, want input=26640 output=40 cache_read=467 cache_write=12", evt.Usage)
}
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
usage := make(map[string]TokenUsage)
b.accumulateResultUsage(usage, &evt, "")
u := usage["cursor"]
if u.InputTokens != 26640 || u.OutputTokens != 40 || u.CacheReadTokens != 467 || u.CacheWriteTokens != 12 {
t.Fatalf("accumulated usage = %+v, want input=26640 output=40 cache_read=467 cache_write=12", u)
}
}
// TestCursorStreamEventUnmarshalLegacyUsage verifies backwards compatibility
// with cursor-agent versions that wrap usage in a nested object.
func TestCursorStreamEventUnmarshalLegacyUsage(t *testing.T) {
t.Parallel()
raw := `{"type":"result","model":"gpt-5","usage":{"input_tokens":800,"output_tokens":400,"cached_input_tokens":200,"cache_creation_input_tokens":100}}`
var evt cursorStreamEvent
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if evt.InputTokens != 0 || evt.OutputTokens != 0 {
t.Errorf("top-level tokens should be 0 for nested-usage shape, got input=%d output=%d", evt.InputTokens, evt.OutputTokens)
}
if evt.Usage == nil {
t.Fatal("usage should be non-nil for nested-usage shape")
}
if evt.Usage.InputTokens != 800 || evt.Usage.OutputTokens != 400 || evt.Usage.CacheReadInputTokens != 200 || evt.Usage.CacheWriteInputTokens != 100 {
t.Fatalf("nested usage = %+v, want input=800 output=400 cache_read=200 cache_write=100", evt.Usage)
}
b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
usage := make(map[string]TokenUsage)
b.accumulateResultUsage(usage, &evt, "")
u := usage["gpt-5"]
if u.InputTokens != 800 || u.OutputTokens != 400 || u.CacheReadTokens != 200 || u.CacheWriteTokens != 100 {
t.Fatalf("accumulated usage = %+v, want input=800 output=400 cache_read=200 cache_write=100", u)
}
}
func TestCursorThinkingStreamForwardsDeltasAndSeparatesBlocks(t *testing.T) {
t.Parallel()
var stream cursorThinkingStream
if got := stream.delta("first "); got != "first " {
t.Fatalf("first delta = %q, want %q", got, "first ")
}
if got := stream.delta("block"); got != "block" {
t.Fatalf("mid-block delta = %q, want %q", got, "block")
}
stream.complete()
// A new block must not be glued onto the previous one: the daemon
// concatenates every thinking message into one transcript entry.
if got := stream.delta("second block"); got != "\n\nsecond block" {
t.Fatalf("first delta of next block = %q, want %q", got, "\n\nsecond block")
}
stream.complete()
}
func TestCursorThinkingStreamDropsEmptyDeltas(t *testing.T) {
t.Parallel()
var stream cursorThinkingStream
if got := stream.delta(""); got != "" {
t.Fatalf("empty delta = %q, want empty", got)
}
// An empty delta must not open a block, so the first real fragment is still
// treated as the very first content (no leading separator).
if got := stream.delta("real"); got != "real" {
t.Fatalf("first real delta = %q, want %q", got, "real")
}
}
func TestParseCursorToolCall(t *testing.T) {
t.Parallel()
tests := []struct {
name string
event string
wantName string
wantCallID string
wantArg string
wantResult string
}{
{
name: "started names the tool from the nested key",
event: `{"type":"tool_call","subtype":"started",
"call_id":"call-1\nfc_9",
"tool_call":{"readToolCall":{"args":{"path":"/tmp/a.txt"}},"toolCallId":"call-1\nfc_9"}}`,
wantName: "read",
wantCallID: "call-1",
wantArg: "/tmp/a.txt",
},
{
name: "completed carries the tool result payload",
event: `{"type":"tool_call","subtype":"completed",
"call_id":"call-2",
"tool_call":{"shellToolCall":{"args":{"path":"x"},"result":{"success":{"exitCode":0}}}}}`,
wantName: "shell",
wantCallID: "call-2",
wantArg: "x",
wantResult: `{"success":{"exitCode":0}}`,
},
{
name: "call id falls back to the nested tool call id",
event: `{"type":"tool_call","subtype":"started",
"tool_call":{"editToolCall":{"args":{"path":"y"}},"toolCallId":"call-3\nfc_1"}}`,
wantName: "edit",
wantCallID: "call-3",
wantArg: "y",
},
{
name: "unknown payload still yields a pairable call id",
event: `{"type":"tool_call","subtype":"started","call_id":"call-4",
"tool_call":{"toolCallId":"call-4","startedAtMs":"1"}}`,
wantCallID: "call-4",
},
{
name: "missing tool_call object degrades to the top-level id",
event: `{"type":"tool_call","subtype":"completed","call_id":"call-5"}`,
wantCallID: "call-5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var evt cursorStreamEvent
if err := json.Unmarshal([]byte(tt.event), &evt); err != nil {
t.Fatalf("unmarshal event: %v", err)
}
call := parseCursorToolCall(&evt)
if call.Name != tt.wantName {
t.Errorf("Name = %q, want %q", call.Name, tt.wantName)
}
if call.CallID != tt.wantCallID {
t.Errorf("CallID = %q, want %q", call.CallID, tt.wantCallID)
}
if tt.wantArg != "" && call.Input["path"] != tt.wantArg {
t.Errorf("Input[path] = %v, want %q", call.Input["path"], tt.wantArg)
}
if call.Result != tt.wantResult {
t.Errorf("Result = %q, want %q", call.Result, tt.wantResult)
}
})
}
}
func TestCursorToolPayloadKeyIsDeterministic(t *testing.T) {
t.Parallel()
envelope := map[string]json.RawMessage{
"toolCallId": json.RawMessage(`"call-1"`),
"startedAtMs": json.RawMessage(`"1"`),
"shellToolCall": json.RawMessage(`{}`),
"readToolCall": json.RawMessage(`{}`),
"ToolCall": json.RawMessage(`{}`),
"someOtherField": json.RawMessage(`{}`),
}
for i := 0; i < 20; i++ {
if got := cursorToolPayloadKey(envelope); got != "readToolCall" {
t.Fatalf("iteration %d: key = %q, want readToolCall", i, got)
}
}
if got := cursorToolPayloadKey(map[string]json.RawMessage{"toolCallId": json.RawMessage(`"x"`)}); got != "" {
t.Fatalf("key without a tool payload = %q, want empty", got)
}
}
// TestCursorUnhandledTypeTallySummary pins the rendering the daemon log depends
// on: sorted so it diffs cleanly across runs, and "name=count" so an operator
// can see at a glance which event went missing and how often.
func TestCursorUnhandledTypeTallySummary(t *testing.T) {
t.Parallel()
var tally cursorUnhandledTypeTally
if got := tally.summary(); got != "" {
t.Errorf("empty tally summary = %q, want %q", got, "")
}
if tally.total != 0 {
t.Errorf("empty tally total = %d, want 0", tally.total)
}
for _, eventType := range []string{"tool_calls", "reasoning", "tool_calls", "reasoning", "tool_calls"} {
tally.observe(eventType)
}
if want := "reasoning=2,tool_calls=3"; tally.summary() != want {
t.Errorf("summary = %q, want %q", tally.summary(), want)
}
if tally.total != 5 {
t.Errorf("total = %d, want 5", tally.total)
}
}
// TestCursorUnhandledTypeTallyNormalizesKeys keeps the tally content-free: a type
// value carrying punctuation or arbitrary length must not be copied into daemon
// logs verbatim, it collapses to the same bounded labels observedCursorEventType
// produces everywhere else.
func TestCursorUnhandledTypeTallyNormalizesKeys(t *testing.T) {
t.Parallel()
var tally cursorUnhandledTypeTally
tally.observe("")
tally.observe(" ")
tally.observe("tool call with spaces")
tally.observe(strings.Repeat("x", 65))
if want := "invalid=2,unknown=2"; tally.summary() != want {
t.Errorf("summary = %q, want %q", tally.summary(), want)
}
}
// TestCursorUnhandledTypeTallyBoundsCardinality guards the daemon against a
// stream that emits an unbounded set of novel type names: the map must stop
// growing at the cap and fold the rest into the overflow bucket, while the
// total still counts every event.
func TestCursorUnhandledTypeTallyBoundsCardinality(t *testing.T) {
t.Parallel()
var tally cursorUnhandledTypeTally
const extra = 50
for i := 0; i < cursorUnhandledTypeCardinalityCap+extra; i++ {
tally.observe(fmt.Sprintf("type-%03d", i))
}
if len(tally.counts) != cursorUnhandledTypeCardinalityCap+1 {
t.Errorf("distinct keys = %d, want %d (cap + overflow bucket)",
len(tally.counts), cursorUnhandledTypeCardinalityCap+1)
}
if got := tally.counts[cursorUnhandledTypeOverflowKey]; got != extra {
t.Errorf("overflow bucket = %d, want %d", got, extra)
}
if tally.total != cursorUnhandledTypeCardinalityCap+extra {
t.Errorf("total = %d, want %d", tally.total, cursorUnhandledTypeCardinalityCap+extra)
}
// A capped key already in the map keeps accumulating under its own name.
tally.observe("type-000")
if got := tally.counts["type-000"]; got != 2 {
t.Errorf("known key count = %d, want 2", got)
}
// The overflow label cannot be produced by normalizing a real type name, so
// a stream cannot forge counts into the bucket.
if observedCursorEventType(cursorUnhandledTypeOverflowKey) != "invalid" {
t.Errorf("overflow key %q is collidable with a real type name", cursorUnhandledTypeOverflowKey)
}
}
// TestCursorNonTranscriptEventType pins which unhandled types must stay silent.
// `user` is the CLI echoing our own prompt and appears in every run; `connection`
// and `retry` are transport control frames. None carry transcript content, so
// tallying them would make the warning fire always, which is the same as never.
func TestCursorNonTranscriptEventType(t *testing.T) {
t.Parallel()
for _, eventType := range []string{"user", "connection", "retry"} {
if !cursorNonTranscriptEventType(eventType) {
t.Errorf("%q must be non-transcript; it carries nothing the transcript needs", eventType)
}
}
for _, eventType := range []string{"tool_calls", "reasoning", "toolCall", ""} {
if cursorNonTranscriptEventType(eventType) {
t.Errorf("%q must NOT be suppressed — it has to be reported as unhandled", eventType)
}
}
// The suppression list must never shadow a type the parser actually handles,
// otherwise silencing the diagnostic would silence the transcript too.
handled := []string{
"system", "assistant", "thinking", "tool_call", "tool_use",
"tool_result", "result", "error", "text", "step_finish",
}
for _, eventType := range handled {
if cursorNonTranscriptEventType(eventType) {
t.Errorf("%q is handled by the parser and must not be in the suppression list", eventType)
}
}
}