multica/server/pkg/agent/claude_test.go
Jiang Bohan 8a8d3ea20e feat(usage): add per-task token usage tracking
Extract token usage from Claude Code's stream-json output in real-time
during task execution, replacing the inaccurate global JSONL log scanner.

- New `task_usage` table: tracks (task_id, provider, model) level usage
- Agent SDK: parse `message.usage` from assistant messages, accumulate
  per-model and return in Result
- Daemon: convert agent usage to entries, send with CompleteTask
- Server: store usage on task completion, expose workspace-level
  aggregation APIs (GET /api/usage/daily, GET /api/usage/summary)
2026-04-08 13:08:15 +08:00

229 lines
5.1 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestClaudeHandleAssistantText(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{Type: "text", Text: "Hello world"},
},
}),
}
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
if output.String() != "Hello world" {
t.Fatalf("expected output 'Hello world', got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageText || m.Content != "Hello world" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleAssistantToolUse(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{
Type: "tool_use",
ID: "call-1",
Name: "Read",
Input: mustMarshal(t, map[string]any{"path": "/tmp/foo"}),
},
},
}),
}
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
if output.String() != "" {
t.Fatalf("tool_use should not add to output, got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageToolUse || m.Tool != "Read" || m.CallID != "call-1" {
t.Fatalf("unexpected message: %+v", m)
}
if m.Input["path"] != "/tmp/foo" {
t.Fatalf("expected input path /tmp/foo, got %v", m.Input["path"])
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleUserToolResult(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "user",
Message: mustMarshal(t, claudeMessageContent{
Role: "user",
Content: []claudeContentBlock{
{
Type: "tool_result",
ToolUseID: "call-1",
Content: mustMarshal(t, "file contents here"),
},
},
}),
}
b.handleUser(msg, ch)
select {
case m := <-ch:
if m.Type != MessageToolResult || m.CallID != "call-1" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
var written bytes.Buffer
msg := claudeSDKMessage{
Type: "control_request",
RequestID: "req-42",
Request: mustMarshal(t, claudeControlRequestPayload{
Subtype: "tool_use",
ToolName: "Bash",
Input: mustMarshal(t, map[string]any{"command": "ls"}),
}),
}
b.handleControlRequest(msg, &written)
var resp map[string]any
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["type"] != "control_response" {
t.Fatalf("expected type control_response, got %v", resp["type"])
}
respInner := resp["response"].(map[string]any)
if respInner["request_id"] != "req-42" {
t.Fatalf("expected request_id req-42, got %v", respInner["request_id"])
}
innerResp := respInner["response"].(map[string]any)
if innerResp["behavior"] != "allow" {
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
}
}
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: json.RawMessage(`invalid json`),
}
// Should not panic
b.handleAssistant(msg, ch, &output, make(map[string]TokenUsage))
if output.String() != "" {
t.Fatalf("expected empty output for invalid JSON, got %q", output.String())
}
select {
case m := <-ch:
t.Fatalf("expected no message, got %+v", m)
default:
}
}
func TestTrySendDropsWhenFull(t *testing.T) {
t.Parallel()
ch := make(chan Message, 1)
// Fill the channel
trySend(ch, Message{Type: MessageText, Content: "first"})
// This should not block
trySend(ch, Message{Type: MessageText, Content: "second"})
m := <-ch
if m.Content != "first" {
t.Fatalf("expected 'first', got %q", m.Content)
}
select {
case m := <-ch:
t.Fatalf("expected empty channel, got %+v", m)
default:
}
}
func TestBuildEnvAppendsExtras(t *testing.T) {
t.Parallel()
env := buildEnv(map[string]string{"FOO": "bar", "BAZ": "qux"})
found := 0
for _, e := range env {
if e == "FOO=bar" || e == "BAZ=qux" {
found++
}
}
if found != 2 {
t.Fatalf("expected 2 extra env vars, found %d", found)
}
}
func TestBuildEnvNilExtras(t *testing.T) {
t.Parallel()
env := buildEnv(nil)
if len(env) == 0 {
t.Fatal("expected at least system env vars")
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return data
}