mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(issues): show per-run token usage on the execution log (MUL-5762)
The execution log already lists every agent run on an issue; it just
never said what any of them cost. task_usage has held per-task token
counts since migration 032 — nothing surfaced them per run.
Three placements, one data source:
- Execution-log header carries the issue total ("2.1M · $4.92") and
opens the breakdown.
- Each row carries its own token figure. This takes the slot the
relative timestamp held: the sidebar is 288px and a third column
would come out of the trigger text, which is what people scan. The
list is sorted newest-first, so ordinal recency is already free; the
timestamp moves into the row tooltip alongside duration and model,
neither of which was surfaced there before.
- The transcript dialog gets the same figure in its header, with the
input/output/cache split in the run-info popover.
Backend: ListIssueTaskUsage returns per-(task, provider, model) rows in
one query, joined onto the existing task-runs response. The model
dimension stays on the wire because cost is priced client-side per
model — a row that collapsed two models cannot be priced at all.
Cost reuses estimateCost from the runtime usage page, so the issue and
the workspace never disagree; the new summarizeTaskUsage helpers live
next to it rather than starting a second cost formula.
No usage recorded stays distinguishable from zero end to end — omitted
on the wire, undefined in the schema, null from the summarizer, an em
dash in the UI. A run from before usage reporting was not free.
Removes the standalone "Token usage" sidebar section: it showed the
same issue totals minus the cost and minus any way to attribute them,
and every field it had is in the dialog. The /api/issues/:id/usage
endpoint it read stays — the CLI's `issue usage` still uses it.
Co-authored-by: multica-agent <[email protected]>
* fix(issues): address review on per-run token usage (MUL-5762)
Three findings from @Emacs, all confirmed against the code:
1. Drop the token figure from active rows. The daemon reports usage
once, after `runner.run` returns (internal/daemon/daemon.go), and
ReportTaskUsage publishes no realtime event — so a running task has
no usage to show and would not learn of it mid-run if it did. The
branch was only ever exercised by a hand-written fixture, which is a
test asserting a scenario production cannot produce. The row keeps
its timer; restore the figure in the same change that adds
incremental reporting + cache invalidation.
2. Subscribe the usage surfaces to the custom-pricing store. estimateCost
reads custom rates imperatively via getCustomPricing(), so nothing
re-rendered these after a saved rate change — the header total, the
dialog's totals and per-run costs, the cost-by-agent split, and the
"unmapped model" notice all kept quoting the old price until the task
list happened to refetch. Same subscription the runtime usage page
already carries, plus the snapshot in every memo that prices usage.
Regression test pinned: it fails without the subscription.
3. Give the dialog's status glyph an sr-only label. TaskStatusIcon is
aria-hidden, so a screen reader could not tell a failed run from a
completed one — the execution log rows already pair the two.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
145 lines
5.1 KiB
Go
145 lines
5.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestListTasksByIssueHydratesUsage guards the per-run token figure on the
|
|
// execution log: the sidebar row, its header total, and the usage dialog all
|
|
// read `usage` off this endpoint, so a task that recorded usage must come back
|
|
// carrying it — at the (provider, model) grain, because cost is priced
|
|
// per-model on the client and a collapsed row cannot be priced at all.
|
|
//
|
|
// It also pins the distinction the whole feature rests on: a run with no
|
|
// recorded usage returns NO usage field, not a zeroed one. The UI renders the
|
|
// former as an em dash and the latter as "0 tokens / $0.00", and a run from
|
|
// before usage reporting was not free.
|
|
func TestListTasksByIssueHydratesUsage(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID := createHandlerTestAgent(t, "UsageListAgent", []byte("[]"))
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'usage-list-issue', 'todo', 'medium', $2, 'member', 92778, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
newTask := func(status string) string {
|
|
var id string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id)
|
|
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), $2, 0, $3)
|
|
RETURNING id
|
|
`, agentID, status, issueID).Scan(&id); err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, id) })
|
|
return id
|
|
}
|
|
|
|
// A run that spilled across two models — the case the per-model grain
|
|
// exists for.
|
|
pricedTask := newTask("completed")
|
|
// A run that predates usage reporting.
|
|
unpricedTask := newTask("completed")
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd_ticks)
|
|
VALUES ($1, 'anthropic', 'claude-opus-5', 96000, 34000, 712000, 50000, NULL),
|
|
($1, 'openai', 'gpt-5.6-terra', 31000, 12000, 158000, 11000, 3310000000)
|
|
`, pricedTask); err != nil {
|
|
t.Fatalf("insert task usage: %v", err)
|
|
}
|
|
|
|
req := newRequest("GET", "/api/issues/"+issueID+"/task-runs", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
w := httptest.NewRecorder()
|
|
testHandler.ListTasksByIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp []AgentTaskResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode task list: %v", err)
|
|
}
|
|
|
|
byID := make(map[string]AgentTaskResponse, len(resp))
|
|
for _, task := range resp {
|
|
byID[task.ID] = task
|
|
}
|
|
|
|
priced, ok := byID[pricedTask]
|
|
if !ok {
|
|
t.Fatalf("priced task %s missing from response: %s", pricedTask, w.Body.String())
|
|
}
|
|
if len(priced.Usage) != 2 {
|
|
t.Fatalf("usage rows = %d, want 2 (one per model): %+v", len(priced.Usage), priced.Usage)
|
|
}
|
|
|
|
byModel := make(map[string]TaskUsageData, len(priced.Usage))
|
|
for _, u := range priced.Usage {
|
|
byModel[u.Model] = u
|
|
}
|
|
|
|
opus, ok := byModel["claude-opus-5"]
|
|
if !ok {
|
|
t.Fatalf("claude-opus-5 row missing: %+v", priced.Usage)
|
|
}
|
|
if opus.Provider != "anthropic" {
|
|
t.Errorf("opus provider = %q, want anthropic", opus.Provider)
|
|
}
|
|
if opus.InputTokens != 96000 || opus.OutputTokens != 34000 ||
|
|
opus.CacheReadTokens != 712000 || opus.CacheWriteTokens != 50000 {
|
|
t.Errorf("opus token counts = %+v, want 96000/34000/712000/50000", opus)
|
|
}
|
|
// The provider reported no cost for this row, so the client must estimate
|
|
// it — a 0 here would tell the client the provider said "free".
|
|
if opus.CostUsdTicks != nil {
|
|
t.Errorf("opus cost_usd_ticks = %d, want nil (provider reported none)", *opus.CostUsdTicks)
|
|
}
|
|
|
|
terra, ok := byModel["gpt-5.6-terra"]
|
|
if !ok {
|
|
t.Fatalf("gpt-5.6-terra row missing: %+v", priced.Usage)
|
|
}
|
|
if terra.CostUsdTicks == nil || *terra.CostUsdTicks != 3310000000 {
|
|
t.Errorf("terra cost_usd_ticks = %v, want 3310000000", terra.CostUsdTicks)
|
|
}
|
|
|
|
unpriced, ok := byID[unpricedTask]
|
|
if !ok {
|
|
t.Fatalf("unpriced task %s missing from response", unpricedTask)
|
|
}
|
|
if len(unpriced.Usage) != 0 {
|
|
t.Errorf("task with no recorded usage carries %d usage rows, want none: %+v",
|
|
len(unpriced.Usage), unpriced.Usage)
|
|
}
|
|
// omitempty must keep the key off the wire entirely, so the client sees
|
|
// `undefined` rather than an empty array it might read as "hydrated, zero".
|
|
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"] != unpricedTask {
|
|
continue
|
|
}
|
|
if _, present := task["usage"]; present {
|
|
t.Errorf("unpriced task serialises a `usage` key; want it omitted: %v", task["usage"])
|
|
}
|
|
}
|
|
}
|