mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd/x/chatd): fix compaction still over limit check (#26377)
Addresses [CODAGT-620](https://linear.app/codercom/issue/CODAGT-620/session-can-get-stuck-at-compaction-with-request-failed). We have logic that checks whether message compaction still leaves the chat over the context limit. We want to abort if it does - if we didn't, we'd get into an endless compaction loop. The check's logic was faulty. This PR changes fixes it. The new flow is: 1. In iteration 1, a chat runner commits a message compaction summary. 2. In iteration 2, the runner submits the newly compacted conversation to the LLM provider in order to generate the next message. 3. In iteration 3, 4, 5, etc., if the conversation needs compaction, the runner looks up the configured context limit and the first assistant message after the last compaction summary. It compares the context usage on that message with the context limit. If the usage is over the limit, it returns an error.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user