diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 9873e91161..5422565998 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -5738,11 +5738,12 @@ func TestActiveServer_Compaction(t *testing.T) { requireTextPart(t, messages[len(messages)-1], "done without compaction") }) - t.Run("fails when compaction leaves chat over limit", func(t *testing.T) { + t.Run("next message fails when compaction continuation stayed over limit", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) db, ps := dbtestutil.NewDB(t) + logSink := testutil.NewFakeSink(t) var streamCount atomic.Int32 anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { body := anthropicRequestBody(t, *req) @@ -5771,7 +5772,10 @@ func TestActiveServer_Compaction(t *testing.T) { Return(workspacesdk.ReadFileLinesResponse{Success: true, FileSize: 12, TotalLines: 1, LinesRead: 1, Content: "1 package main"}, nil). Times(1) + reg := prometheus.NewRegistry() server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.Logger = logSink.Logger() + cfg.PrometheusRegistry = reg cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { require.Equal(t, dbAgent.ID, agentID) return mockConn, func() {}, nil @@ -5783,15 +5787,52 @@ func TestActiveServer_Compaction(t *testing.T) { APIKeyID: testAPIKeyID(t, db, user.ID), WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, - Title: "compaction-still-over-limit", + Title: "compaction-next-message-over-limit", ModelConfigID: model.ID, InitialUserContent: []codersdk.ChatMessagePart{ codersdk.ChatMessageText("read the file and stay too large"), }, }) require.NoError(t, err) + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.False(t, chat.LastError.Valid) + require.Equal(t, int32(2), streamCount.Load()) + messages := chatMessages(ctx, t, db, chat.ID) + requireTextPart(t, messages[len(messages)-1], "still too large") + + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + ModelConfigID: model.ID, + Content: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("continue after the large compacted turn"), + }, + }) + require.NoError(t, err) + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) - require.Contains(t, chatLastErrorMessage(chat.LastError), "The chat request failed unexpectedly.") + require.Equal(t, + "Conversation compaction could not reduce the history below the configured limit. Raise the compaction limit in settings, or start a new conversation.", + chatLastErrorMessage(chat.LastError), + ) + require.Equal(t, int32(2), streamCount.Load(), "over-limit history should fail before another model stream") + requireChatdMetricCounter(t, reg, "coderd_chatd_compaction_total", 1, map[string]string{ + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "result": "error", + }) + + isCompactionFailureLog := func(e slog.SinkEntry) bool { + if e.Level != slog.LevelWarn || e.Message != "chat generation failed" { + return false + } + errValue, ok := sinkFieldValue(e.Fields, "error") + return ok && strings.Contains(fmt.Sprintf("%v", errValue), "compaction left the chat above the compaction limit") + } + testutil.Eventually(ctx, t, func(context.Context) bool { + return len(logSink.Entries(isCompactionFailureLog)) > 0 + }, testutil.IntervalFast) }) } diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 43b255262e..dbc276aabf 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -129,21 +129,19 @@ const ( generationFinishReasonMaxSteps generationFinishReason = "max_steps" ) -type compactionTrigger string - -const ( - compactionTriggerRequired compactionTrigger = "required" - compactionTriggerAlreadyCompacted compactionTrigger = "already_compacted" +var errCompactionStillOverLimit = chaterror.WithClassification( + xerrors.New("compaction left the chat above the compaction limit"), + chaterror.ClassifiedError{ + Message: "Conversation compaction could not reduce the history below the configured limit. Raise the compaction limit in settings, or start a new conversation.", + Kind: codersdk.ChatErrorKindConfig, + }, ) -var errCompactionStillOverLimit = xerrors.New("compaction left the chat above the compaction limit") - type generationDecision struct { kind generationActionKind localToolCalls []fantasy.ToolCallContent pendingDynamicToolCalls []pendingDynamicToolCall finishReason generationFinishReason - compactionTrigger compactionTrigger promotedMessageID int64 } @@ -181,15 +179,17 @@ func isTerminalGeneration(err error) bool { } type generationDecisionInput struct { - chat database.Chat - messages []database.ChatMessage - dynamicToolNames map[string]bool - exclusiveToolNames map[string]bool - stopAfterTools map[string]struct{} - maxSteps int - compactionEnabled bool - compactionNeeded bool - workspaceContextEligible bool + chat database.Chat + messages []database.ChatMessage + dynamicToolNames map[string]bool + exclusiveToolNames map[string]bool + stopAfterTools map[string]struct{} + maxSteps int + compactionEnabled bool + compactionNeeded bool + compactionThresholdPercent int32 + compactionContextLimit int64 + workspaceContextEligible bool } // shouldPersistWorkspaceContext reports whether the committed chat @@ -265,11 +265,11 @@ func decideGenerationAction(input generationDecisionInput) (generationDecision, if input.compactionEnabled && input.compactionNeeded { compactionRequirement = compactionRequirementNeeded } - switch compactionStatusFromHistory(input.messages, compactionRequirement) { + switch compactionStatusFromHistory(input.messages, compactionRequirement, input.compactionThresholdPercent, input.compactionContextLimit) { case compactionStatusNeeded: - return generationDecision{kind: generationActionCompact, compactionTrigger: compactionTriggerRequired}, nil + return generationDecision{kind: generationActionCompact}, nil case compactionStatusAfterCompaction: - return generationDecision{kind: generationActionGenerateAssistant, compactionTrigger: compactionTriggerAlreadyCompacted}, nil + return generationDecision{kind: generationActionGenerateAssistant}, nil case compactionStatusStillOverLimit: return generationDecision{}, terminalGeneration(errCompactionStillOverLimit) case compactionStatusNotNeeded: @@ -279,6 +279,13 @@ func decideGenerationAction(input generationDecisionInput) (generationDecision, } } +func generationCompactionThreshold(compaction *generationCompaction) int32 { + if compaction == nil { + return 0 + } + return compaction.Options.ThresholdPercent +} + func unresolvedToolCallsFromHistory( messages []database.ChatMessage, dynamicToolNames map[string]bool, @@ -361,15 +368,17 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS cleanup := prepared.Cleanup decision, err := retryGenerationPhase(ctx, s.waitGenerationPhaseBackoff, func() (generationDecision, error) { return decideGenerationAction(generationDecisionInput{ - chat: prepared.Chat, - messages: prepared.Messages, - dynamicToolNames: prepared.DynamicToolNames, - exclusiveToolNames: prepared.ExclusiveToolNames, - stopAfterTools: prepared.StopAfterTools, - maxSteps: prepared.MaxSteps, - compactionEnabled: prepared.Compaction != nil, - compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required, - workspaceContextEligible: prepared.WorkspaceContextEligible, + chat: prepared.Chat, + messages: prepared.Messages, + dynamicToolNames: prepared.DynamicToolNames, + exclusiveToolNames: prepared.ExclusiveToolNames, + stopAfterTools: prepared.StopAfterTools, + maxSteps: prepared.MaxSteps, + compactionEnabled: prepared.Compaction != nil, + compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required, + compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction), + compactionContextLimit: prepared.ContextLimitFallback, + workspaceContextEligible: prepared.WorkspaceContextEligible, }) }) if err != nil { @@ -377,6 +386,14 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS if errors.Is(err, errTaskExpectedExit) { return errTaskExpectedExit } + if errors.Is(err, errCompactionStillOverLimit) && prepared.Compaction != nil { + s.server.metrics.RecordCompaction( + compactionProvider(prepared.Compaction.Options), + compactionModel(prepared.Compaction.Options), + false, + errCompactionStillOverLimit, + ) + } return s.finishGenerationError(ctx, machine, input, 0, err, generationAttemptNotRequired) } @@ -389,7 +406,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS cleanup() return s.finishGenerationTurn(ctx, machine, input, 0, decision, generationAttemptNotRequired) case generationActionGenerateAssistant: - actionErr = s.generateAssistant(ctx, machine, input, prepared, decision) + actionErr = s.generateAssistant(ctx, machine, input, prepared) case generationActionExecuteLocalTools: actionErr = s.executeLocalTools(ctx, machine, input, prepared, decision) case generationActionCompact: @@ -600,7 +617,6 @@ func (s *taskStarter) generateAssistant( machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, prepared generationPrepared, - decision generationDecision, ) error { attempt, _, publish, closeEpisode, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { @@ -625,12 +641,6 @@ func (s *taskStarter) generateAssistant( if err != nil { return err } - if decision.compactionTrigger == compactionTriggerAlreadyCompacted && - shouldCompactPromptUsage(outcome.Step.Usage, prepared.ContextLimitFallback, prepared.Compaction.Options.ThresholdPercent) { - err := errCompactionStillOverLimit - s.server.metrics.RecordCompaction(compactionProvider(prepared.Compaction.Options), compactionModel(prepared.Compaction.Options), false, err) - return s.finishGenerationError(ctx, machine, input, attempt, err, generationAttemptRequired) - } if len(outcome.Step.Content) == 0 { return s.finishGenerationTurn(ctx, machine, input, attempt, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, generationAttemptRequired) } diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 459457f524..215f0b096a 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -609,14 +609,7 @@ func (server *Server) prepareGeneration( func latestPromptUsage(messages []database.ChatMessage) fantasy.Usage { for i := len(messages) - 1; i >= 0; i-- { - usage := fantasy.Usage{ - InputTokens: messages[i].InputTokens.Int64, - OutputTokens: messages[i].OutputTokens.Int64, - TotalTokens: messages[i].TotalTokens.Int64, - ReasoningTokens: messages[i].ReasoningTokens.Int64, - CacheCreationTokens: messages[i].CacheCreationTokens.Int64, - CacheReadTokens: messages[i].CacheReadTokens.Int64, - } + usage := usageFromMessage(messages[i]) if usage != (fantasy.Usage{}) { return usage } diff --git a/coderd/x/chatd/message_conversion.go b/coderd/x/chatd/message_conversion.go index b587836151..2771582c15 100644 --- a/coderd/x/chatd/message_conversion.go +++ b/coderd/x/chatd/message_conversion.go @@ -363,18 +363,32 @@ const ( compactionRequirementNeeded ) -func compactionStatusFromHistory(messages []database.ChatMessage, requirement compactionRequirement) compactionStatus { +func compactionStatusFromHistory( + messages []database.ChatMessage, + requirement compactionRequirement, + thresholdPercent int32, + contextLimit int64, +) compactionStatus { boundaryIndex := latestCompactionBoundaryIndex(messages) if requirement == compactionRequirementNeeded { if boundaryIndex == -1 { return compactionStatusNeeded } - if hasUncompressedAssistantAfter(messages, boundaryIndex) { + // The first assistant response after the previously compacted summary. + // Messages with role ChatMessageRoleAssistant carry context usage. + // Looking at ChatMessageRoleAssistant is enough - ChatMessageRoleTool + // does not carry context usage, and is always preceded by an assistant + // message. + if assistant, ok := firstUncompressedAssistantAfter(messages, boundaryIndex); ok && + postCompactionAssistantOverLimit(assistant, thresholdPercent, contextLimit) { return compactionStatusStillOverLimit } + if hasUncompressedMessageAfter(messages, boundaryIndex) { + return compactionStatusNeeded + } return compactionStatusAfterCompaction } - if boundaryIndex != -1 && !hasUncompressedAssistantAfter(messages, boundaryIndex) { + if boundaryIndex != -1 && !hasUncompressedMessageAfter(messages, boundaryIndex) { return compactionStatusAfterCompaction } return compactionStatusNotNeeded @@ -406,19 +420,56 @@ func isCompactionBoundaryMessage(msg database.ChatMessage) bool { return false } -func hasUncompressedAssistantAfter(messages []database.ChatMessage, index int) bool { +func firstUncompressedAssistantAfter(messages []database.ChatMessage, index int) (database.ChatMessage, bool) { for i := index + 1; i < len(messages); i++ { msg := messages[i] if msg.Deleted || msg.Compressed { continue } if msg.Role == database.ChatMessageRoleAssistant { + return msg, true + } + } + return database.ChatMessage{}, false +} + +func hasUncompressedMessageAfter(messages []database.ChatMessage, index int) bool { + for i := index + 1; i < len(messages); i++ { + msg := messages[i] + if !msg.Deleted && !msg.Compressed { return true } } return false } +func postCompactionAssistantOverLimit(msg database.ChatMessage, thresholdPercent int32, contextLimit int64) bool { + return shouldCompactPromptUsage(usageFromMessage(msg), contextLimit, thresholdPercent) +} + +func usageFromMessage(msg database.ChatMessage) fantasy.Usage { + var usage fantasy.Usage + if msg.InputTokens.Valid { + usage.InputTokens = msg.InputTokens.Int64 + } + if msg.OutputTokens.Valid { + usage.OutputTokens = msg.OutputTokens.Int64 + } + if msg.TotalTokens.Valid { + usage.TotalTokens = msg.TotalTokens.Int64 + } + if msg.ReasoningTokens.Valid { + usage.ReasoningTokens = msg.ReasoningTokens.Int64 + } + if msg.CacheCreationTokens.Valid { + usage.CacheCreationTokens = msg.CacheCreationTokens.Int64 + } + if msg.CacheReadTokens.Valid { + usage.CacheReadTokens = msg.CacheReadTokens.Int64 + } + return usage +} + func historyHasStopAfterToolResult(messages []database.ChatMessage, stopAfterTools map[string]struct{}) (bool, error) { if len(stopAfterTools) == 0 { return false, nil diff --git a/coderd/x/chatd/message_conversion_test.go b/coderd/x/chatd/message_conversion_test.go index 5469901e10..c9566048bf 100644 --- a/coderd/x/chatd/message_conversion_test.go +++ b/coderd/x/chatd/message_conversion_test.go @@ -272,6 +272,134 @@ func TestCurrentTurnStepCount_CountsAssistantMessagesAfterLatestUser(t *testing. require.Equal(t, 2, got) } +func TestDecisionCompactsAgainAfterPostCompactionTurn(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("initial request")), + dbMessage(t, 2, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("compacted summary")), + dbMessage(t, 3, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 4, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("continued after compaction")), + dbMessage(t, 6, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("next request")), + } + + decision, err := decideGenerationAction(generationDecisionInput{ + messages: messages, + compactionEnabled: true, + compactionNeeded: true, + compactionThresholdPercent: 70, + compactionContextLimit: 100, + }) + require.NoError(t, err) + require.Equal(t, generationActionCompact, decision.kind) +} + +func TestCompactionStatusFromHistory(t *testing.T) { + t.Parallel() + + const thresholdPercent = int32(70) + + t.Run("needed without boundary", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("start")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("read-1", "read_file", json.RawMessage(`{}`))), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusNeeded, got) + }) + + t.Run("after compaction without post boundary history", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusAfterCompaction, got) + }) + + t.Run("needed after under limit post compaction assistant", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + withUsage(dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("continued")), 20, 100), + dbMessage(t, 5, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("next")), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusNeeded, got) + }) + + t.Run("still over limit from first post compaction assistant usage", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + withUsage(dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("read-1", "read_file", json.RawMessage(`{}`))), 80, 100), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusStillOverLimit, got) + }) + + t.Run("still over limit includes prompt cache tokens", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + withUsageTokens(dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("read-1", "read_file", json.RawMessage(`{}`))), fantasy.Usage{CacheReadTokens: 80}, 100), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusStillOverLimit, got) + }) + + t.Run("still over limit uses configured context limit", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + withUsage(dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("read-1", "read_file", json.RawMessage(`{}`))), 80, 200), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusStillOverLimit, got) + + got = compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 200) + require.Equal(t, compactionStatusNeeded, got) + }) + + t.Run("still over limit includes exact threshold boundary", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + withUsage(dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("read-1", "read_file", json.RawMessage(`{}`))), 70, 100), + } + + got := compactionStatusFromHistory(messages, compactionRequirementNeeded, thresholdPercent, 100) + require.Equal(t, compactionStatusStillOverLimit, got) + }) +} + func TestDecisionDetectsStopAfterToolFromCommittedHistory(t *testing.T) { t.Parallel() @@ -494,6 +622,21 @@ func dbMessage(t *testing.T, id int64, role database.ChatMessageRole, compressed } } +func withUsage(msg database.ChatMessage, inputTokens int64, contextLimit int64) database.ChatMessage { + return withUsageTokens(msg, fantasy.Usage{InputTokens: inputTokens, TotalTokens: inputTokens}, contextLimit) +} + +func withUsageTokens(msg database.ChatMessage, usage fantasy.Usage, contextLimit int64) database.ChatMessage { + msg.InputTokens = sql.NullInt64{Int64: usage.InputTokens, Valid: usage.InputTokens != 0} + msg.OutputTokens = sql.NullInt64{Int64: usage.OutputTokens, Valid: usage.OutputTokens != 0} + msg.TotalTokens = sql.NullInt64{Int64: usage.TotalTokens, Valid: usage.TotalTokens != 0} + msg.ReasoningTokens = sql.NullInt64{Int64: usage.ReasoningTokens, Valid: usage.ReasoningTokens != 0} + msg.CacheCreationTokens = sql.NullInt64{Int64: usage.CacheCreationTokens, Valid: usage.CacheCreationTokens != 0} + msg.CacheReadTokens = sql.NullInt64{Int64: usage.CacheReadTokens, Valid: usage.CacheReadTokens != 0} + msg.ContextLimit = sql.NullInt64{Int64: contextLimit, Valid: contextLimit != 0} + return msg +} + func requireNotNilTime(t *testing.T, value *time.Time) time.Time { t.Helper() require.NotNil(t, value)