diff --git a/internal/domain/service/conversation_service.go b/internal/domain/service/conversation_service.go index 0852b05..91b2068 100644 --- a/internal/domain/service/conversation_service.go +++ b/internal/domain/service/conversation_service.go @@ -176,7 +176,16 @@ func (cs *ConversationService) AddToolResultMessage( return err } - return conversation.AddMessage(*message) + if err := conversation.AddMessage(*message); err != nil { + return err + } + + // Hard-compact mid-turn: if the previous AI response pushed us over the + // threshold, summarize now (after the tool_result is in place, so the + // tool_use/tool_result pair is preserved in the summary) rather than + // waiting for the AI to end the turn without tool calls. + cs.maybeCompact(ctx, sessionID, conversation) + return nil } // ProcessAssistantResponse processes an AI assistant response, handling tools and text. @@ -335,6 +344,26 @@ func (cs *ConversationService) prepareAIRequest( return conversation, messageParams, toolParams, opts, nil } +// maybeCompact runs compaction if the current token total meets the threshold. +// Errors are logged to stderr and swallowed — non-fatal, conversation continues. +func (cs *ConversationService) maybeCompact( + ctx context.Context, + sessionID string, + conversation *entity.Conversation, +) { + if cs.getTokenUsage(sessionID) < cs.compactionThreshold { + return + } + if err := cs.compactConversation(ctx, sessionID, conversation); err != nil { + fmt.Fprintf( + os.Stderr, + "[ConversationService] Auto-compaction failed for session %s (non-fatal, conversation continues): %v\n", + sessionID, + err, + ) + } +} + // finalizeAIResponse adds the AI response to the conversation and updates processing state. // This is shared logic between streaming and non-streaming requests. func (cs *ConversationService) finalizeAIResponse( @@ -356,17 +385,13 @@ func (cs *ConversationService) finalizeAIResponse( cs.processing[sessionID] = hasToolCalls cs.processingMu.Unlock() - // Track token usage and check for compaction (atomic to avoid race between set and check) - shouldCompact := cs.setTokensAndCheckThreshold(sessionID, response.InputTokens, response.OutputTokens) - if !hasToolCalls && shouldCompact { - if err := cs.compactConversation(ctx, sessionID, conversation); err != nil { - fmt.Fprintf( - os.Stderr, - "[ConversationService] Auto-compaction failed for session %s (non-fatal, conversation continues): %v\n", - sessionID, - err, - ) - } + // Update token usage from this response. At this site we only compact when the + // turn has ended naturally (no tool calls), to avoid leaving an assistant + // message with tool_use blocks orphaned from their tool_result. Mid-turn + // compaction (when hasToolCalls is true) fires later in AddToolResultMessage. + cs.setTokensAndCheckThreshold(sessionID, response.InputTokens, response.OutputTokens) + if !hasToolCalls { + cs.maybeCompact(ctx, sessionID, conversation) } return response, toolCalls, nil diff --git a/internal/domain/service/conversation_service_test.go b/internal/domain/service/conversation_service_test.go index 95b8b6e..258b99f 100644 --- a/internal/domain/service/conversation_service_test.go +++ b/internal/domain/service/conversation_service_test.go @@ -2388,6 +2388,186 @@ func TestCompactionNotTriggeredDuringToolCycle(t *testing.T) { } } +// toolCallCompactionMockAIProvider returns a tool-call response with high tokens +// on a regular turn, and a summary response when called by compactConversation. +// Summary calls are detected by matching the prompt produced by buildSummaryRequest +// rather than coupling to the tools-nil implementation detail. +// summaryErr is returned instead of a summary response when set. +type toolCallCompactionMockAIProvider struct { + mockAIProvider + + turnCalls int + summaryCalls int + inputTokens int64 + outputTokens int64 + toolCalls []port.ToolCallInfo + summaryResponse string + summaryErr error +} + +func (m *toolCallCompactionMockAIProvider) SendMessage( + _ context.Context, + messages []port.MessageParam, + _ []port.ToolParam, + _ port.AIRequestOptions, +) (*entity.Message, []port.ToolCallInfo, error) { + // A compaction summary request is the single-user-message prompt built by + // buildSummaryRequest, which contains the literal "CONVERSATION TO SUMMARIZE". + if len(messages) > 0 && strings.Contains(messages[0].Content, "CONVERSATION TO SUMMARIZE") { + m.summaryCalls++ + if m.summaryErr != nil { + return nil, nil, m.summaryErr + } + resp := &entity.Message{ + Role: entity.RoleAssistant, + Content: m.summaryResponse, + } + resp.InputTokens = 500 + resp.OutputTokens = 500 + return resp, nil, nil + } + + m.turnCalls++ + resp := &entity.Message{ + Role: entity.RoleAssistant, + Content: "Calling tool", + } + resp.InputTokens = m.inputTokens + resp.OutputTokens = m.outputTokens + return resp, m.toolCalls, nil +} + +func TestCompactionTriggeredAfterToolResults(t *testing.T) { + provider := &toolCallCompactionMockAIProvider{ + inputTokens: 6000, + outputTokens: 6000, + summaryResponse: "Summary after tool cycle.", + toolCalls: []port.ToolCallInfo{ + {ToolID: "tool_1", ToolName: "list_files", Input: map[string]interface{}{"path": "."}}, + }, + } + + // 6000+6000=12000 > 10000 threshold + svc, err := NewConversationService(provider, &mockToolExecutor{}, int64(10000)) + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + ctx := context.Background() + sessionID, _ := svc.StartConversation(ctx) + + _, _ = svc.AddUserMessage(ctx, sessionID, "List files") + _, toolCalls, err := svc.ProcessAssistantResponse(ctx, sessionID) + if err != nil { + t.Fatalf("Response failed: %v", err) + } + if len(toolCalls) == 0 { + t.Fatal("Expected tool calls from first response") + } + + // After AI response with tool calls: compaction must NOT have fired yet + // (the assistant message has tool_use blocks awaiting matching tool_result). + conv, _ := svc.GetConversation(sessionID) + if conv.MessageCount() < 2 { + t.Fatalf("Expected at least 2 messages before tool results added, got %d", conv.MessageCount()) + } + lastMsg, _ := conv.GetLastMessage() + if strings.Contains(lastMsg.Content, "[CONVERSATION SUMMARY") { + t.Fatal("Compaction must not trigger before tool results are added") + } + + // Now add the matching tool_result. This is where mid-turn hard-compaction must fire. + toolResults := []entity.ToolResult{ + {ToolID: "tool_1", Result: "file1.txt\nfile2.txt", IsError: false}, + } + if err := svc.AddToolResultMessage(ctx, sessionID, toolResults); err != nil { + t.Fatalf("AddToolResultMessage failed: %v", err) + } + + conv, _ = svc.GetConversation(sessionID) + if conv.MessageCount() != 1 { + t.Errorf("Expected 1 message after compaction, got %d", conv.MessageCount()) + } + lastMsg, ok := conv.GetLastMessage() + if !ok { + t.Fatal("Expected a summary message") + } + if !strings.Contains(lastMsg.Content, "[CONVERSATION SUMMARY - Auto-compacted]") { + t.Errorf("Expected summary marker, got: %s", lastMsg.Content) + } + if !strings.Contains(lastMsg.Content, "Summary after tool cycle.") { + t.Errorf("Expected summary content, got: %s", lastMsg.Content) + } + + // Exactly one turn call + one summary call. A second summary call would mean + // compaction fired twice, or a second turn call would mean an unexpected AI hit. + if provider.turnCalls != 1 { + t.Errorf("Expected 1 turn call, got %d", provider.turnCalls) + } + if provider.summaryCalls != 1 { + t.Errorf("Expected 1 summary call, got %d", provider.summaryCalls) + } + + // Token counter must be reset to the summary response's tokens (500+500=1000). + // If not reset, the next AI call would immediately re-trigger compaction. + tokens, threshold := svc.GetTokenUsage(sessionID) + if tokens != 1000 { + t.Errorf("Expected token usage reset to 1000 after compaction, got %d", tokens) + } + if threshold != 10000 { + t.Errorf("Expected threshold 10000, got %d", threshold) + } +} + +func TestCompactionFailureInAddToolResultIsNonFatal(t *testing.T) { + provider := &toolCallCompactionMockAIProvider{ + inputTokens: 6000, + outputTokens: 6000, + toolCalls: []port.ToolCallInfo{ + {ToolID: "tool_1", ToolName: "list_files", Input: map[string]interface{}{"path": "."}}, + }, + summaryErr: errors.New("summary provider unavailable"), + } + + svc, err := NewConversationService(provider, &mockToolExecutor{}, int64(10000)) + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + ctx := context.Background() + sessionID, _ := svc.StartConversation(ctx) + + _, _ = svc.AddUserMessage(ctx, sessionID, "List files") + if _, _, err := svc.ProcessAssistantResponse(ctx, sessionID); err != nil { + t.Fatalf("Response failed: %v", err) + } + + toolResults := []entity.ToolResult{ + {ToolID: "tool_1", Result: "ok", IsError: false}, + } + // Compaction attempt fails, but AddToolResultMessage must still succeed (non-fatal). + if err := svc.AddToolResultMessage(ctx, sessionID, toolResults); err != nil { + t.Fatalf("AddToolResultMessage should be non-fatal on compaction failure, got: %v", err) + } + + conv, _ := svc.GetConversation(sessionID) + if conv.MessageCount() < 3 { + t.Errorf("Expected conversation intact (>=3 messages) when compaction fails, got %d", conv.MessageCount()) + } + lastMsg, _ := conv.GetLastMessage() + if strings.Contains(lastMsg.Content, "[CONVERSATION SUMMARY") { + t.Error("Failed compaction must not leave a summary marker behind") + } + + // Exactly one turn call and one summary attempt (which errored). + if provider.turnCalls != 1 { + t.Errorf("Expected 1 turn call, got %d", provider.turnCalls) + } + if provider.summaryCalls != 1 { + t.Errorf("Expected 1 summary call attempt, got %d", provider.summaryCalls) + } +} + func TestCompactionCleanup(t *testing.T) { svc, err := NewConversationService(&mockAIProvider{}, &mockToolExecutor{}, int64(160000)) if err != nil {