mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
c7dd11e03e
* fix(llm): preserve provider reasoning/thinking across assistant turns Assistant tool-call history was rebuilt from Content()/ToolCalls() alone, so provider reasoning state was dropped on replay across all three supported protocols: - OpenAI-compatible Chat Completions: reasoning_content was parsed but never sent back on the next request. - Anthropic Messages: thinking/redacted_thinking blocks were flattened to a display string, losing the signature required to replay them. - OpenAI Responses: reasoning items (encrypted_content) were dropped entirely from the next request's input. Adds Message.Native (NativeTurn), an opaque per-protocol replay payload built by reusing each SDK's own response->request converter (anthropic.Message.ToParam(), each Responses output item's ToParam()) so the wire format is never hand-rolled. Each adapter type-asserts its own payload before reuse and falls back to the existing reconstruction otherwise, so cross-provider or native-less turns are unaffected. Also: - Adds Message.ReasoningContent, a plain-text projection used by the compression summarizer so a reasoning-only turn isn't rendered blank. - Fixes NativeTurn.EstimatedTokens() double-counting visible content already counted via ExtractText(), which could trigger compression earlier than necessary. - Guards the Responses adapter against carrying a dangling reasoning item with no accompanying message/function_call into Native. - Persists reasoning_content in the session JSONL transcript and surfaces it in the session viewer, for audit visibility into no-visible-answer turns. - Makes Anthropic extended thinking (extra_body.thinking) safe to enable via the existing provider config: dropped per-request when it would conflict with a forced tool_choice or exceed that call's max_tokens, rather than failing the whole request. - Removes GroupingTask's hardcoded 4096 max_tokens cap in favor of the template's own completion limit, which was the actual trigger for the max_tokens/budget_tokens conflict above. Refs #805 * fix(llm): tighten follow-ups from reasoning/thinking replay fix - Compare Anthropic extended thinking's budget_tokens against the effective max_tokens (falling back to the same 8192 default buildAnthropicParams uses) instead of the raw, possibly-zero ChatRequest.MaxTokens, so the guard doesn't get skipped when the caller leaves MaxTokens unset. - Preserve a degenerate turn in history when it has reasoning text but neither visible content nor a replayable Native payload (e.g. an openai-responses reasoning item with no accompanying message/function_call), instead of dropping all trace of it. - Unify token accounting between CountMessagesTokens and computeActiveZoneSize via a shared messageTokens() helper, so reasoning-heavy conversations don't get judged compressible by one and still-over-budget by the other. - Only write reasoning_content to the session JSONL transcript when non-empty, matching the existing pattern for comments/error/ sourceSessionId. * fix(session): persist LLM native replay payload for training reflow Session JSONL carried a human-readable ReasoningContent projection but dropped the opaque replay state (llm.Message.Native) that holds an Anthropic thinking block's signature or an OpenAI Responses reasoning item's encrypted_content — the fields a full session export/training reflow actually needs. - ResponseRecord gains a Native field; SetResponse populates it from resp.Native() and persists it verbatim as native_payload on the llm_response JSONL record. - copyMessagesForJSON now includes tool_calls (previously dropped) and native_payload per assistant message, so llm_request records are a complete, replayable projection of what was actually sent. - copyMessages (the in-memory deep copy backing TaskRecord.RequestMessages) now also carries Native/ReasoningContent, closing a gap where its own "deep copy" contract silently dropped both fields. Nothing parses or reshapes the payload on the way to disk — it is written exactly as the provider adapter would replay it. * refactor: trim verbose comments and add VisibleContent() unit tests - Remove redundant 'what' comments and Go-basics explanations across the NativeTurn/reasoning replay implementation - Keep only non-obvious 'why' comments (external API constraints, aliasing hazards, fallback semantics) - Add 6 direct unit tests for ChatResponse.VisibleContent() covering: no choices, nil content, empty string, normal text, think-tag stripping, and whitespace-only (key difference from Content()) Net: -185 lines of comments, +66 lines of tests. * docs: clarify json:"-" on ResponseMessage.Native * style: fix gofmt alignment in client.go and history.go
251 lines
7.0 KiB
Go
251 lines
7.0 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package llmloop
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/alibaba/open-code-review/internal/llm"
|
|
)
|
|
|
|
func msg(role, text string) llm.Message {
|
|
return llm.NewTextMessage(role, text)
|
|
}
|
|
|
|
func TestCountMessagesTokens(t *testing.T) {
|
|
msgs := []llm.Message{
|
|
msg("user", "hello world"),
|
|
msg("assistant", "hi there"),
|
|
}
|
|
got := CountMessagesTokens(msgs)
|
|
if got <= 0 {
|
|
t.Errorf("expected positive token count, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestCountMessagesTokens_Empty(t *testing.T) {
|
|
got := CountMessagesTokens(nil)
|
|
if got != 0 {
|
|
t.Errorf("expected 0 for nil, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestCountMessagesTokens_IncludesNativePayload guards a gap found in review
|
|
// of the #805 fix: once an assistant turn's Native payload (thinking blocks,
|
|
// reasoning items, encrypted_content, reasoning_content) actually replays on
|
|
// the wire, a token budget computed only from ExtractText() would
|
|
// systematically under-count reasoning-heavy conversations and could let
|
|
// compression's threshold checks fire too late.
|
|
func TestCountMessagesTokens_IncludesNativePayload(t *testing.T) {
|
|
withoutNative := []llm.Message{msg("user", "hello world")}
|
|
withNative := []llm.Message{
|
|
msg("user", "hello world"),
|
|
llm.NewToolCallMessage("", nil, llm.NativeTurn{
|
|
Family: "openai-chat-completions",
|
|
Payload: llm.ReasoningPayload(strings.Repeat("reasoning ", 200)),
|
|
}, ""),
|
|
}
|
|
|
|
base := CountMessagesTokens(withoutNative)
|
|
got := CountMessagesTokens(withNative)
|
|
if got <= base {
|
|
t.Errorf("CountMessagesTokens with a Native payload = %d, want more than the base count %d", got, base)
|
|
}
|
|
}
|
|
|
|
func TestGroupIntoRounds(t *testing.T) {
|
|
messages := []llm.Message{
|
|
msg("system", "sys"),
|
|
msg("user", "prompt"),
|
|
msg("assistant", "resp1"),
|
|
msg("tool", "result1"),
|
|
msg("tool", "result2"),
|
|
msg("assistant", "resp2"),
|
|
msg("tool", "result3"),
|
|
msg("assistant", "resp3"),
|
|
}
|
|
|
|
rounds := groupIntoRounds(messages, 2)
|
|
if len(rounds) != 3 {
|
|
t.Fatalf("expected 3 rounds, got %d", len(rounds))
|
|
}
|
|
|
|
if rounds[0].assistantIdx != 2 {
|
|
t.Errorf("round[0].assistantIdx = %d, want 2", rounds[0].assistantIdx)
|
|
}
|
|
if len(rounds[0].toolIdxs) != 2 {
|
|
t.Errorf("round[0] should have 2 tool messages, got %d", len(rounds[0].toolIdxs))
|
|
}
|
|
if rounds[1].assistantIdx != 5 {
|
|
t.Errorf("round[1].assistantIdx = %d, want 5", rounds[1].assistantIdx)
|
|
}
|
|
if rounds[2].assistantIdx != 7 {
|
|
t.Errorf("round[2].assistantIdx = %d, want 7", rounds[2].assistantIdx)
|
|
}
|
|
if len(rounds[2].toolIdxs) != 0 {
|
|
t.Errorf("round[2] should have 0 tool messages")
|
|
}
|
|
}
|
|
|
|
func TestGroupIntoRounds_NoAssistant(t *testing.T) {
|
|
messages := []llm.Message{
|
|
msg("system", "sys"),
|
|
msg("user", "prompt"),
|
|
msg("user", "another"),
|
|
}
|
|
rounds := groupIntoRounds(messages, 2)
|
|
if len(rounds) != 0 {
|
|
t.Errorf("expected 0 rounds, got %d", len(rounds))
|
|
}
|
|
}
|
|
|
|
func TestPartitionMessages_ShortConversation(t *testing.T) {
|
|
messages := []llm.Message{
|
|
msg("system", "sys"),
|
|
msg("user", "prompt"),
|
|
}
|
|
result := partitionMessages(messages, 100000, 0)
|
|
if result.frozenEnd != 2 {
|
|
t.Errorf("frozenEnd = %d, want 2", result.frozenEnd)
|
|
}
|
|
if result.compressEnd != 2 {
|
|
t.Errorf("compressEnd = %d, want 2", result.compressEnd)
|
|
}
|
|
}
|
|
|
|
func TestPartitionMessages_EverythingFits(t *testing.T) {
|
|
messages := []llm.Message{
|
|
msg("system", "sys"),
|
|
msg("user", "prompt"),
|
|
msg("assistant", "short reply"),
|
|
msg("tool", "ok"),
|
|
}
|
|
result := partitionMessages(messages, 100000, 0)
|
|
if result.activeCount != 0 {
|
|
t.Errorf("activeCount = %d, want 0 (everything fits)", result.activeCount)
|
|
}
|
|
if result.compressEnd != len(messages) {
|
|
t.Errorf("compressEnd = %d, want %d", result.compressEnd, len(messages))
|
|
}
|
|
}
|
|
|
|
func TestStripMarkdownFences(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "no fences",
|
|
input: `{"key": "value"}`,
|
|
want: `{"key": "value"}`,
|
|
},
|
|
{
|
|
name: "json fence",
|
|
input: "```json\n{\"key\": \"value\"}\n```",
|
|
want: `{"key": "value"}`,
|
|
},
|
|
{
|
|
name: "plain fence",
|
|
input: "```\ncontent\n```",
|
|
want: "content",
|
|
},
|
|
{
|
|
name: "fence with surrounding whitespace",
|
|
input: " ```json\n{}\n``` ",
|
|
want: "{}",
|
|
},
|
|
{
|
|
name: "empty after strip",
|
|
input: "```json\n```",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "single-line json fence without newline",
|
|
input: "```json",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "bare fence without newline",
|
|
input: "```",
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := StripMarkdownFences(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("StripMarkdownFences(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildMessageXML(t *testing.T) {
|
|
messages := []llm.Message{
|
|
msg("user", "hello"),
|
|
msg("assistant", "world"),
|
|
}
|
|
got := buildMessageXML(messages)
|
|
if !strings.Contains(got, `<message id="0" role="user">`) {
|
|
t.Errorf("missing user message tag: %s", got)
|
|
}
|
|
if !strings.Contains(got, `<message id="1" role="assistant">`) {
|
|
t.Errorf("missing assistant message tag: %s", got)
|
|
}
|
|
if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
|
|
t.Errorf("missing content: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestCopyMessages(t *testing.T) {
|
|
orig := []llm.Message{msg("user", "a"), msg("assistant", "b")}
|
|
cp := copyMessages(orig)
|
|
if len(cp) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(cp))
|
|
}
|
|
cp[0] = msg("system", "mutated")
|
|
if orig[0].Role == "system" {
|
|
t.Error("copyMessages should return independent slice")
|
|
}
|
|
}
|
|
|
|
func TestPromptTokenLimit(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
maxTokens int
|
|
want int
|
|
}{
|
|
{name: "zero", maxTokens: 0, want: 0},
|
|
{name: "one truncates to zero", maxTokens: 1, want: 0},
|
|
{name: "four truncates to three", maxTokens: 4, want: 3},
|
|
{name: "five rounds to exact 4.0 via float half-ULP", maxTokens: 5, want: 4},
|
|
{name: "typical 4k context", maxTokens: 4096, want: 3276},
|
|
{name: "default max tokens", maxTokens: 58888, want: 47110},
|
|
{name: "typical 128k context", maxTokens: 128000, want: 102400},
|
|
{name: "typical 200k context", maxTokens: 200000, want: 160000},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := PromptTokenLimit(tt.maxTokens); got != tt.want {
|
|
t.Errorf("PromptTokenLimit(%d) = %d, want %d", tt.maxTokens, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPromptTokenLimitMatchesReplacedExpression pins the one-time migration:
|
|
// PromptTokenLimit replaced a literal `maxTokens*4/5` at four call sites, so the
|
|
// float form must agree with the integer form it replaced across the realistic
|
|
// max_tokens range. This is specific to tokenWarningThreshold being 0.80 — if the
|
|
// threshold ever changes, delete this test rather than "fixing" it.
|
|
func TestPromptTokenLimitMatchesReplacedExpression(t *testing.T) {
|
|
for _, maxTokens := range []int{0, 1, 2, 3, 4, 5, 7, 40, 100, 1000, 4096, 8192, 32768, 58888, 128000, 200000, 1_000_000} {
|
|
if got, want := PromptTokenLimit(maxTokens), maxTokens*4/5; got != want {
|
|
t.Errorf("PromptTokenLimit(%d) = %d, want %d (maxTokens*4/5)", maxTokens, got, want)
|
|
}
|
|
}
|
|
}
|