package sidequestion import ( "context" "errors" "fmt" "iter" "reflect" "strings" "testing" "time" "unicode/utf8" "github.com/Autumn-27/norma/llm" ) func replayFixture(history []Exchange, memory *Memory) Replay { return Replay{Memory: *memory, Load: func(_ context.Context, after int64) ([]Exchange, error) { var page []Exchange for _, e := range history { if e.Ordinal > after && e.Status == "completed" { page = append(page, e) } if len(page) == 20 { break } } return page, nil }, Save: func(_ context.Context, value Memory) error { *memory = value; return nil }} } func TestSideTwentyExchangeBoundaryAndRestart(t *testing.T) { for _, n := range []int{19, 20, 21, 50} { t.Run(fmt.Sprint(n), func(t *testing.T) { var history []Exchange for i := 1; i <= n; i++ { history = append(history, Exchange{Ordinal: int64(i), Question: fmt.Sprintf("question-%d", i), Answer: "historical answer", Status: "completed", SnapshotAt: time.Unix(int64(i), 0)}) } history[0].Answer = "EARLY-DECISION-ALPHA" var requests []llm.CompletionRequest summaries := 0 p := fakeProvider{complete: func(_ context.Context, r llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { if len(r.Tools) == 0 && len(r.System) > 0 && r.System[0] == summaryInstruction { summaries++ if !strings.Contains(r.Messages[0].Text(), "EARLY-DECISION-ALPHA") { t.Fatal("rolling update lost prior summary") } return assistant("EARLY-DECISION-ALPHA; historical discussion, not current evidence"), "end_turn", llm.Usage{InputTokens: 11}, nil } requests = append(requests, r) return assistant("answer"), "end_turn", llm.Usage{InputTokens: 7}, nil }} snap := Snapshot{Request: fixture(), Model: Model{WindowTokens: 200000}} var memory Memory out, info, err := (SideQuestionService{p}).Respond(t.Context(), snap, "what was my first decision?", replayFixture(history, &memory), ContextOptions{}, nil) if err != nil { t.Fatal(err) } if info.RecentExchanges != min(n, 20) || memory.Through != int64(max(0, n-20)) { t.Fatalf("boundary: %+v %+v", info, memory) } if out.Usage.InputTokens != 11*summaries+7 { t.Fatal("summary usage missing") } if !strings.Contains(string(mustJSON(t, requests[0])), "EARLY-DECISION-ALPHA") { t.Fatal("first-round decision absent after round 20") } before := summaries _, _, err = (SideQuestionService{p}).Respond(t.Context(), snap, "continue after restart", replayFixture(history, &memory), ContextOptions{}, nil) if err != nil || summaries != before { t.Fatalf("persisted summary not reused: %v", err) } }) } } func TestSideBudgetCountsContentNotJSONCharacters(t *testing.T) { snap := Snapshot{Request: fixture(), Model: Model{WindowTokens: 200000}} snap.Request.MaxTokens = 32768 snap.Request.Messages = append(snap.Request.Messages, llm.UserText(strings.Repeat("\n", 5500))) before := mustJSON(t, snap) if len(before) < 200000 { t.Fatal("fixture too small to exercise regression") } req, err := BuildRequest(snap, nil, "progress?") if err != nil { t.Fatalf("valid code-heavy snapshot rejected: %v", err) } if req.MaxTokens != 8192 || EstimateInputTokens(req) >= 200000 { t.Fatalf("budget: %d %d", req.MaxTokens, EstimateInputTokens(req)) } if string(before) != string(mustJSON(t, snap)) { t.Fatal("snapshot mutated") } baseline := EstimateInputTokens(llm.CompletionRequest{Messages: []llm.Message{llm.UserText("x")}}) if EstimateInputTokens(llm.CompletionRequest{Messages: []llm.Message{llm.UserText("x")}, System: []string{strings.Repeat("中", 3000)}, Tools: fixture().Tools}) <= baseline+3000 { t.Fatal("system/schema/CJK omitted") } } func TestSideLongExchangeAndSnapshotCompactionAreBounded(t *testing.T) { for _, hugeHistory := range []bool{true, false} { t.Run(fmt.Sprint(hugeHistory), func(t *testing.T) { snap := Snapshot{Request: fixture(), Model: Model{WindowTokens: 32000}} var history []Exchange if hugeHistory { history = []Exchange{{Ordinal: 1, Question: "earlier", Answer: strings.Repeat("历史依据ABC", 14000), Status: "completed"}} } else { snap.Request.Messages = append([]llm.Message{llm.UserText(strings.Repeat("old tool evidence 中文", 9000))}, snap.Request.Messages...) } original := mustJSON(t, snap) calls, answers := 0, 0 var final llm.CompletionRequest p := fakeProvider{complete: func(_ context.Context, r llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { if r.Thinking == "disabled" { calls++ if len(r.Tools) != 0 || EstimateInputTokens(r)+r.MaxTokens > 32000 || !utf8.ValidString(r.Messages[0].Text()) { t.Fatal("unsafe summary request") } return assistant("goal, evidence and unresolved questions"), "end_turn", llm.Usage{OutputTokens: 3}, nil } answers++ final = r return assistant("done"), "end_turn", llm.Usage{OutputTokens: 5}, nil }} var memory Memory out, info, err := (SideQuestionService{p}).Respond(t.Context(), snap, "progress?", replayFixture(history, &memory), ContextOptions{}, nil) if err != nil || calls < 2 || calls > 12 || answers != 1 || out.Usage.OutputTokens != 3*calls+5 { t.Fatalf("calls=%d answers=%d info=%+v err=%v", calls, answers, info, err) } if info.EstimatedInputTokens > info.InputBudget { t.Fatal("oversized final request") } if !reflect.DeepEqual(final.Messages, llm.MessagesForAPI(final.Messages)) { t.Fatal("tool pairing broken") } if !strings.Contains(string(mustJSON(t, final)), "verified asset") { t.Fatal("recent tool evidence discarded") } if string(original) != string(mustJSON(t, snap)) { t.Fatal("main snapshot modified") } before := calls _, _, err = (SideQuestionService{p}).Respond(t.Context(), snap, "another question", replayFixture(history, &memory), ContextOptions{}, nil) if err != nil || calls != before { t.Fatalf("summary cache not reused: %v", err) } if !hugeHistory { snap.Version++ _, _, err = (SideQuestionService{p}).Respond(t.Context(), snap, "new snapshot", replayFixture(history, &memory), ContextOptions{}, nil) if err != nil || calls == before { t.Fatalf("stale snapshot cache reused: %v", err) } } }) } } func TestSideOverflowRecoveryOnceAndFailureUsage(t *testing.T) { for _, twice := range []bool{false, true} { t.Run(fmt.Sprint(twice), func(t *testing.T) { answers, summaries := 0, 0 p := fakeProvider{complete: func(_ context.Context, r llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { if r.Thinking == "disabled" { summaries++ return assistant("summary"), "end_turn", llm.Usage{InputTokens: 3}, nil } answers++ if answers == 1 || twice { return llm.Message{}, "", llm.Usage{InputTokens: 5}, errors.New("context_length_exceeded") } return assistant("recovered"), "end_turn", llm.Usage{InputTokens: 7}, nil }} snap := Snapshot{Request: fixture(), Model: Model{WindowTokens: 32000}} snap.Request.Messages = append([]llm.Message{llm.UserText(strings.Repeat("old context ", 2000))}, snap.Request.Messages...) out, info, err := (SideQuestionService{p}).Respond(t.Context(), snap, "question", Replay{}, ContextOptions{}, nil) if answers != 2 || summaries != 1 || !info.OverflowRetried || (err != nil) != twice { t.Fatalf("retry limit: %d %d %+v %v", answers, summaries, info, err) } want := 15 if twice { want = 13 } if out.Usage.InputTokens != want { t.Fatalf("usage=%+v", out.Usage) } }) } // A stream that already exposed text must never replay it as a fresh answer. count := 0 p := fakeProvider{stream: func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { return func(y func(llm.StreamEvent, error) bool) { count++ y(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial"}, nil) y(llm.StreamEvent{}, errors.New("context_length_exceeded")) } }} out, _, err := (SideQuestionService{p}).Respond(t.Context(), Snapshot{Request: fixture(), Model: Model{Streaming: true}}, "q", Replay{}, ContextOptions{}, nil) if err == nil || out.Text != "partial" || count != 1 { t.Fatal("partial stream retried") } } func TestSideSummaryCancellationAndFailureDoNotAdvanceMemory(t *testing.T) { for _, mode := range []string{"cancel", "error", "empty", "truncated", "too-large", "tool", "save-failed"} { t.Run(mode, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() calls := 0 p := fakeProvider{complete: func(_ context.Context, r llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { calls++ usage := llm.Usage{InputTokens: 9} if r.Thinking != "disabled" { t.Fatal("failed summary continued to answer") } switch mode { case "cancel": cancel() return assistant("late"), "end_turn", usage, nil case "error": return llm.Message{}, "", usage, errors.New("unavailable") case "empty": return assistant(""), "end_turn", usage, nil case "truncated": return assistant("partial"), "length", usage, nil case "too-large": return assistant(strings.Repeat("中", 5000)), "end_turn", usage, nil case "tool": return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: "forbidden", Name: "Bash"}}}, "tool_use", usage, nil } return assistant("summary"), "end_turn", usage, nil }} var history []Exchange for i := 1; i <= 21; i++ { history = append(history, Exchange{Ordinal: int64(i), Question: "q", Answer: "a", Status: "completed"}) } var memory Memory replay := replayFixture(history, &memory) if mode == "save-failed" { replay.Save = func(context.Context, Memory) error { return errors.New("cleared") } } out, _, err := (SideQuestionService{p}).Respond(ctx, Snapshot{Request: fixture()}, "q", replay, ContextOptions{}, nil) wantCalls := 1 if mode == "empty" || mode == "truncated" || mode == "too-large" { wantCalls = 2 } if err == nil || calls != wantCalls || memory.Through != 0 || out.Usage.InputTokens != 9*wantCalls { t.Fatalf("failed preparation: %+v %+v %v", memory, out, err) } }) } } func TestSideSummaryRecoversWithoutSavingPartialOutput(t *testing.T) { for _, mode := range []string{"length", "max_tokens", "empty", "thinking-only", "too-large"} { t.Run(mode, func(t *testing.T) { var memory Memory var requests []llm.CompletionRequest answers, saves := 0, 0 p := fakeProvider{complete: func(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { if req.Thinking != "disabled" { answers++ if memory.History != "complete summary" || memory.Through != 1 { t.Fatalf("answer started before complete summary was saved: %+v", memory) } return assistant("answer"), "end_turn", llm.Usage{InputTokens: 7}, nil } requests = append(requests, req) if len(req.Tools) != 0 || EstimateInputTokens(req)+req.MaxTokens+512 > 32000 { t.Fatal("summary recovery exceeds budget or enables tools") } if memory != (Memory{}) || saves != 0 { t.Fatalf("incomplete summary persisted: %+v", memory) } if len(requests) == 1 { usage := llm.Usage{InputTokens: 9, OutputTokens: 2048} switch mode { case "empty": return assistant(" "), "end_turn", usage, nil case "thinking-only": return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockThinking, Thinking: "reasoning only"}}}, "end_turn", usage, nil case "too-large": return assistant(strings.Repeat("中", 5000)), "end_turn", usage, nil default: return assistant("partial summary"), mode, usage, nil } } if len(requests) != 2 || req.MaxTokens <= requests[0].MaxTokens || req.Messages[0].Text() != requests[0].Messages[0].Text() || len(req.System) <= len(requests[0].System) { t.Fatal("recovery must increase output budget, tighten instructions and replay original material") } return assistant("complete summary"), "end_turn", llm.Usage{InputTokens: 11, OutputTokens: 30}, nil }} var history []Exchange for i := 1; i <= 21; i++ { history = append(history, Exchange{Ordinal: int64(i), Question: "q", Answer: "a", Status: "completed"}) } replay := replayFixture(history, &memory) replay.Save = func(_ context.Context, value Memory) error { saves++; memory = value; return nil } out, _, err := (SideQuestionService{p}).Respond(t.Context(), Snapshot{Request: fixture(), Model: Model{WindowTokens: 32000}}, "q", replay, ContextOptions{}, nil) if err != nil || out.Text != "answer" || len(requests) != 2 || answers != 1 || saves != 1 || out.Usage.InputTokens != 27 || out.Usage.OutputTokens != 2078 { t.Fatalf("recovery: summaries=%d answers=%d saves=%d out=%+v err=%v", len(requests), answers, saves, out, err) } }) } } func TestSideSummaryRecoveryReservesWindowForEveryChunk(t *testing.T) { for _, window := range []int{32000, 200000} { t.Run(fmt.Sprint(window), func(t *testing.T) { calls := 0 var first llm.CompletionRequest p := fakeProvider{complete: func(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { calls++ if EstimateInputTokens(req)+req.MaxTokens+512 > window || !utf8.ValidString(req.Messages[0].Text()) { t.Fatal("unsafe chunk during recovery") } if calls%2 == 1 { first = req return assistant("partial"), "max_tokens", llm.Usage{OutputTokens: 2048}, nil } if !reflect.DeepEqual(req.Messages, first.Messages) { t.Fatal("recovery skipped or changed source material") } return assistant("complete rolling summary"), "end_turn", llm.Usage{OutputTokens: 10}, nil }} b := contextBuilder{service: SideQuestionService{p}, window: window} got, err := b.summarize(t.Context(), "prior", strings.Repeat("中文 evidence ", 12000)) if err != nil || got != "complete rolling summary" || calls < 4 || calls > maxSummaryCalls || b.usage.OutputTokens != calls/2*2058 { t.Fatalf("chunk recovery: calls=%d usage=%+v err=%v", calls, b.usage, err) } }) } } func TestSideSummaryRecoveryHonorsCancellationAndSharedCallCap(t *testing.T) { for _, mode := range []string{"cancel", "call-cap", "provider-error"} { t.Run(mode, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() calls := 0 p := fakeProvider{complete: func(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { calls++ if mode == "provider-error" && calls == 2 { return llm.Message{}, "", llm.Usage{InputTokens: 5}, errors.New("unavailable") } return assistant("partial"), "max_tokens", llm.Usage{InputTokens: 9}, nil }} b := contextBuilder{service: SideQuestionService{p}, window: 32000} if mode == "call-cap" { b.calls = maxSummaryCalls - 1 } if mode == "cancel" { b.onUpdate = func(Answer, ContextInfo) { cancel() } } _, err := b.summarize(ctx, "prior", "new evidence") wantCalls, wantUsage := 1, 9 if mode == "provider-error" { wantCalls, wantUsage = 2, 14 } if err == nil || calls != wantCalls || b.usage.InputTokens != wantUsage || b.memory != (Memory{}) { t.Fatalf("unbounded recovery: calls=%d usage=%+v err=%v", calls, b.usage, err) } if mode == "cancel" && !errors.Is(err, context.Canceled) { t.Fatalf("cancellation lost: %v", err) } if mode == "call-cap" && !strings.Contains(err.Error(), "处理上限") { t.Fatalf("call cap lost: %v", err) } }) } } func TestSideSummaryCallCapAndStaticBudget(t *testing.T) { calls := 0 p := fakeProvider{complete: func(_ context.Context, r llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { calls++ if r.Thinking != "disabled" { t.Fatal("oversized request answered") } return assistant("bounded summary"), "end_turn", llm.Usage{OutputTokens: 1}, nil }} snap := Snapshot{Model: Model{WindowTokens: 32000}, Request: fixture()} snap.Request.Messages = []llm.Message{llm.UserText(strings.Repeat("x", 1200000))} out, _, err := (SideQuestionService{p}).Respond(t.Context(), snap, "q", Replay{}, ContextOptions{}, nil) if err == nil || calls != 12 || out.Usage.OutputTokens != 12 { t.Fatalf("summary cap calls=%d err=%v", calls, err) } calls = 0 snap.Request.System = []string{strings.Repeat("x", 200000)} _, _, err = (SideQuestionService{p}).Respond(t.Context(), snap, "q", Replay{}, ContextOptions{}, nil) if !errors.Is(err, ErrContextBudget) || calls != 0 { t.Fatalf("static oversized budget: calls=%d err=%v", calls, err) } } func TestSideTwentyLongRepliesRespectSmallWindow(t *testing.T) { var history []Exchange for i := 1; i <= 20; i++ { history = append(history, Exchange{Ordinal: int64(i), Question: fmt.Sprintf("question-%d", i), Answer: strings.Repeat("evidence ", 3000), Status: "completed"}) } calls := 0 p := fakeProvider{complete: func(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { if EstimateInputTokens(req)+req.MaxTokens > 32000 { t.Fatal("request exceeds small model window") } if req.Thinking == "disabled" { calls++ return assistant("historical objectives and evidence"), "end_turn", llm.Usage{}, nil } return assistant("answer"), "end_turn", llm.Usage{}, nil }} var memory Memory _, info, err := (SideQuestionService{p}).Respond(t.Context(), Snapshot{Request: fixture(), Model: Model{WindowTokens: 32000}}, "current status?", replayFixture(history, &memory), ContextOptions{}, nil) if err != nil || !info.HistorySummarized || calls > 12 || memory.Through != 20 { t.Fatalf("20 long replies: calls=%d info=%+v memory=%+v err=%v", calls, info, memory, err) } } func TestSideOverflowWithoutReductionDoesNotRepeatAnswer(t *testing.T) { answers, summaries := 0, 0 p := fakeProvider{complete: func(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { if req.Thinking == "disabled" { summaries++ return assistant(strings.Repeat("longer summary ", 100)), "end_turn", llm.Usage{InputTokens: 3}, nil } answers++ return llm.Message{}, "", llm.Usage{InputTokens: 5}, errors.New("context_length_exceeded") }} snapshot := Snapshot{Request: llm.CompletionRequest{MaxTokens: 128, Messages: []llm.Message{llm.UserText("tiny")}}} out, _, err := (SideQuestionService{p}).Respond(t.Context(), snapshot, "question", Replay{}, ContextOptions{}, nil) if err == nil || !strings.Contains(err.Error(), "未能进一步缩减") || answers != 1 || summaries != 1 || out.Usage.InputTokens != 8 { t.Fatalf("no-progress recovery: %d %d %+v %v", answers, summaries, out, err) } }