Files
Kite c7dd11e03e fix(llm): preserve provider reasoning/thinking across assistant turns (#1070)
* 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
2026-08-28 11:51:40 +08:00

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)
}
}
}