diff --git a/core.ref b/core.ref index 86850e2..a9cdcb9 100644 --- a/core.ref +++ b/core.ref @@ -1 +1 @@ -ea4ea1d83f8c5f61da91bc7c72c8cde0018cd569 +9e738199df77aca481afae83dbff4c3df420af80 diff --git a/frontend/src/lib/features/items/activityAttributionData.test.js b/frontend/src/lib/features/items/activityAttributionData.test.js index 95de6c7..0cdc033 100644 --- a/frontend/src/lib/features/items/activityAttributionData.test.js +++ b/frontend/src/lib/features/items/activityAttributionData.test.js @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { agentOwnerName, + historyTelemetryRunIDs, + isAIChatAttributed, loadAttributedComments, loadAttributedItemHistory, + MAX_HISTORY_TELEMETRY_RUNS, } from "./activityAttributionData.js"; describe("item activity attribution request graph", () => { @@ -38,3 +41,46 @@ describe("item activity attribution request graph", () => { expect(agentOwnerName(history[0])).toBe("History Owner"); }); }); + +// History is delivered newest-first, which is the order the telemetry cap +// consumes. A run id can recur because several field rows share one turn. +function chatGroup(runId) { + return { source: "ai_chat", agent_run_id: runId }; +} + +describe("history telemetry lazy-load scope", () => { + it("keeps only distinct ai-chat runs, newest first", () => { + const ids = historyTelemetryRunIDs([ + chatGroup(101), + chatGroup(101), + { source: "standard_agent", agent_run_id: 200 }, + { source: "ai_chat", agent_run_id: null }, + { source: "", agent_run_id: 300 }, + chatGroup(102), + ]); + + expect(ids).toEqual([101, 102]); + }); + + it("caps the fetchable runs so a long history cannot fan out per row", () => { + const groups = Array.from({ length: 75 }, (_, index) => chatGroup(1000 + index)); + + const ids = historyTelemetryRunIDs(groups); + + expect(MAX_HISTORY_TELEMETRY_RUNS).toBe(20); + expect(ids).toHaveLength(20); + expect(ids).toEqual(Array.from({ length: 20 }, (_, index) => 1000 + index)); + }); + + it("tolerates an empty or missing group list", () => { + expect(historyTelemetryRunIDs([])).toEqual([]); + expect(historyTelemetryRunIDs(undefined)).toEqual([]); + }); + + it("matches only the ai_chat source", () => { + expect(isAIChatAttributed({ source: "ai_chat" })).toBe(true); + expect(isAIChatAttributed({ source: "standard_agent" })).toBe(false); + expect(isAIChatAttributed({})).toBe(false); + expect(isAIChatAttributed(null)).toBe(false); + }); +}); diff --git a/internal/aitools/env_provenance_test.go b/internal/aitools/env_provenance_test.go new file mode 100644 index 0000000..4664548 --- /dev/null +++ b/internal/aitools/env_provenance_test.go @@ -0,0 +1,57 @@ +//go:build test + +package aitools + +import "testing" + +func TestEnvEventMetadataDescribesAgentProvenance(t *testing.T) { + tests := []struct { + name string + env Env + wantSource string + wantRunID int + }{ + { + name: "chat names its surface and run", + env: Env{UserID: 7, Source: SourceAIChat, RunID: 55}, + wantSource: "ai_chat", + wantRunID: 55, + }, + { + name: "unset source falls back to agent", + env: Env{UserID: 7}, + wantSource: "agent", + wantRunID: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metadata := tt.env.eventMetadata() + if metadata.ActorKind != "agent" { + t.Fatalf("actor kind = %q, want agent", metadata.ActorKind) + } + if metadata.ActorRef != "user:7" { + t.Fatalf("actor ref = %q, want user:7", metadata.ActorRef) + } + if metadata.SourceKind != tt.wantSource { + t.Fatalf("source kind = %q, want %q", metadata.SourceKind, tt.wantSource) + } + if metadata.AgentRunID != tt.wantRunID { + t.Fatalf("agent run id = %d, want %d", metadata.AgentRunID, tt.wantRunID) + } + }) + } +} + +func TestEnvHistoryRunIDOnlyReportsRecordedRuns(t *testing.T) { + withRun := Env{UserID: 7, RunID: 55} + if got := withRun.historyRunID(); got == nil || *got != 55 { + t.Fatalf("historyRunID() = %v, want 55", got) + } + + withoutRun := Env{UserID: 7} + if got := withoutRun.historyRunID(); got != nil { + t.Fatalf("historyRunID() = %d, want nil", *got) + } +} diff --git a/internal/handlers/agent_conversation_handler_test.go b/internal/handlers/agent_conversation_handler_test.go index 500c507..2610573 100644 --- a/internal/handlers/agent_conversation_handler_test.go +++ b/internal/handlers/agent_conversation_handler_test.go @@ -56,6 +56,14 @@ func (r fixedChatResolver) ResolveForFeatureWithOverride(string, int) (llm.Clien return r.client, nil } +func (r fixedChatResolver) ResolveWithIdentity(int) (llm.Client, llm.ConnectionDescriptor, error) { + return r.client, llm.ConnectionDescriptor{}, nil +} + +func (r fixedChatResolver) ResolveForFeatureWithIdentity(string, int) (llm.Client, llm.ConnectionDescriptor, error) { + return r.client, llm.ConnectionDescriptor{}, nil +} + func newConversationHandler(t *testing.T) (*AIHandler, *models.User, database.Database, *capturedChatClient) { t.Helper() tdb := testutils.CreateTestDB(t, true) diff --git a/internal/itemevents/itemevents_test.go b/internal/itemevents/itemevents_test.go index c4af449..ff316f1 100644 --- a/internal/itemevents/itemevents_test.go +++ b/internal/itemevents/itemevents_test.go @@ -36,3 +36,48 @@ func TestChangesReturnsStableTypedCanonicalFields(t *testing.T) { t.Fatalf("Changes() = %#v, want %#v", got, want) } } + +func TestNewEventSurfacesAgentRunAsSourceRef(t *testing.T) { + tests := []struct { + name string + metadata Metadata + wantRef string + }{ + { + name: "agent run with no other source", + metadata: func() Metadata { + m := Agent("user:7", "ai_chat") + m.AgentRunID = 55 + return m + }(), + wantRef: "agent_run:55", + }, + { + name: "automation source ref wins over the run link", + metadata: func() Metadata { + m := Agent("user:7", "ai_chat") + m.AgentRunID = 55 + m.SourceRef = "app:jira" + return m + }(), + wantRef: "app:jira", + }, + { + name: "no run leaves the source ref empty", + metadata: Agent("user:7", "mcp"), + wantRef: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event, err := newEvent("item.updated", 3, 9, tt.metadata, map[string]any{"field": "title"}) + if err != nil { + t.Fatalf("newEvent() error = %v", err) + } + if event.SourceRef != tt.wantRef { + t.Fatalf("source ref = %q, want %q", event.SourceRef, tt.wantRef) + } + }) + } +} diff --git a/internal/repository/llm_usage_token_classes_test.go b/internal/repository/llm_usage_token_classes_test.go index 51036fb..547d7c7 100644 --- a/internal/repository/llm_usage_token_classes_test.go +++ b/internal/repository/llm_usage_token_classes_test.go @@ -36,7 +36,41 @@ func TestLLMUsageRepositoryPersistsAndAggregatesTokenClasses(t *testing.T) { totals.CacheReadTokens != 48 || totals.CacheWriteTokens != 12 || totals.ReasoningTokens != 10 || totals.Calls != 2 { t.Fatalf("totals = %+v", totals) } + if totals.Model != "model-a" { + t.Fatalf("model = %q, want model-a", totals.Model) + } if totals.CostUSD == nil || *totals.CostUSD != cost { t.Fatalf("cost = %v, want %v", totals.CostUSD, cost) } } + +func TestLLMUsageRepositorySumsProviderCallsPerRow(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + repo := NewLLMUsageRepository(tdb.GetDatabase()) + + // A chat turn stores one aggregated row covering several provider + // round-trips; a broker row covers exactly one. TotalsForRun must add the + // per-row counts, not count rows. + if err := repo.Insert(context.Background(), LLMUsageRecord{ + RunID: 77, Calls: 6, Model: "model-a", TotalTokens: 900, + }); err != nil { + t.Fatalf("Insert() error = %v", err) + } + if err := repo.Insert(context.Background(), LLMUsageRecord{ + RunID: 77, Model: "model-a", TotalTokens: 100, + }); err != nil { + t.Fatalf("second Insert() error = %v", err) + } + + totals, err := repo.TotalsForRun(context.Background(), 77) + if err != nil { + t.Fatalf("TotalsForRun() error = %v", err) + } + if totals.Calls != 7 { + t.Fatalf("calls = %d, want 7", totals.Calls) + } + if totals.TotalTokens != 1000 { + t.Fatalf("total tokens = %d, want 1000", totals.TotalTokens) + } +} diff --git a/internal/services/item_history_provenance_test.go b/internal/services/item_history_provenance_test.go new file mode 100644 index 0000000..8156a44 --- /dev/null +++ b/internal/services/item_history_provenance_test.go @@ -0,0 +1,91 @@ +//go:build test + +package services + +import ( + "testing" + + "windshift/internal/itemevents" + "windshift/internal/repository" +) + +func TestHistorySourceForAgentStampsOnlyAgentActors(t *testing.T) { + tests := []struct { + name string + metadata itemevents.Metadata + want string + }{ + {name: "ai chat", metadata: itemevents.Agent("user:7", "ai_chat"), want: "ai_chat"}, + {name: "mcp", metadata: itemevents.Agent("user:7", "mcp"), want: "mcp"}, + {name: "standard agent", metadata: itemevents.Agent("user:7", "standard_agent"), want: "standard_agent"}, + {name: "direct user write", metadata: itemevents.User(7, "application"), want: ""}, + {name: "automation", metadata: itemevents.System("automation"), want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := historySourceForAgent(tt.metadata); got != tt.want { + t.Fatalf("historySourceForAgent() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestHistoryRunForAgentLinksOnlyAgentWritesWithARun(t *testing.T) { + withRun := itemevents.Agent("user:7", "ai_chat") + withRun.AgentRunID = 55 + withoutRun := itemevents.Agent("user:7", "mcp") + direct := itemevents.User(7, "application") + + tests := []struct { + name string + metadata itemevents.Metadata + want *int + }{ + {name: "agent run", metadata: withRun, want: intPtr(55)}, + {name: "agent with no run", metadata: withoutRun, want: nil}, + {name: "direct write", metadata: direct, want: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := historyRunForAgent(tt.metadata) + if tt.want == nil { + if got != nil { + t.Fatalf("historyRunForAgent() = %d, want nil", *got) + } + return + } + if got == nil || *got != *tt.want { + t.Fatalf("historyRunForAgent() = %v, want %d", got, *tt.want) + } + }) + } +} + +func TestStampHistorySourceStampsEveryRowInTheBatch(t *testing.T) { + metadata := itemevents.Agent("user:7", "ai_chat") + metadata.AgentRunID = 55 + history := []repository.HistoryEntry{{ItemID: 1, FieldName: "title"}, {ItemID: 1, FieldName: "status_id"}} + + stampHistorySource(history, metadata) + + for i, entry := range history { + if entry.Source != "ai_chat" { + t.Fatalf("entry %d source = %q, want ai_chat", i, entry.Source) + } + if entry.AgentRunID == nil || *entry.AgentRunID != 55 { + t.Fatalf("entry %d agent_run_id = %v, want 55", i, entry.AgentRunID) + } + } +} + +func TestStampHistorySourceLeavesDirectWritesUntouched(t *testing.T) { + history := []repository.HistoryEntry{{ItemID: 1, FieldName: "title"}} + + stampHistorySource(history, itemevents.User(7, "application")) + + if history[0].Source != "" || history[0].AgentRunID != nil { + t.Fatalf("direct write stamped: source=%q agent_run_id=%v", history[0].Source, history[0].AgentRunID) + } +}