mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
* fix(agent/runtimes): show Cursor Composer token usage and billing Attribute Cursor stream-json usage to the configured runtime model when result events omit `model`, and add Composer/Auto pricing so dashboard cost estimates resolve for composer-2.5 runs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(views): align Cursor Composer pricing Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
689 lines
21 KiB
Go
689 lines
21 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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("do something", ExecOptions{
|
|
Cwd: "/tmp/work",
|
|
Model: "composer-1.5",
|
|
}, slog.Default())
|
|
|
|
expected := []string{
|
|
"-p", "do something",
|
|
"--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("continue", 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("hello", ExecOptions{}, slog.Default())
|
|
expected := []string{"-p", "hello", "--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("task", 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("task", 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 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{}
|
|
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)
|
|
}
|
|
}
|