Files
multica/server/pkg/agent/cursor_test.go
marovole 0e31a9ca58 fix(agent/runtimes): show Cursor Composer token usage and billing (#4135)
* 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>
2026-06-15 15:52:48 +08:00

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)
}
}