mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* feat(agents): expose usage in task history * fix(agents): surface task usage query failures * perf(agent): bound task usage history queries * fix(agents): paginate task usage history safely * fix(migrations): repair interrupted keyset index builds * refactor(agents): make task usage opt-in * docs(agents): clarify task usage availability Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Multica Eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
211 lines
7.0 KiB
Go
211 lines
7.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// TestListAgentTasksHydratesUsage pins the JSON contract used by
|
|
// `multica agent tasks --output json`: usage is returned at the stored
|
|
// (provider, model) grain, only for tasks owned by the requested agent, and
|
|
// remains absent when a task has no recorded usage.
|
|
func TestListAgentTasksHydratesUsage(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "AgentTaskUsage", []byte("[]"))
|
|
otherAgentID := createHandlerTestAgent(t, "AgentTaskUsageOther", []byte("[]"))
|
|
|
|
newTask := func(agentID string) string {
|
|
return dbfx.Task(t, agentID, testutil.Cols{
|
|
"runtime_id": handlerTestRuntimeID(t),
|
|
"status": "completed",
|
|
})
|
|
}
|
|
|
|
usageTask := newTask(agentID)
|
|
noUsageTask := newTask(agentID)
|
|
otherAgentTask := newTask(otherAgentID)
|
|
|
|
dbfx.Insert(t, "task_usage", testutil.Cols{
|
|
"task_id": usageTask,
|
|
"provider": "anthropic",
|
|
"model": "claude-opus-5",
|
|
"input_tokens": 96000,
|
|
"output_tokens": 34000,
|
|
"cache_read_tokens": 712000,
|
|
"cache_write_tokens": 50000,
|
|
"cost_usd_ticks": nil,
|
|
})
|
|
dbfx.Insert(t, "task_usage", testutil.Cols{
|
|
"task_id": usageTask,
|
|
"provider": "openai",
|
|
"model": "gpt-5.6-terra",
|
|
"input_tokens": 31000,
|
|
"output_tokens": 12000,
|
|
"cache_read_tokens": 158000,
|
|
"cache_write_tokens": 11000,
|
|
"cost_usd_ticks": int64(3310000000),
|
|
})
|
|
dbfx.Insert(t, "task_usage", testutil.Cols{
|
|
"task_id": otherAgentTask,
|
|
"provider": "openai",
|
|
"model": "other-agent-model",
|
|
"input_tokens": 1,
|
|
"output_tokens": 2,
|
|
"cache_read_tokens": 3,
|
|
"cache_write_tokens": 4,
|
|
"cost_usd_ticks": 5,
|
|
})
|
|
|
|
usageRows, err := testHandler.Queries.ListAgentTaskUsage(ctx, db.ListAgentTaskUsageParams{
|
|
AgentID: parseUUID(agentID),
|
|
TaskIds: []pgtype.UUID{parseUUID(usageTask), parseUUID(noUsageTask)},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list agent task usage: %v", err)
|
|
}
|
|
if len(usageRows) != 2 {
|
|
t.Fatalf("agent-scoped usage rows = %d, want 2: %+v", len(usageRows), usageRows)
|
|
}
|
|
for _, row := range usageRows {
|
|
if uuidToString(row.TaskID) != usageTask {
|
|
t.Fatalf("agent-scoped usage leaked task %s, want only %s", uuidToString(row.TaskID), usageTask)
|
|
}
|
|
}
|
|
|
|
req := newRequest(http.MethodGet, "/api/agents/"+agentID+"/tasks?include_usage=true", nil)
|
|
req = withURLParam(req, "id", agentID)
|
|
var resp []AgentTaskResponse
|
|
w := testutil.Call(t, testHandler.ListAgentTasks, req).Want(http.StatusOK).JSON(&resp)
|
|
|
|
byID := make(map[string]AgentTaskResponse, len(resp))
|
|
for _, task := range resp {
|
|
byID[task.ID] = task
|
|
}
|
|
if _, ok := byID[otherAgentTask]; ok {
|
|
t.Fatalf("response leaked task %s from another agent", otherAgentTask)
|
|
}
|
|
|
|
withUsage, ok := byID[usageTask]
|
|
if !ok {
|
|
t.Fatalf("usage task %s missing from response", usageTask)
|
|
}
|
|
if len(withUsage.Usage) != 2 {
|
|
t.Fatalf("usage rows = %d, want 2: %+v", len(withUsage.Usage), withUsage.Usage)
|
|
}
|
|
|
|
byModel := make(map[string]TaskUsageData, len(withUsage.Usage))
|
|
for _, usage := range withUsage.Usage {
|
|
byModel[usage.Model] = usage
|
|
}
|
|
opus, ok := byModel["claude-opus-5"]
|
|
if !ok {
|
|
t.Fatalf("claude-opus-5 row missing: %+v", withUsage.Usage)
|
|
}
|
|
if opus.Provider != "anthropic" || opus.InputTokens != 96000 ||
|
|
opus.OutputTokens != 34000 || opus.CacheReadTokens != 712000 ||
|
|
opus.CacheWriteTokens != 50000 {
|
|
t.Errorf("unexpected claude-opus-5 usage: %+v", opus)
|
|
}
|
|
if opus.CostUsdTicks != nil {
|
|
t.Errorf("claude-opus-5 cost_usd_ticks = %v, want nil", opus.CostUsdTicks)
|
|
}
|
|
|
|
terra, ok := byModel["gpt-5.6-terra"]
|
|
if !ok {
|
|
t.Fatalf("gpt-5.6-terra row missing: %+v", withUsage.Usage)
|
|
}
|
|
if terra.CostUsdTicks == nil || *terra.CostUsdTicks != 3310000000 {
|
|
t.Errorf("gpt-5.6-terra cost_usd_ticks = %v, want 3310000000", terra.CostUsdTicks)
|
|
}
|
|
|
|
withoutUsage, ok := byID[noUsageTask]
|
|
if !ok {
|
|
t.Fatalf("no-usage task %s missing from response", noUsageTask)
|
|
}
|
|
if len(withoutUsage.Usage) != 0 {
|
|
t.Errorf("task with no recorded usage has %d rows: %+v", len(withoutUsage.Usage), withoutUsage.Usage)
|
|
}
|
|
|
|
var raw []map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode raw task list: %v", err)
|
|
}
|
|
for _, task := range raw {
|
|
if task["id"] != noUsageTask {
|
|
continue
|
|
}
|
|
if _, present := task["usage"]; present {
|
|
t.Errorf("no-usage task serialises a usage key: %v", task["usage"])
|
|
}
|
|
}
|
|
|
|
invalidReq := newRequest(http.MethodGet, "/api/agents/"+agentID+"/tasks?include_usage=yes", nil)
|
|
invalidReq = withURLParam(invalidReq, "id", agentID)
|
|
invalidResp := testutil.Call(t, testHandler.ListAgentTasks, invalidReq).Want(http.StatusBadRequest)
|
|
if !strings.Contains(invalidResp.Body.String(), "include_usage must be true or false") {
|
|
t.Fatalf("invalid include_usage response = %s", invalidResp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestListAgentTasksReturnsErrorWhenUsageLoadFails(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
agentID := createHandlerTestAgent(t, "AgentTaskUsageFailure", []byte("[]"))
|
|
dbfx.Task(t, agentID, testutil.Cols{
|
|
"runtime_id": handlerTestRuntimeID(t),
|
|
"status": "completed",
|
|
})
|
|
|
|
lockTx, err := testPool.Begin(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("begin controlled task usage query failure: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = lockTx.Rollback(context.Background()) })
|
|
if _, err := lockTx.Exec(context.Background(), `LOCK TABLE task_usage IN ACCESS EXCLUSIVE MODE`); err != nil {
|
|
_ = lockTx.Rollback(context.Background())
|
|
t.Fatalf("lock task usage for controlled query failure: %v", err)
|
|
}
|
|
|
|
defaultReq := newRequest(http.MethodGet, "/api/agents/"+agentID+"/tasks", nil)
|
|
defaultReq = withURLParam(defaultReq, "id", agentID)
|
|
var defaultResp []AgentTaskResponse
|
|
testutil.Call(t, testHandler.ListAgentTasks, defaultReq).Want(http.StatusOK).JSON(&defaultResp)
|
|
if len(defaultResp) == 0 {
|
|
t.Fatal("default task history unexpectedly empty")
|
|
}
|
|
for _, task := range defaultResp {
|
|
if len(task.Usage) != 0 {
|
|
t.Fatalf("default task history loaded usage while accounting table was locked: %+v", task.Usage)
|
|
}
|
|
}
|
|
|
|
req := newRequest(http.MethodGet, "/api/agents/"+agentID+"/tasks?include_usage=true", nil)
|
|
req = withURLParam(req, "id", agentID)
|
|
errorCtx, cancel := context.WithTimeout(req.Context(), 250*time.Millisecond)
|
|
req = req.WithContext(errorCtx)
|
|
w := testutil.Call(t, testHandler.ListAgentTasks, req)
|
|
cancel()
|
|
if err := lockTx.Rollback(context.Background()); err != nil {
|
|
t.Fatalf("release controlled task usage lock: %v", err)
|
|
}
|
|
|
|
w.Want(http.StatusInternalServerError)
|
|
if !strings.Contains(w.Body.String(), "failed to list agent task usage") {
|
|
t.Fatalf("usage query failure should not fall back to empty usage: %s", w.Body.String())
|
|
}
|
|
}
|