diff --git a/docs/acceptance/thread-usage/README.md b/docs/acceptance/thread-usage/README.md new file mode 100644 index 00000000..25c739bc --- /dev/null +++ b/docs/acceptance/thread-usage/README.md @@ -0,0 +1,84 @@ +# Current chat usage acceptance + +Verified on Windows with Chrome on 2026-10-22 (UTC+08:00). + +Settings → Usage now offers **All chats** and **Current chat**. All chats and +30 days remain the defaults. Scope and reporting period are independent; select +All time to inspect the current chat's entire saved usage. The current-chat +option is disabled when no chat is selected. + +## Actual application records + +The before image uses the production frontend and Go API built from +`a07575e9ee863f94cf65d5add5623d27b7358d69`. The after images use this change's +production builds, restarted on the same port and SQLite home. Both versions +read the same saved records. + +A local Anthropic-compatible HTTP provider returned deterministic replies and +usage receipts. The production API created both chats and executed all six +turns through `POST /threads/{thread_id}/turns`. After two replies in each chat, +the production `run cancel` CLI ended its idle Run; the next API turn created +the successor Run. No API response, DOM, or database record was fabricated for +the screenshots. The local model has no configured pricing or cache details. + +| Scope | Runs | Replies / calls | Input | Output | Total | Calls without a recorded cost | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| All chats | 4 | 6 | 1,600 | 320 | 1,920 | 6 | +| Usage chat A | 2 | 3 | 500 | 100 | 600 | 3 | +| Usage chat B | 2 | 3 | 1,100 | 220 | 1,320 | 3 | + +The actual Thread bindings were: + +- A: `thread-run-20261021210427-a65ab6aa350f`, Runs + `run-20261021210427-a65ab6aa350f` and `run-20261021210813-6efb37dd8a49`. +- B: `thread-run-20261021210653-246739963ecb`, Runs + `run-20261021210653-246739963ecb` and `run-20261021210813-98cf5c82a848`. + +Real browser requests to `/api/v1/usage?period=all&utc_offset_minutes=480` +returned HTTP 200. Adding each Thread's `thread_id` returned the corresponding +totals above and exactly one recent conversation. Switching A → B while current +chat remained selected changed 600 → 1,320, including model totals, trend and +activity. Returning to the new-chat settings route disabled Current chat and +restored 1,920. Missing cost and cache coverage remained `—` with the existing +coverage explanation. The 720-pixel viewport wrapped the controls without overlap. + +## Screenshots + +Before: application-wide usage, with no chat scope control. + +![Before: all chats](before-all-chats.png) + +After: all three replies across chat A's two Runs. + +![After: chat A](after-chat-a.png) + +After switching the current chat to B, using the same API and saved data. + +![After: chat B](after-chat-b.png) + +Current chat at 720 × 1,000. + +![After: narrow layout](after-chat-b-narrow.png) + +## Focused verification + +- The HTTP regression first failed against the baseline: a valid `thread_id` + returned 400. It passes with optional scope support and retains original defaults, + read authorization, invalid-query rejection and the missing-Thread 404. +- `go test ./internal/store ./internal/httpapi -run '^(TestThreadUsageStatistics|TestUsageStatistics)' -count=1` + passed. A real Thread continuation binds two Runs. Scoped receipts include + root, specialist, compaction and fan-out calls; all four sources from another + chat are excluded. Both priced and unpriced settlements are filtered, with + existing period, coverage, untracked-cost and global semantics retained. + Archived chats remain readable; deleted and missing chats return not found. +- `TestUsageStatisticsEmptyReadOnlyAndValidation` passed with both global and + scoped queries under `PRAGMA query_only=ON` and unchanged SQLite write counts. +- `go test ./internal/httpapi -run '^TestOpenAPI' -count=1` passed. +- `npm test -- src/v2/components/usage-settings.test.tsx` passed (10 tests), + including Settings prop wiring, disabled current scope, independent periods, + an aborted A request resolving after B, and fallback to global when no chat is open. +- API generation, `npm run check:api`, and `npm run build` passed. Vite reported + the existing large-chunk warning. + +Only the documented live workflow was checked in Chrome. Settlement pricing, +auxiliary sources and delayed-response ordering were covered by the focused tests. diff --git a/docs/acceptance/thread-usage/after-chat-a.png b/docs/acceptance/thread-usage/after-chat-a.png new file mode 100644 index 00000000..001133fc Binary files /dev/null and b/docs/acceptance/thread-usage/after-chat-a.png differ diff --git a/docs/acceptance/thread-usage/after-chat-b-narrow.png b/docs/acceptance/thread-usage/after-chat-b-narrow.png new file mode 100644 index 00000000..ba38fe34 Binary files /dev/null and b/docs/acceptance/thread-usage/after-chat-b-narrow.png differ diff --git a/docs/acceptance/thread-usage/after-chat-b.png b/docs/acceptance/thread-usage/after-chat-b.png new file mode 100644 index 00000000..2065e4ac Binary files /dev/null and b/docs/acceptance/thread-usage/after-chat-b.png differ diff --git a/docs/acceptance/thread-usage/before-all-chats.png b/docs/acceptance/thread-usage/before-all-chats.png new file mode 100644 index 00000000..583cc6a6 Binary files /dev/null and b/docs/acceptance/thread-usage/before-all-chats.png differ diff --git a/docs/openapi.json b/docs/openapi.json index 7c8145b2..b1e4b28b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -34769,6 +34769,16 @@ "minimum": -840, "type": "integer" } + }, + { + "name": "thread_id", + "in": "query", + "description": "Optional exact Thread identity. Restricts all receipt, cost, model, day and activity statistics to every Run bound to this Thread. Omit for application-wide usage. Active and archived Threads are supported; missing or deleted Threads return NOT_FOUND.", + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -34811,6 +34821,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { + "$ref": "#/components/responses/NotFound" + }, "414": { "$ref": "#/components/responses/RequestTooLarge" }, diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index a44256d0..c97bc916 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -2352,9 +2352,10 @@ func openAPIOperationSpecs() []openAPIOperationSpec { NotFound: true, Parameters: append([]openAPIParameter{runID}, paginationParameters()...)}, {Path: "/api/v1/usage", OperationID: "getUsageStatistics", Summary: "Inspect local application usage", Tag: "System", Description: "Aggregates this application's durable model terminal receipts across root agents, specialists, context compaction, and read-only fan-out; failed sent calls count and not-sent receipts are excluded. Tokens, model groups, recent Threads, sparse days, and untracked_calls use terminal receipt time. Cache tokens are input subsets; usage_reported_calls and cache_usage_reported_calls identify missing coverage. Missing provider/model identities remain empty strings. Costs use reservation settled_at independently of receipt time: known_cost_micros sums only actual priced USD settlements, unpriced_calls counts unknown-cost settlements, and untracked_calls counts receipts without a matched settlement. Reservation estimates never become known cost. Calendar dates use a fixed UTC offset; 30d includes today and the preceding 29 dates, all starts at the earliest receipt or settlement, and empty history starts today. The sparse activity_days always cover the last 182 calendar dates including today, independently of period. Collections are sorted deterministically; recent_sessions includes at most eight Threads with selected-period receipts and uses their latest receipt as last_activity_at. Unbound historical Runs contribute to totals and unassigned_calls.", - DataType: reflect.TypeOf(domain.UsageStatistics{}), Parameters: []openAPIParameter{ + DataType: reflect.TypeOf(domain.UsageStatistics{}), NotFound: true, Parameters: []openAPIParameter{ {Name: "period", In: "query", Description: "Calendar reporting period", Schema: map[string]any{"type": "string", "enum": []string{"today", "30d", "all"}, "default": "30d"}}, {Name: "utc_offset_minutes", In: "query", Description: "Fixed UTC offset in minutes, without daylight-saving transitions", Schema: map[string]any{"type": "integer", "minimum": -840, "maximum": 840, "default": 0}}, + {Name: "thread_id", In: "query", Description: "Optional exact Thread identity. Restricts all receipt, cost, model, day and activity statistics to every Run bound to this Thread. Omit for application-wide usage. Active and archived Threads are supported; missing or deleted Threads return NOT_FOUND.", Schema: map[string]any{"type": "string", "minLength": 1, "maxLength": 256}}, }}, {Path: "/api/v1/sessions", OperationID: "listSessions", Summary: "List Sessions", Tag: "Sessions", Description: "Returns a bounded creation-ordered keyset page of persisted Sessions. Updates and later Sessions cannot shift continuation pages.", DataType: reflect.TypeOf(SessionView{}), diff --git a/internal/httpapi/usage_statistics.go b/internal/httpapi/usage_statistics.go index 5c62cf04..f43a0585 100644 --- a/internal/httpapi/usage_statistics.go +++ b/internal/httpapi/usage_statistics.go @@ -14,7 +14,7 @@ import ( func (a *API) usageStatistics(request *http.Request) (any, *Page, error) { values := request.URL.Query() for name, items := range values { - if (name != "period" && name != "utc_offset_minutes") || len(items) != 1 || items[0] == "" { + if (name != "period" && name != "utc_offset_minutes" && name != "thread_id") || len(items) != 1 || items[0] == "" { return nil, nil, apperror.New(apperror.CodeInvalidArgument, "usage query parameter is invalid") } } @@ -33,13 +33,28 @@ func (a *API) usageStatistics(request *http.Request) (any, *Page, error) { return nil, nil, apperror.New(apperror.CodeInvalidArgument, "usage UTC offset must be an integer between -840 and 840") } } - source, ok := a.store.(interface { - GetUsageStatistics(context.Context, string, int, time.Time) (domain.UsageStatistics, error) - }) - if !ok { - return nil, nil, apperror.New(apperror.CodeFailedPrecondition, "usage statistics are unavailable") + var result domain.UsageStatistics + var err error + if threadID := values.Get("thread_id"); threadID != "" { + if err := validateIdentity(threadID, "usage Thread identity"); err != nil { + return nil, nil, err + } + source, ok := a.store.(interface { + GetThreadUsageStatistics(context.Context, string, string, int, time.Time) (domain.UsageStatistics, error) + }) + if !ok { + return nil, nil, apperror.New(apperror.CodeFailedPrecondition, "Thread usage statistics are unavailable") + } + result, err = source.GetThreadUsageStatistics(request.Context(), threadID, period, offset, time.Now().UTC()) + } else { + source, ok := a.store.(interface { + GetUsageStatistics(context.Context, string, int, time.Time) (domain.UsageStatistics, error) + }) + if !ok { + return nil, nil, apperror.New(apperror.CodeFailedPrecondition, "usage statistics are unavailable") + } + result, err = source.GetUsageStatistics(request.Context(), period, offset, time.Now().UTC()) } - result, err := source.GetUsageStatistics(request.Context(), period, offset, time.Now().UTC()) if err != nil { return nil, nil, err } diff --git a/internal/httpapi/usage_statistics_test.go b/internal/httpapi/usage_statistics_test.go index d0c6c01a..3e314112 100644 --- a/internal/httpapi/usage_statistics_test.go +++ b/internal/httpapi/usage_statistics_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "cyberagent-workbench/internal/application" "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/store" ) @@ -42,7 +43,20 @@ func TestUsageStatisticsReadAPIQueryAndAuthorization(t *testing.T) { if result.Period != "all" || result.UTCOffsetMinutes != 480 { t.Fatalf("usage query not forwarded: %+v", result) } - for _, query := range []string{"period=week", "period=", "period=today&period=all", "utc_offset_minutes=", "utc_offset_minutes=841", "utc_offset_minutes=-841", "utc_offset_minutes=1.5", "utc_offset_minutes=0&utc_offset_minutes=1", "unknown=all"} { + _, run, err := application.NewRunService(st).Create(t.Context(), application.CreateRunRequest{Goal: "usage scope", Profile: "review"}) + if err != nil { + t.Fatal(err) + } + threadID := domain.InitialThreadID(run.ID) + response = request(http.MethodGet, "/api/v1/usage?period=all&utc_offset_minutes=480&thread_id="+threadID, testAccessToken) + decodeDataStatus(t, response, http.StatusOK, &result) + if result.Period != "all" || result.UTCOffsetMinutes != 480 || result.Totals.Calls != 0 { + t.Fatalf("scoped usage query not forwarded: %+v", result) + } + if response := request(http.MethodGet, "/api/v1/usage?thread_id=missing-thread", testAccessToken); response.Code != http.StatusNotFound { + t.Fatalf("unknown Thread must not become empty global usage: %d %s", response.Code, response.Body.String()) + } + for _, query := range []string{"period=week", "period=", "period=today&period=all", "utc_offset_minutes=", "utc_offset_minutes=841", "utc_offset_minutes=-841", "utc_offset_minutes=1.5", "utc_offset_minutes=0&utc_offset_minutes=1", "thread_id=", "thread_id=a&thread_id=b", "thread_id=%20a", "thread_id=a%2Fb", "thread_id=a%0Ab", "unknown=all"} { response := request(http.MethodGet, "/api/v1/usage?"+query, testAccessToken) if response.Code != http.StatusBadRequest { t.Fatalf("invalid query accepted: %s: %+v", query, response) @@ -67,9 +81,13 @@ func TestUsageStatisticsOpenAPIContract(t *testing.T) { t.Fatal(err) } operation := spec.Paths["/api/v1/usage"].Get - if operation == nil || !operation.ReadOnly || operation.OperationID != "getUsageStatistics" || len(operation.Parameters) != 2 { + if operation == nil || !operation.ReadOnly || operation.OperationID != "getUsageStatistics" || len(operation.Parameters) != 3 { t.Fatalf("usage contract missing: %+v", operation) } + thread := operation.Parameters[2] + if thread.Name != "thread_id" || thread.In != "query" || thread.Required || thread.Schema["minLength"] != float64(1) || thread.Schema["maxLength"] != float64(256) || operation.Responses["404"] == nil { + t.Fatalf("optional Thread scope contract missing: %+v", thread) + } for _, name := range []string{"UsageStatistics", "UsageTotals", "UsageCost", "UsageModel", "UsageSession", "UsageDay", "UsageActivityDay"} { if spec.Components.Schemas[name] == nil { t.Fatalf("usage response type %s absent from OpenAPI", name) diff --git a/internal/store/usage_statistics.go b/internal/store/usage_statistics.go index 174f84b0..f3a70c84 100644 --- a/internal/store/usage_statistics.go +++ b/internal/store/usage_statistics.go @@ -67,6 +67,19 @@ func (r usageReceipt) monetaryIdentity() (string, int64) { // or acquiring a writer lock. Terminal receipts alone contribute tokens, so // checkpoints, started events, and monetary ledgers cannot double count usage. func (s *SQLiteStore) GetUsageStatistics(ctx context.Context, period string, utcOffsetMinutes int, now time.Time) (domain.UsageStatistics, error) { + return s.getUsageStatistics(ctx, "", period, utcOffsetMinutes, now) +} + +// GetThreadUsageStatistics includes every Run bound to the selected Thread, +// using the same receipt and settlement rules as application-wide usage. +func (s *SQLiteStore) GetThreadUsageStatistics(ctx context.Context, threadID, period string, utcOffsetMinutes int, now time.Time) (domain.UsageStatistics, error) { + if threadID == "" { + return domain.UsageStatistics{}, apperror.New(apperror.CodeInvalidArgument, "usage Thread identity is required") + } + return s.getUsageStatistics(ctx, threadID, period, utcOffsetMinutes, now) +} + +func (s *SQLiteStore) getUsageStatistics(ctx context.Context, threadID, period string, utcOffsetMinutes int, now time.Time) (domain.UsageStatistics, error) { if period == "" { period = "30d" } @@ -91,7 +104,15 @@ func (s *SQLiteStore) GetUsageStatistics(ctx context.Context, period string, utc return result, err } defer finish() - rows, err := tx.QueryContext(ctx, `SELECT e.run_id,e.sequence,e.source,e.type,COALESCE(e.subject_id,''),e.created_at,e.payload_json, + var scopeArgs []any + if threadID != "" { + var exists int + if err := tx.QueryRowContext(ctx, `SELECT 1 FROM threads WHERE id=? AND status<>?`, threadID, domain.ThreadDeleted).Scan(&exists); err != nil { + return result, err + } + scopeArgs = append(scopeArgs, threadID) + } + receiptQuery := `SELECT e.run_id,e.sequence,e.source,e.type,COALESCE(e.subject_id,''),e.created_at,e.payload_json, COALESCE(t.id,''),COALESCE(t.title,''),COALESCE(c.id,0), CASE WHEN f.usage_recorded=1 THEN f.input_tokens END, CASE WHEN f.usage_recorded=1 THEN f.output_tokens END, @@ -102,10 +123,13 @@ func (s *SQLiteStore) GetUsageStatistics(ctx context.Context, period string, utc LEFT JOIN readonly_fanout_model_calls f ON e.source='readonly_fanout' AND f.run_id=e.run_id AND f.execution_id=json_extract(e.payload_json,'$.execution_id') AND f.shard_ordinal=json_extract(e.payload_json,'$.shard') AND f.attempt_number=json_extract(e.payload_json,'$.attempt') AND f.provider=json_extract(e.payload_json,'$.provider') AND f.model=json_extract(e.payload_json,'$.model') - WHERE (e.source IN ('model_gateway','specialist_model_gateway') AND e.type IN ('model.started','model.completed','model.failed')) + WHERE ((e.source IN ('model_gateway','specialist_model_gateway') AND e.type IN ('model.started','model.completed','model.failed')) OR (e.source='thread_context_compaction' AND e.type IN ('context_compaction.model_completed','context_compaction.model_failed')) - OR (e.source='readonly_fanout' AND e.type IN ('readonly_fanout.shard_completed','readonly_fanout.shard_failed','readonly_fanout.shard_cancelled')) - ORDER BY e.run_id,e.sequence`) + OR (e.source='readonly_fanout' AND e.type IN ('readonly_fanout.shard_completed','readonly_fanout.shard_failed','readonly_fanout.shard_cancelled')))` + if threadID != "" { + receiptQuery += ` AND binding.thread_id=?` + } + rows, err := tx.QueryContext(ctx, receiptQuery+` ORDER BY e.run_id,e.sequence`, scopeArgs...) if err != nil { return result, err } @@ -180,11 +204,15 @@ func (s *SQLiteStore) GetUsageStatistics(ctx context.Context, period string, utc // Cost is independently dated by the actual reservation settlement. The // settled amount can contain a conservative reservation estimate, so only // a priced settlement's actual_micros is a known cost. - rows, err = tx.QueryContext(ctx, `SELECT r.run_id,r.scope,r.attempt_number,r.provider,r.model,r.created_at,r.settled_at,r.settled_micros, + costQuery := `SELECT r.run_id,r.scope,r.attempt_number,r.provider,r.model,r.created_at,r.settled_at,r.settled_micros, COALESCE((SELECT MIN(sequence) FROM run_events WHERE run_id=r.run_id AND source='monetary_budget' AND type='monetary.budget_reserved' AND subject_id=r.id),0), COALESCE(e.payload_json,'{}') FROM run_monetary_reservations r LEFT JOIN run_events e ON e.run_id=r.run_id AND e.sequence=(SELECT MIN(sequence) FROM run_events WHERE run_id=r.run_id AND source='monetary_budget' AND type='monetary.budget_settled' AND subject_id=r.id) - WHERE r.status='settled' ORDER BY r.run_id,r.scope,r.attempt_number`) + WHERE r.status='settled'` + if threadID != "" { + costQuery += ` AND EXISTS (SELECT 1 FROM thread_runs binding WHERE binding.run_id=r.run_id AND binding.thread_id=?)` + } + rows, err = tx.QueryContext(ctx, costQuery+` ORDER BY r.run_id,r.scope,r.attempt_number`, scopeArgs...) if err != nil { return result, err } diff --git a/internal/store/usage_statistics_test.go b/internal/store/usage_statistics_test.go index 607896a8..43359e7e 100644 --- a/internal/store/usage_statistics_test.go +++ b/internal/store/usage_statistics_test.go @@ -1,6 +1,8 @@ package store import ( + "database/sql" + "errors" "fmt" "math" "strings" @@ -8,11 +10,129 @@ import ( "time" "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/events" "cyberagent-workbench/internal/llm" ) +func TestThreadUsageStatisticsAcrossRunsFiltersReceiptsAndCosts(t *testing.T) { + st := openWorkItemTestStore(t) + ctx := t.Context() + runs := application.NewRunService(st) + create := func(goal string) domain.Run { + t.Helper() + _, run, err := runs.Create(ctx, application.CreateRunRequest{Goal: goal, Profile: "review", Interactive: true, Budget: domain.Budget{MaxTurns: 10}}) + if err != nil { + t.Fatal(err) + } + return run + } + first, other := create("usage A"), create("usage B") + if _, err := runs.Start(ctx, first.ID); err != nil { + t.Fatal(err) + } + if _, err := runs.Fail(ctx, first.ID, "continue usage fixture"); err != nil { + t.Fatal(err) + } + threadID := domain.InitialThreadID(first.ID) + next, err := application.NewThreadService(st).Submit(ctx, application.SubmitThreadMessageRequest{ + Version: domain.ThreadMessageProtocolVersion, ThreadID: threadID, Content: "next usage Run", + OperationKey: "usage-successor-0001", RequestedBy: "test_operator"}) + if err != nil { + t.Fatal(err) + } + bindings, err := st.ListThreadRuns(ctx, threadID) + if err != nil || len(bindings) != 2 || bindings[1].RunID != next.Run.ID || bindings[1].PredecessorRunID != first.ID { + t.Fatalf("real Thread continuation did not bind both Runs: %+v %v", bindings, err) + } + now := time.Now().UTC() + old := now.AddDate(0, 0, -40) + const key = int64(1<<61 + 1) + for _, check := range []struct { + run domain.Run + source, kind, subject string + tokens int + at time.Time + }{ + {first, "model_gateway", events.ModelCompletedEvent, "first", 12, old}, + {next.Run, "model_gateway", events.ModelFailedEvent, "next", 24, now}, + {next.Run, "specialist_model_gateway", events.ModelCompletedEvent, "child", 3, now}, + {next.Run, "thread_context_compaction", "context_compaction.model_completed", "compact", 4, now}, + {next.Run, "readonly_fanout", events.ReadOnlyFanoutShardCompletedEvent, "fanout", 5, now}, + {other, "model_gateway", events.ModelCompletedEvent, "other-root", 1000, now}, + {other, "specialist_model_gateway", events.ModelCompletedEvent, "other-child", 1000, now}, + {other, "thread_context_compaction", "context_compaction.model_completed", "other-compact", 1000, now}, + {other, "readonly_fanout", events.ReadOnlyFanoutShardCompletedEvent, "other-fanout", 1000, now}, + } { + usageTestEvent(t, st, check.run, check.source, check.kind, check.subject, check.at, map[string]any{ + "provider": "p", "model": "m", "monetary_attempt_number": key, "execution_id": "fanout", "shard": 1, "attempt": 1, + "usage": llm.Usage{InputTokens: check.tokens, TotalTokens: check.tokens, CacheReadTokens: 1, CacheUsageReported: true}, + }) + } + usageTestReservation(t, st, first, "first-cost", domain.MonetaryScopeRoot, key, old, old, 10, domain.MonetaryPricingPriced) + usageTestReservation(t, st, next.Run, "next-cost", domain.MonetaryScopeRoot, key, now, now, 900, domain.MonetaryPricingUsageUnknown) + usageTestReservation(t, st, next.Run, "child-cost", domain.MonetaryScopeSpecialist, key, now, now, 20, domain.MonetaryPricingPriced) + usageTestReservation(t, st, other, "other-cost", domain.MonetaryScopeRoot, key, now, now, 800, domain.MonetaryPricingPriced) + usageTestReservation(t, st, other, "other-child-cost", domain.MonetaryScopeSpecialist, key, now, now, 900, domain.MonetaryPricingUsageUnknown) + for _, check := range []struct { + period string + calls, tokens, cost, priced int64 + }{ + {"all", 5, 48, 30, 2}, {"30d", 4, 36, 20, 1}, + } { + got, err := st.GetThreadUsageStatistics(ctx, threadID, check.period, 0, now) + if err != nil || got.Totals.Calls != check.calls || got.Totals.TotalTokens != check.tokens || got.Totals.CacheReadTokens != check.calls || + got.Cost.KnownCostMicros != check.cost || got.Cost.PricedCalls != check.priced || got.Cost.UnpricedCalls != 1 || got.Cost.UntrackedCalls != 2 { + t.Fatalf("scoped %s totals/cost changed or leaked: %+v %v", check.period, got, err) + } + if got.UnassignedCalls != 0 || len(got.Models) != 1 || got.Models[0].Usage != got.Totals || + len(got.RecentSessions) != 1 || got.RecentSessions[0].ThreadID != threadID || got.RecentSessions[0].Usage != got.Totals { + t.Fatalf("scoped breakdown leaked another Thread: %+v", got) + } + var daily, activity int64 + for _, day := range got.Days { + daily += day.Usage.TotalTokens + } + for _, day := range got.ActivityDays { + activity += day.TotalTokens + } + if daily != check.tokens || activity != 48 { + t.Fatalf("day or independent activity range leaked: days=%d activity=%d", daily, activity) + } + } + global, err := st.GetUsageStatistics(ctx, "all", 0, now) + if err != nil || global.Totals.TotalTokens != 4048 || global.Cost.KnownCostMicros != 830 || global.Cost.UnpricedCalls != 2 || len(global.RecentSessions) != 2 { + t.Fatalf("original global call changed: %+v %v", global, err) + } + otherStats, err := st.GetThreadUsageStatistics(ctx, domain.InitialThreadID(other.ID), "all", 0, now) + if err != nil || otherStats.Totals.TotalTokens != 4000 || otherStats.Cost.KnownCostMicros != 800 || otherStats.Cost.UnpricedCalls != 1 { + t.Fatalf("other scope reused first Thread: %+v %v", otherStats, err) + } + thread, err := st.GetThread(ctx, threadID) + if err != nil { + t.Fatal(err) + } + archived, err := application.NewThreadService(st).Transition(ctx, threadID, domain.ThreadArchive, thread.Version, "test_operator", "usage-archive-0001") + if err != nil { + t.Fatal(err) + } + if got, err := st.GetThreadUsageStatistics(ctx, threadID, "all", 0, now); err != nil || got.Totals.TotalTokens != 48 { + t.Fatalf("archived usage lost: %+v %v", got, err) + } + if _, err := application.NewThreadService(st).Transition(ctx, threadID, domain.ThreadDelete, archived.Version, "test_operator", "usage-delete-0001"); err != nil { + t.Fatal(err) + } + for _, id := range []string{threadID, "missing-thread"} { + if _, err := st.GetThreadUsageStatistics(ctx, id, "all", 0, now); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("missing/deleted Thread became usage: %q %v", id, err) + } + } + if _, err := st.GetThreadUsageStatistics(ctx, "", "all", 0, now); apperror.CodeOf(err) != apperror.CodeInvalidArgument { + t.Fatalf("empty Thread became global usage: %v", err) + } +} + func usageTestEvent(t *testing.T, st *SQLiteStore, run domain.Run, source, kind, subject string, at time.Time, payload map[string]any) { t.Helper() event, err := events.New(run.ID, run.MissionID, kind, source, subject, payload) @@ -181,7 +301,7 @@ func TestUsageStatisticsCostsUseSettlementDatesAndActualPrices(t *testing.T) { } func TestUsageStatisticsEmptyReadOnlyAndValidation(t *testing.T) { - st, _ := newMonetaryTestStore(t) + st, run := newMonetaryTestStore(t) st.db.SetMaxOpenConns(1) var before, after int64 if err := st.db.QueryRow(`SELECT total_changes()`).Scan(&before); err != nil { @@ -196,6 +316,10 @@ func TestUsageStatisticsEmptyReadOnlyAndValidation(t *testing.T) { if err != nil || result.Totals.Calls != 0 || result.Models == nil || result.Days == nil || result.ActivityDays == nil || result.RecentSessions == nil { t.Fatalf("empty read-only usage: %+v %v", result, err) } + result, err = st.GetThreadUsageStatistics(t.Context(), domain.InitialThreadID(run.ID), period, -300, now) + if err != nil || result.Totals.Calls != 0 || result.Models == nil || result.Days == nil || result.ActivityDays == nil || result.RecentSessions == nil { + t.Fatalf("empty read-only Thread usage: %+v %v", result, err) + } } if err := st.db.QueryRow(`SELECT total_changes()`).Scan(&after); err != nil || after != before { t.Fatalf("usage query mutated the database: %d/%d %v", before, after, err) diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 97ae2278..a1756957 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -31175,6 +31175,8 @@ export interface operations { period?: "today" | "30d" | "all"; /** @description Fixed UTC offset in minutes, without daylight-saving transitions */ utc_offset_minutes?: number; + /** @description Optional exact Thread identity. Restricts all receipt, cost, model, day and activity statistics to every Run bound to this Thread. Omit for application-wide usage. Active and archived Threads are supported; missing or deleted Threads return NOT_FOUND. */ + thread_id?: string; }; header?: never; path?: never; @@ -31199,6 +31201,7 @@ export interface operations { 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; 414: components["responses"]["RequestTooLarge"]; 429: components["responses"]["ResourceExhausted"]; 500: components["responses"]["InternalError"]; diff --git a/web/src/v2/components/settings.tsx b/web/src/v2/components/settings.tsx index 98302ae8..ce1948cb 100644 --- a/web/src/v2/components/settings.tsx +++ b/web/src/v2/components/settings.tsx @@ -502,7 +502,7 @@ export function V2Settings({ client, section, threadID, workspaces, onSelectSect
} {section === "appearance" && } {section === "usage" && {t("正在加载用量统计…", "Loading usage…")}

}> - +
} {section === "environment" && <>

{t("执行环境设置", "Execution environment settings")}

{t("检测用户安装,保存下次启动配置,再回到任务选择编码环境。", "Detect installed environments, save settings for the next startup, then return to your task to choose a coding environment.")}

{t("正在加载执行环境设置…", "Loading execution environment settings…")}

}> diff --git a/web/src/v2/components/usage-settings.test.tsx b/web/src/v2/components/usage-settings.test.tsx index 9df66a5b..271524ff 100644 --- a/web/src/v2/components/usage-settings.test.tsx +++ b/web/src/v2/components/usage-settings.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { APIClient } from "../../api/client"; import type { components } from "../../api/schema"; @@ -27,7 +27,7 @@ const data: UsageStatistics = { beforeEach(() => localStorage.setItem("prayu.locale.v1", "en-US")); afterEach(() => { localStorage.removeItem("prayu.locale.v1"); vi.restoreAllMocks(); }); -function setup(get = vi.fn().mockResolvedValue(data), integrated = false) { +function setup(get = vi.fn().mockResolvedValue(data), integrated = false, threadID = "") { const onOpenThread = vi.fn(); const client = { get } as unknown as APIClient; const queries = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -36,9 +36,9 @@ function setup(get = vi.fn().mockResolvedValue(data), integrated = false) { return ; } render({integrated - ? - : } + : } ); return { get, onOpenThread }; } @@ -51,10 +51,61 @@ it("mounts Usage in Settings, counts cache inside input, and opens the selected expect(screen.getByText(/2 \/ 4 calls reported cache details/)).toHaveTextContent("Calls without token usage: 1."); expect(screen.getByText("1 calls have no conversation and are included in totals.")).toBeInTheDocument(); expect(get).toHaveBeenCalledWith("/usage", { period: "30d", utc_offset_minutes: -new Date().getTimezoneOffset() }, expect.any(AbortSignal)); + expect(screen.getByRole("button", { name: "All chats" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Current chat" })).toBeDisabled(); await userEvent.click(screen.getByRole("button", { name: "Open conversation: 修复设置布局" })); expect(onOpenThread).toHaveBeenCalledExactlyOnceWith("thread-a"); }); +it("passes the current chat through Settings and keeps period and scope independent", async () => { + const { get } = setup(undefined, true, "thread-a"); + await screen.findByRole("region", { name: "Usage overview" }); + await userEvent.click(screen.getByRole("button", { name: "Current chat" })); + await waitFor(() => expect(get).toHaveBeenLastCalledWith("/usage", { + period: "30d", utc_offset_minutes: -new Date().getTimezoneOffset(), thread_id: "thread-a", + }, expect.any(AbortSignal))); + await userEvent.click(screen.getByRole("button", { name: "All time" })); + await waitFor(() => expect(get).toHaveBeenLastCalledWith("/usage", { + period: "all", utc_offset_minutes: -new Date().getTimezoneOffset(), thread_id: "thread-a", + }, expect.any(AbortSignal))); + await userEvent.click(screen.getByRole("button", { name: "All chats" })); + await waitFor(() => expect(get).toHaveBeenLastCalledWith("/usage", { + period: "all", utc_offset_minutes: -new Date().getTimezoneOffset(), + }, expect.any(AbortSignal))); + expect(screen.getByRole("button", { name: "All time" })).toHaveAttribute("aria-pressed", "true"); +}); + +it("isolates in-flight chat responses and returns to global usage when no chat is open", async () => { + let resolveA!: (value: UsageStatistics) => void; + let resolveB!: (value: UsageStatistics) => void; + const pendingA = new Promise((resolve) => { resolveA = resolve; }); + const pendingB = new Promise((resolve) => { resolveB = resolve; }); + const get = vi.fn((_path: string, params: { thread_id?: string }, _signal?: AbortSignal) => + params.thread_id === "thread-a" ? pendingA : params.thread_id === "thread-b" ? pendingB : Promise.resolve(data)); + const client = { get } as unknown as APIClient; + const queries = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = (threadID: string) => + + ; + const rendered = render(view("thread-a")); + await screen.findByRole("region", { name: "Usage overview" }); + await userEvent.click(screen.getByRole("button", { name: "Current chat" })); + await waitFor(() => expect(get).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole("region", { name: "Usage overview" })).not.toBeInTheDocument(); + rendered.rerender(view("thread-b")); + await waitFor(() => expect(get).toHaveBeenCalledTimes(3)); + expect(get.mock.calls[1][2]?.aborted).toBe(true); + await act(async () => resolveB({ ...data, totals: { ...totals, total_tokens: 2400 } })); + expect(within(await screen.findByRole("region", { name: "Usage overview" })).getByText("2,400")).toBeInTheDocument(); + await act(async () => resolveA({ ...data, totals: { ...totals, total_tokens: 1200 } })); + expect(within(screen.getByRole("region", { name: "Usage overview" })).getByText("2,400")).toBeInTheDocument(); + expect(within(screen.getByRole("region", { name: "Usage overview" })).queryByText("1,200")).not.toBeInTheDocument(); + rendered.rerender(view("")); + expect(screen.getByRole("button", { name: "Current chat" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "All chats" })).toHaveAttribute("aria-pressed", "true"); + expect(within(await screen.findByRole("region", { name: "Usage overview" })).getByText("1,500")).toBeInTheDocument(); +}); + it("fetches the selected period and translates the entire dashboard without changing user titles", async () => { const { get } = setup(); await screen.findByRole("region", { name: "Usage overview" }); diff --git a/web/src/v2/components/usage-settings.tsx b/web/src/v2/components/usage-settings.tsx index fc97686d..fc869ad1 100644 --- a/web/src/v2/components/usage-settings.tsx +++ b/web/src/v2/components/usage-settings.tsx @@ -11,13 +11,17 @@ import "./usage-settings.css"; type Period = "today" | "30d" | "all"; type UsageStatistics = components["schemas"]["UsageStatistics"]; -export function UsageSettings({ client, onOpenThread }: { client: APIClient; onOpenThread?: (id: string) => void }) { +export function UsageSettings({ client, threadID = "", onOpenThread }: { client: APIClient; threadID?: string; onOpenThread?: (id: string) => void }) { const { locale, t } = useLocale(); const [period, setPeriod] = useState("30d"); + const [currentChat, setCurrentChat] = useState(false); + const scopedThreadID = currentChat ? threadID : ""; const utcOffsetMinutes = -new Date().getTimezoneOffset(); const query = useQuery({ - queryKey: v2QueryKeys.usage(period, utcOffsetMinutes), - queryFn: ({ signal }) => client.get("/usage", { period, utc_offset_minutes: utcOffsetMinutes }, signal), + queryKey: v2QueryKeys.usage(period, utcOffsetMinutes, scopedThreadID), + queryFn: ({ signal }) => client.get("/usage", { + period, utc_offset_minutes: utcOffsetMinutes, ...(scopedThreadID ? { thread_id: scopedThreadID } : {}), + }, signal), staleTime: 30_000, refetchInterval: 60_000, }); @@ -36,7 +40,13 @@ export function UsageSettings({ client, onOpenThread }: { client: APIClient; onO return

{t("用量统计", "Usage")}

-

{t("Universal-Code 的模型调用与本地费用", "Model usage and local costs in Universal-Code")}

+

{scopedThreadID + ? t("当前聊天所有轮次的模型调用与本地费用", "Model usage and local costs in the current chat") + : t("Universal-Code 的模型调用与本地费用", "Model usage and local costs in Universal-Code")}

+
+ + +
{([ ["today", t("今日", "Today")], ["30d", t("近 30 天", "30 days")], ["all", t("全部", "All time")] ] as const).map(([value, label]) => )} diff --git a/web/src/v2/query-keys.ts b/web/src/v2/query-keys.ts index 1fa898c9..31f9b587 100644 --- a/web/src/v2/query-keys.ts +++ b/web/src/v2/query-keys.ts @@ -10,5 +10,5 @@ export const v2QueryKeys = { approvals: (runID: string) => ["v2", "run", runID, "approvals"] as const, workspaces: ["v2", "workspaces"] as const, inspectorRecords: ["v2", "inspector", "records"] as const, - usage: (period: string, utcOffsetMinutes: number) => ["v2", "usage", period, utcOffsetMinutes] as const, + usage: (period: string, utcOffsetMinutes: number, threadID = "") => ["v2", "usage", period, utcOffsetMinutes, threadID] as const, };