mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
refactor(provider): remove the summary request flag and what only it served: the Request.Summary field and ErrSummaryToolsUnsupported, the adapters' no-tools branches, the broker's capability probe, and the test doubles and live test written for a summarizer call that no fold makes; the reported-usage effect test now detects a fold by its archive pointer and has a control arm
This commit is contained in:
1 parent
0f81082776
commit
14649962fc
67 files changed
+58
-653
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -44,16 +44,10 @@ func (p *bulkReplyProvider) lastRequestMessageWith(text string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *bulkReplyProvider) summaryRequests() int {
|
||||
func (p *bulkReplyProvider) requestCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
n := 0
|
||||
for _, req := range p.reqs {
|
||||
if req.Summary {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
return len(p.reqs)
|
||||
}
|
||||
|
||||
// TestEffectCompactArchivesInsteadOfSummarizing pins the fold at the boundary a
|
||||
@@ -92,6 +86,7 @@ model = "x"
|
||||
}
|
||||
}
|
||||
|
||||
before := rec.requestCount()
|
||||
verdict, err := ctrl.Compact(context.Background(), agent.CompactRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
@@ -99,8 +94,8 @@ model = "x"
|
||||
if !verdict.Compacted() {
|
||||
t.Fatalf("verdict = %+v, want a fold", verdict)
|
||||
}
|
||||
if n := rec.summaryRequests(); n != 0 {
|
||||
t.Fatalf("a manual compaction made %d summarizer request(s)", n)
|
||||
if n := rec.requestCount() - before; n != 0 {
|
||||
t.Fatalf("a manual compaction made %d model request(s)", n)
|
||||
}
|
||||
|
||||
if err := ctrl.Run(context.Background(), "what is left to do"); err != nil {
|
||||
@@ -169,6 +164,7 @@ model = "x"
|
||||
t.Fatalf("first fold = %+v, %v", v, err)
|
||||
}
|
||||
say(4)
|
||||
before := rec.requestCount()
|
||||
again, err := ctrl.Compact(context.Background(), agent.CompactRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("second Compact: %v", err)
|
||||
@@ -176,8 +172,8 @@ model = "x"
|
||||
if !again.Compacted() {
|
||||
t.Fatalf("a request by hand after a little more was said was declined: %+v (%s)", again, agent.CompactDeclineText(again.Reason))
|
||||
}
|
||||
if n := rec.summaryRequests(); n != 0 {
|
||||
t.Fatalf("folding made %d summarizer request(s)", n)
|
||||
if n := rec.requestCount() - before; n != 0 {
|
||||
t.Fatalf("folding made %d model request(s)", n)
|
||||
}
|
||||
if _, detail := ctrl.ContextReport(); !strings.Contains(detail, "archived") || strings.Contains(detail, "summarized") {
|
||||
t.Fatalf("the report does not name the fold an archive:\n%s", detail)
|
||||
|
||||
@@ -31,11 +31,8 @@ func (p *failedBatchProvider) Name() string { return "boot-failed-batch" }
|
||||
func (p *failedBatchProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
summary := req.Summary
|
||||
round := p.rounds
|
||||
if !summary {
|
||||
p.rounds++
|
||||
}
|
||||
p.rounds++
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, failedBatchReads+3)
|
||||
call := func(id, name string, args any) {
|
||||
@@ -43,8 +40,6 @@ func (p *failedBatchProvider) Stream(_ context.Context, req provider.Request) (<
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: string(raw)}}
|
||||
}
|
||||
switch {
|
||||
case summary:
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Goal\n- read the packages"}
|
||||
case round < 5:
|
||||
for i := range failedBatchReads {
|
||||
call(fmt.Sprintf("r%d-%d", round, i), "read_file", map[string]string{"path": fmt.Sprintf("src%d.txt", i)})
|
||||
|
||||
@@ -10,12 +10,9 @@ import (
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func digestRequests(reqs []provider.Request) (summarizer, carrying int) {
|
||||
// carryingProjection counts the requests that already hold a fold.
|
||||
func carryingProjection(reqs []provider.Request) (carrying int) {
|
||||
for _, req := range reqs {
|
||||
if req.Summary {
|
||||
summarizer++
|
||||
continue
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
if strings.Contains(m.Content, "<compaction-summary>") {
|
||||
carrying++
|
||||
@@ -23,7 +20,7 @@ func digestRequests(reqs []provider.Request) (summarizer, carrying int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return summarizer, carrying
|
||||
return carrying
|
||||
}
|
||||
|
||||
// modelSwitchFixtureChars is the first-request size this fixture is padded to:
|
||||
@@ -91,7 +88,7 @@ context_window = 32500
|
||||
t.Fatalf("Run(%q): %v", prompt, err)
|
||||
}
|
||||
}
|
||||
if _, carrying := digestRequests(rec.requests()); carrying == 0 {
|
||||
if carryingProjection(rec.requests()) == 0 {
|
||||
t.Fatal("fixture never compacted")
|
||||
}
|
||||
if err := from.Snapshot(); err != nil {
|
||||
@@ -110,16 +107,11 @@ context_window = 32500
|
||||
t.Fatalf("the fixture's first request is %d characters, want %d: the padding did not reach it", got, modelSwitchFixtureChars)
|
||||
}
|
||||
before := len(rec.requests())
|
||||
summBefore, _ := digestRequests(rec.requests())
|
||||
if err := to.Run(context.Background(), "after the switch"); err != nil {
|
||||
t.Fatalf("Run after switch: %v", err)
|
||||
}
|
||||
after := rec.requests()[before:]
|
||||
summ, carrying := digestRequests(after)
|
||||
if summ != 0 {
|
||||
t.Fatalf("switch spent %d summarization call(s) (had %d before)", summ, summBefore)
|
||||
}
|
||||
if carrying == 0 {
|
||||
if carryingProjection(after) == 0 {
|
||||
t.Fatalf("request after the switch dropped the projection; messages=%s", messageDigest(after[len(after)-1].Messages))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +39,11 @@ func (p *progressEffectProvider) Name() string { return "boot-progress-effect" }
|
||||
func (p *progressEffectProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
digest := req.Summary
|
||||
round := p.rounds
|
||||
if !digest {
|
||||
p.rounds++
|
||||
}
|
||||
p.rounds++
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
switch {
|
||||
case digest:
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Files\n- notes.md was written"}
|
||||
case round < len(p.script):
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: p.script[round]}
|
||||
default:
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
type promptSizeProvider struct {
|
||||
mu sync.Mutex
|
||||
reported int
|
||||
digests int
|
||||
mains []provider.Request
|
||||
}
|
||||
|
||||
@@ -29,13 +28,6 @@ func (p *promptSizeProvider) Stream(_ context.Context, req provider.Request) (<-
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
if req.Summary {
|
||||
p.digests++
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Summary\nSUMMARY of the earlier work."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
p.mains = append(p.mains, req)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: p.reported, TotalTokens: p.reported + 1}}
|
||||
@@ -44,10 +36,26 @@ func (p *promptSizeProvider) Stream(_ context.Context, req provider.Request) (<-
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *promptSizeProvider) counts() (digests, mains int) {
|
||||
func (p *promptSizeProvider) counts() (folded, mains int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.digests, len(p.mains)
|
||||
for _, req := range p.mains {
|
||||
if carriesArchivePointer(req) {
|
||||
folded++
|
||||
}
|
||||
}
|
||||
return folded, len(p.mains)
|
||||
}
|
||||
|
||||
// carriesArchivePointer is whether a request already holds a fold: the earlier
|
||||
// conversation was moved to a file and a pointer stands in for it.
|
||||
func carriesArchivePointer(req provider.Request) bool {
|
||||
for _, m := range req.Messages {
|
||||
if strings.Contains(m.Content, "archived, not summarized") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// imageSession is about 70k estimated tokens that include one screenshot,
|
||||
@@ -123,7 +131,15 @@ context_window = 100000
|
||||
}
|
||||
|
||||
func TestEffectReportedUsageBelowTriggerLeavesContextAlone(t *testing.T) {
|
||||
if digests, _ := foldsAfterTwoTurns(t, 60_000); digests != 0 {
|
||||
t.Fatalf("digest requests = %d with the provider reporting 60k of 100k", digests)
|
||||
if _, second := foldsAfterTwoTurns(t, 60_000); carriesArchivePointer(second) {
|
||||
t.Fatal("a fold reached the request with the provider reporting 60k of 100k")
|
||||
}
|
||||
}
|
||||
|
||||
// The control arm: the same run with the provider reporting more than the
|
||||
// trigger does fold, so the assertion above can fail.
|
||||
func TestEffectReportedUsageAboveTriggerFolds(t *testing.T) {
|
||||
if _, second := foldsAfterTwoTurns(t, 95_000); !carriesArchivePointer(second) {
|
||||
t.Fatal("a provider reporting 95k of 100k left the context unfolded")
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,8 @@ func (p *compactionEffectProvider) Stream(_ context.Context, req provider.Reques
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
p.mu.Unlock()
|
||||
text := p.bulk
|
||||
if req.Summary {
|
||||
text = "## Standing facts\n- none recorded"
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: text}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: p.bulk}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -47,7 +47,7 @@ func compactedSession(t *testing.T, rec *compactionEffectProvider, home, dir str
|
||||
t.Fatalf("Run(%q): %v", prompt, err)
|
||||
}
|
||||
}
|
||||
if _, carrying := digestRequests(rec.requests()); carrying == 0 {
|
||||
if carryingProjection(rec.requests()) == 0 {
|
||||
t.Fatal("fixture never compacted")
|
||||
}
|
||||
if err := from.Snapshot(); err != nil {
|
||||
|
||||
@@ -35,11 +35,6 @@ func (p *budgetEffectProvider) Stream(_ context.Context, req provider.Request) (
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
defer close(ch)
|
||||
if req.Summary {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Standing facts\n- none recorded"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
return ch, nil
|
||||
}
|
||||
asked, answered := "", ""
|
||||
for _, m := range req.Messages {
|
||||
switch {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -39,7 +38,7 @@ func (p *effectRecordingProvider) Stream(ctx context.Context, req provider.Reque
|
||||
p.rawInputs = append(p.rawInputs, agent.RawUserInput(ctx, ""))
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, "ok")}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -71,9 +71,6 @@ context_window = 32000
|
||||
|
||||
const contractMark = "Treat the user's goal as a task contract"
|
||||
for i, req := range rec.requests() {
|
||||
if isSummarizerRequest(req) {
|
||||
continue
|
||||
}
|
||||
if n := strings.Count(userTextOf(req), contractMark); n > 2 {
|
||||
t.Fatalf("request %d carried the full contract %d times; copies are piling up", i, n)
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// isSummarizerRequest tells the compaction summarizer's own call apart from the
|
||||
// agent's. Only the agent's requests carry the projection under test.
|
||||
func isSummarizerRequest(req provider.Request) bool {
|
||||
return req.Summary
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
package provider
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrSummaryToolsUnsupported refuses retained schemas before an unsafe broker POST.
|
||||
var ErrSummaryToolsUnsupported = errors.New("provider cannot guarantee disabled summary tools")
|
||||
|
||||
// Request is a single completion request.
|
||||
type Request struct {
|
||||
Messages []Message
|
||||
@@ -19,9 +14,6 @@ type Request struct {
|
||||
// Mode is the session's model mode id. An adapter sends only a mode its
|
||||
// config declared for this endpoint, so an unknown one reaches no wire.
|
||||
Mode string `json:"Mode,omitempty"`
|
||||
// Summary asks for a compaction briefing with tool use disabled. It crosses
|
||||
// the provider broker so remote and local summaries have the same contract.
|
||||
Summary bool `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// ResponseFormat asks a provider to constrain its output shape.
|
||||
|
||||
@@ -478,7 +478,7 @@ func (c *client) buildRequest(_ context.Context, req provider.Request) anthReque
|
||||
r.Thinking = &thinkingConfig{Type: t}
|
||||
}
|
||||
}
|
||||
return r.forSummary(req.Summary)
|
||||
return r
|
||||
}
|
||||
|
||||
// readStream parses the Messages API SSE stream into Chunks. Text deltas emit live;
|
||||
|
||||
@@ -3,10 +3,3 @@ package anthropic
|
||||
type toolChoice struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (r anthRequest) forSummary(summary bool) anthRequest {
|
||||
if summary && len(r.Tools) > 0 {
|
||||
r.ToolChoice = &toolChoice{Type: "none"}
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestSummaryRetainsSchemasWithToolUseDisabled(t *testing.T) {
|
||||
c := &client{model: "m", webSearch: true}
|
||||
req := provider.Request{Tools: []provider.ToolSchema{{Name: "read_file"}}}
|
||||
normal := c.buildRequest(context.Background(), req)
|
||||
req.Summary = true
|
||||
summary := c.buildRequest(context.Background(), req)
|
||||
if !reflect.DeepEqual(normal.Tools, summary.Tools) || summary.ToolChoice == nil || summary.ToolChoice.Type != "none" || normal.ToolChoice != nil {
|
||||
t.Fatalf("summary changed the catalog or retained tool use: %+v", summary)
|
||||
}
|
||||
}
|
||||
@@ -758,7 +758,7 @@ func (c *client) buildRequest(req provider.Request) chatRequest {
|
||||
// is left untouched for backends that also honour it.
|
||||
out.Thinking = &thinkingMode{Type: c.thinkingType}
|
||||
}
|
||||
return out.forSummary(req.Summary)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *client) buildPrefixRequest(req provider.Request, content, reasoning string) chatRequest {
|
||||
|
||||
@@ -14,11 +14,3 @@ type chatRequest struct {
|
||||
Thinking *thinkingMode `json:"thinking,omitempty"`
|
||||
ExtraBody map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
func (r chatRequest) forSummary(summary bool) chatRequest {
|
||||
if summary && len(r.Tools) > 0 {
|
||||
r.ToolChoice = "none"
|
||||
r.ExtraBody = omitExtraBodyFields(r.ExtraBody, "tool_choice")
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestSummaryKeepsSchemasButCannotChooseATool(t *testing.T) {
|
||||
c := &client{model: "m", extraBody: map[string]any{"tool_choice": "required"}}
|
||||
req := provider.Request{Tools: []provider.ToolSchema{{Name: "read_file"}}, Summary: true}
|
||||
raw, err := json.Marshal(c.buildRequest(req))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["tool_choice"] != "none" || len(body["tools"].([]any)) != 1 {
|
||||
t.Fatalf("summary lost its schemas or allowed calls: %s", raw)
|
||||
}
|
||||
req.Summary = false
|
||||
raw, err = json.Marshal(c.buildRequest(req))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil || body["tool_choice"] != "required" {
|
||||
t.Fatalf("normal request no longer honors extra_body: %s, %v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -165,16 +165,6 @@ func (b *brokered) Stream(ctx context.Context, req provider.Request) (<-chan pro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Summary && len(req.Tools) > 0 {
|
||||
// Capability and POST use one authenticated target, never the picker's stale cache.
|
||||
catalog, probeErr := b.client.fetchCatalogAt(ctx, base, token)
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if probeErr != nil || !catalog.SummaryToolChoiceNone {
|
||||
return nil, provider.ErrSummaryToolsUnsupported
|
||||
}
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+PathStream, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *Server) handleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(catalogResponse{Providers: s.resolver.Catalog(), SummaryToolChoiceNone: true})
|
||||
_ = json.NewEncoder(w).Encode(catalogResponse{Providers: s.resolver.Catalog()})
|
||||
}
|
||||
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
package providerbroker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestSummaryCatalogRequiresFreshPolicySupport(t *testing.T) {
|
||||
for _, mode := range []string{"old", "false", "invalid", "server error", "unauthorized", "timeout", "supported"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
var catalog, posts atomic.Int32
|
||||
var behavior atomic.Value
|
||||
behavior.Store("supported")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == PathCatalog {
|
||||
catalog.Add(1)
|
||||
switch behavior.Load().(string) {
|
||||
case "old":
|
||||
_, _ = w.Write([]byte(`{"providers":[]}`))
|
||||
case "false":
|
||||
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":false}`))
|
||||
case "invalid":
|
||||
_, _ = w.Write([]byte(`not json`))
|
||||
case "server error":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case "unauthorized":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case "timeout":
|
||||
<-r.Context().Done()
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":true}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
posts.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(encodeChunk(provider.Chunk{Type: provider.ChunkDone}))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, testToken, srv.Client())
|
||||
p, err := c.Resolve(provider.Selection{Ref: "p/m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
behavior.Store(mode)
|
||||
before := catalog.Load()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
ch, err := p.Stream(ctx, provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}})
|
||||
if mode == "supported" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, ch)
|
||||
if posts.Load() != 1 {
|
||||
t.Fatal("supported summary did not run")
|
||||
}
|
||||
} else if (mode != "timeout" && !errors.Is(err, provider.ErrSummaryToolsUnsupported)) || (mode == "timeout" && !errors.Is(err, context.DeadlineExceeded)) || posts.Load() != 0 {
|
||||
t.Fatalf("unsupported summary sent: err=%v posts=%d", err, posts.Load())
|
||||
}
|
||||
if catalog.Load() != before+1 {
|
||||
t.Fatal("summary relied on cached catalog capability")
|
||||
}
|
||||
behavior.Store("old")
|
||||
before = catalog.Load()
|
||||
ch, err = p.Stream(t.Context(), provider.Request{Summary: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, ch)
|
||||
ch, err = p.Stream(t.Context(), provider.Request{Tools: []provider.ToolSchema{{Name: "read_file"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, ch)
|
||||
if catalog.Load() != before {
|
||||
t.Fatal("ordinary and independent requests gained a capability dependency")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryCapabilityIsPinnedToTheAuthenticatedTarget(t *testing.T) {
|
||||
var aPosts, bPosts atomic.Int32
|
||||
var target atomic.Value
|
||||
b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(HeaderToken) != "token-b" {
|
||||
t.Error("wrong B token")
|
||||
}
|
||||
if r.URL.Path == PathCatalog {
|
||||
_, _ = w.Write([]byte(`{"providers":[]}`))
|
||||
return
|
||||
}
|
||||
bPosts.Add(1)
|
||||
}))
|
||||
defer b.Close()
|
||||
var switchDuringProbe atomic.Bool
|
||||
a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(HeaderToken) != "token-a" {
|
||||
t.Error("wrong A token")
|
||||
}
|
||||
if r.URL.Path == PathCatalog {
|
||||
if switchDuringProbe.Load() {
|
||||
target.Store(b.URL)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":true}`))
|
||||
return
|
||||
}
|
||||
aPosts.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(encodeChunk(provider.Chunk{Type: provider.ChunkDone}))
|
||||
}))
|
||||
defer a.Close()
|
||||
target.Store(a.URL)
|
||||
c := NewEndpointClient(func() (string, string, error) {
|
||||
url := target.Load().(string)
|
||||
token := "token-a"
|
||||
if url == b.URL {
|
||||
token = "token-b"
|
||||
}
|
||||
return url, token, nil
|
||||
}, a.Client())
|
||||
p, err := c.Resolve(provider.Selection{Ref: "p/m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}}
|
||||
switchDuringProbe.Store(true)
|
||||
ch, err := p.Stream(t.Context(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, ch)
|
||||
if aPosts.Load() != 1 || bPosts.Load() != 0 {
|
||||
t.Fatalf("probe and POST did not share target: A=%d B=%d", aPosts.Load(), bPosts.Load())
|
||||
}
|
||||
if _, err = p.Stream(t.Context(), req); err == nil || bPosts.Load() != 0 {
|
||||
t.Fatal("positive A capability leaked to legacy B")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentServerAdvertisesSummaryPolicy(t *testing.T) {
|
||||
p := &fakeProvider{name: "p"}
|
||||
s, err := NewServer(staticResolver(p), testToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, PathCatalog, nil)
|
||||
r.Header.Set(HeaderToken, testToken)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
var body struct {
|
||||
Summary bool `json:"summary_tool_choice_none"`
|
||||
}
|
||||
if json.Unmarshal(w.Body.Bytes(), &body) != nil || !body.Summary {
|
||||
t.Fatal("new broker does not advertise policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryCapabilityPreservesCancellation(t *testing.T) {
|
||||
probing := make(chan struct{})
|
||||
var posts atomic.Int32
|
||||
var block atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == PathCatalog {
|
||||
if block.Load() {
|
||||
close(probing)
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"providers":[]}`))
|
||||
return
|
||||
}
|
||||
posts.Add(1)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := NewClient(srv.URL, testToken, srv.Client()).Resolve(provider.Selection{Ref: "p/m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block.Store(true)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Stream(ctx, provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}})
|
||||
done <- err
|
||||
}()
|
||||
<-probing
|
||||
cancel()
|
||||
if err := <-done; !errors.Is(err, context.Canceled) || posts.Load() != 0 {
|
||||
t.Fatalf("cancel err=%v posts=%d", err, posts.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryCapabilityUsesRotatedTokenAtTheSameURL(t *testing.T) {
|
||||
var token atomic.Value
|
||||
token.Store("before")
|
||||
var posts atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(HeaderToken) != token.Load().(string) {
|
||||
t.Error("stale token")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == PathCatalog {
|
||||
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":true}`))
|
||||
return
|
||||
}
|
||||
posts.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(encodeChunk(provider.Chunk{Type: provider.ChunkDone}))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewEndpointClient(func() (string, string, error) { return srv.URL, token.Load().(string), nil }, srv.Client())
|
||||
p, err := c.Resolve(provider.Selection{Ref: "p/m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token.Store("after")
|
||||
ch, err := p.Stream(t.Context(), provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, ch)
|
||||
if posts.Load() != 1 {
|
||||
t.Fatal("rotated token summary did not reach provider")
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,6 @@ func TestATurnCompletesOverTheReverseForward(t *testing.T) {
|
||||
}
|
||||
ch, err := p.Stream(context.Background(), provider.Request{
|
||||
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
|
||||
Summary: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stream: %v", err)
|
||||
@@ -98,7 +97,7 @@ func TestATurnCompletesOverTheReverseForward(t *testing.T) {
|
||||
if got[2].Usage == nil || got[2].Usage.CacheHitTokens != 4 {
|
||||
t.Fatalf("usage did not survive the tunnel: %+v", got[2])
|
||||
}
|
||||
if sent := <-seen; len(sent.Messages) != 1 || sent.Messages[0].Content != "hi" || !sent.Summary {
|
||||
if sent := <-seen; len(sent.Messages) != 1 || sent.Messages[0].Content != "hi" {
|
||||
t.Fatalf("the request did not reach the local provider intact: %+v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ const (
|
||||
)
|
||||
|
||||
type catalogResponse struct {
|
||||
Providers []provider.Descriptor `json:"providers"`
|
||||
SummaryToolChoiceNone bool `json:"summary_tool_choice_none"`
|
||||
Providers []provider.Descriptor `json:"providers"`
|
||||
}
|
||||
|
||||
type streamRequest struct {
|
||||
|
||||
@@ -256,7 +256,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi
|
||||
}
|
||||
c.authed.Store(true)
|
||||
out := make(chan provider.Chunk, 64)
|
||||
go c.readStream(requestCtx, resp, out, wireMessages, downgrade, !req.Summary)
|
||||
go c.readStream(requestCtx, resp, out, wireMessages, downgrade, true)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -375,7 +375,6 @@ func (c *client) buildRequestBodyWithContinuation(req provider.Request, allowPre
|
||||
})
|
||||
}
|
||||
body["tools"] = tools
|
||||
applySummaryToolChoice(body, req.Summary)
|
||||
}
|
||||
instructions, rest := splitInstructions(messages)
|
||||
if instructions != "" {
|
||||
@@ -386,7 +385,7 @@ func (c *client) buildRequestBodyWithContinuation(req provider.Request, allowPre
|
||||
previousID, expectedDigest := c.lastResponseID, c.expectedPrefixDigest
|
||||
mode := c.effectiveModeLocked()
|
||||
c.mu.Unlock()
|
||||
if allowPrevious && !req.Summary && mode == "stateful" && previousID != "" && len(messages) > 0 &&
|
||||
if allowPrevious && mode == "stateful" && previousID != "" && len(messages) > 0 &&
|
||||
messages[len(messages)-1].Role == provider.RoleUser &&
|
||||
c.conversationDigest(messages[:len(messages)-1]) == expectedDigest {
|
||||
body["input"] = messages[len(messages)-1].Content
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package responses
|
||||
|
||||
func applySummaryToolChoice(body map[string]any, summary bool) {
|
||||
if summary {
|
||||
body["tool_choice"] = "none"
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func TestSummaryKeepsSchemasWithoutAContinuationOrToolUse(t *testing.T) {
|
||||
c := &client{model: "m", mode: "stateful", webSearch: true, lastResponseID: "main-response"}
|
||||
prefix := []provider.Message{{Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "finding"}}
|
||||
c.expectedPrefixDigest = c.conversationDigest(prefix)
|
||||
req := provider.Request{Messages: append(prefix, provider.Message{Role: provider.RoleUser, Content: "summarize"}), Tools: []provider.ToolSchema{{Name: "read_file"}}}
|
||||
normal, used, _ := c.buildRequestBody(req)
|
||||
if !used {
|
||||
t.Fatal("fixture did not exercise the normal continuation")
|
||||
}
|
||||
req.Summary = true
|
||||
summary, used, _ := c.buildRequestBody(req)
|
||||
if used || summary["previous_response_id"] != nil || summary["tool_choice"] != "none" || !reflect.DeepEqual(summary["tools"], normal["tools"]) {
|
||||
t.Fatalf("summary changed tools or reused a live continuation: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryDoesNotReplaceTheMainResponseContinuation(t *testing.T) {
|
||||
for _, invalid := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint("invalid=", invalid), func(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
bodies = append(bodies, body)
|
||||
if invalid && len(bodies) == 2 {
|
||||
writeEvents(w, `{"type":"response.completed","response":{"id":"poisoned","output":[{"type":"function_call","call_id":"c","name":"echo","arguments":{}}]}}`)
|
||||
return
|
||||
}
|
||||
writeEvents(w, `{"type":"response.output_text.delta","item_id":"msg","delta":"answer"}`,
|
||||
fmt.Sprintf(`{"type":"response.completed","response":{"id":"r%d"}}`, len(bodies)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p := New(Config{Name: "relay", BaseURL: srv.URL, Model: "m"})
|
||||
req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "one"}}}
|
||||
collect(t, p, req)
|
||||
req.Messages = append(req.Messages, provider.Message{Role: provider.RoleAssistant, Content: "answer"}, provider.Message{Role: provider.RoleUser, Content: "summarize"})
|
||||
req.Summary = true
|
||||
chunks := collect(t, p, req)
|
||||
if invalid {
|
||||
assertTerminalFailure(t, chunks, provider.ErrInvalidFunctionCall)
|
||||
}
|
||||
req.Summary = false
|
||||
req.Messages[len(req.Messages)-1].Content = "two"
|
||||
collect(t, p, req)
|
||||
if bodies[1]["previous_response_id"] != nil || bodies[2]["previous_response_id"] != "r1" {
|
||||
t.Fatalf("summary replaced the main continuation: %+v", bodies)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//go:build live_compaction
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/model/openai"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type liveSummaryRecorder struct {
|
||||
provider.Provider
|
||||
requests []provider.Request
|
||||
results []liveSummaryResult
|
||||
}
|
||||
type liveSummaryResult struct {
|
||||
Kind string `json:"kind"`
|
||||
Milliseconds int64 `json:"milliseconds"`
|
||||
Digest bool `json:"digest"`
|
||||
Grounded bool `json:"grounded"`
|
||||
ToolCalls int `json:"tool_calls"`
|
||||
Usage *provider.Usage `json:"usage"`
|
||||
}
|
||||
|
||||
func (p *liveSummaryRecorder) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.requests = append(p.requests, req)
|
||||
start := time.Now()
|
||||
ch, err := p.Provider.Stream(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(chan provider.Chunk)
|
||||
go func() {
|
||||
defer close(out)
|
||||
var text strings.Builder
|
||||
var usage *provider.Usage
|
||||
calls := 0
|
||||
for c := range ch {
|
||||
if c.Type == provider.ChunkToolCall {
|
||||
calls++
|
||||
}
|
||||
if c.Type == provider.ChunkText {
|
||||
text.WriteString(c.Text)
|
||||
}
|
||||
if c.Type == provider.ChunkUsage {
|
||||
usage = c.Usage
|
||||
}
|
||||
select {
|
||||
case out <- c:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
kind := "warm"
|
||||
if req.Summary {
|
||||
kind = "reuse"
|
||||
if len(req.Tools) == 0 {
|
||||
kind = "independent"
|
||||
}
|
||||
}
|
||||
p.results = append(p.results, liveSummaryResult{Kind: kind, Milliseconds: time.Since(start).Milliseconds(), Digest: hasDigestHeading(text.String()), Grounded: strings.Contains(text.String(), "ParseDuration") && strings.Contains(strings.ToLower(text.String()), "zero"), ToolCalls: calls, Usage: usage})
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestLiveCompactionPrefix(t *testing.T) {
|
||||
key := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("DEEPSEEK_API_KEY is unset")
|
||||
}
|
||||
model := os.Getenv("REASONIX_LIVE_MODEL")
|
||||
if model == "" {
|
||||
model = "deepseek-flash"
|
||||
}
|
||||
savedTransport := http.DefaultTransport
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.DisableKeepAlives = true
|
||||
http.DefaultTransport = transport
|
||||
t.Cleanup(func() { transport.CloseIdleConnections(); http.DefaultTransport = savedTransport })
|
||||
p, err := openai.New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: model, APIKey: key, Extra: map[string]any{"thinking": "disabled"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rich := range []bool{false, true} {
|
||||
for trial := range 3 {
|
||||
rec := &liveSummaryRecorder{Provider: p}
|
||||
messages := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "You are a coding agent. Preserve the public API. " + strings.Repeat("Keep changes narrow and run relevant tests. ", 100)},
|
||||
{Role: provider.RoleUser, Content: "Investigate why ParseDuration accepts negative values. Preserve function names and return a typed error."},
|
||||
{Role: provider.RoleAssistant, Content: "Inspected duration.go: ParseDuration lacks a negative-value guard. Plan: reject values below zero and add boundary tests."},
|
||||
{Role: provider.RoleUser, Content: "Continue; ensure zero remains valid."},
|
||||
}
|
||||
if rich {
|
||||
messages = append(messages[:2], provider.Message{Role: provider.RoleAssistant, ReasoningContent: "Inspect the input guard.", ToolCalls: []provider.ToolCall{{ID: "call_read", Name: "read_file", Arguments: `{"path":"duration.go","private_payload":"synthetic-argument-marker"}`}}}, provider.Message{Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_read", Content: "func ParseDuration(v int) (int,error) { return v,nil }"}, messages[2], messages[3])
|
||||
}
|
||||
schemas := []provider.ToolSchema{{Name: "read_file", Description: "Read a file", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`)}}
|
||||
a := New(rec, tool.NewRegistry(), &sessionstore.Session{Messages: messages}, Options{ContextWindow: 128000, MaxOutputTokens: 1024}, event.Discard)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second)
|
||||
ch, err := a.streamProviderRequest(ctx, freezeProviderRequest(provider.Request{Messages: messages, Tools: schemas, MaxTokens: 64}))
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
for c := range ch {
|
||||
if c.Type == provider.ChunkError {
|
||||
cancel()
|
||||
t.Fatal(c.Err)
|
||||
}
|
||||
}
|
||||
fold := messages[2 : len(messages)-1]
|
||||
_, _, _, _, err = a.window().runCompactionSummary(ctx, fold, "")
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = a.window().summarizeAtMode(ctx, fold, "", 1024, 0, false)
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rec.results {
|
||||
if r.Kind != "warm" && (!r.Digest || !r.Grounded || r.ToolCalls != 0 || r.Usage == nil || r.Usage.FinishReason != "stop") {
|
||||
t.Fatalf("summary contract failed: %+v", r)
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(struct {
|
||||
Model string `json:"model"`
|
||||
Rich bool `json:"rich"`
|
||||
Trial int `json:"trial"`
|
||||
Results []liveSummaryResult `json:"results"`
|
||||
}{model, rich, trial + 1, rec.results})
|
||||
t.Log(string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
@@ -20,7 +19,7 @@ func (p *countingProvider) Name() string { return "counting" }
|
||||
func (p *countingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.got = append(p.got, req)
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, fmt.Sprintf("%s %d", p.reply, len(p.got)))}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: fmt.Sprintf("%s %d", p.reply, len(p.got))}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -93,7 +92,7 @@ func (p *scriptedSummarizer) Stream(_ context.Context, req provider.Request) (<-
|
||||
r := p.replies[at]
|
||||
ch := make(chan provider.Chunk, 4)
|
||||
if r.text != "" {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, r.text)}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: r.text}
|
||||
}
|
||||
if r.usage != nil {
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: r.usage}
|
||||
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -44,7 +43,7 @@ func (f *fakeProvider) answer(req provider.Request) string {
|
||||
if f.verbatim {
|
||||
return f.reply
|
||||
}
|
||||
return testutil.SummaryReply(req, f.reply)
|
||||
return f.reply
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
|
||||
@@ -95,7 +95,7 @@ func (p *MockProvider) Stream(ctx context.Context, req provider.Request) (<-chan
|
||||
chunks = append(chunks, provider.Chunk{Type: provider.ChunkReasoning, Text: t.Reasoning})
|
||||
}
|
||||
if t.Text != "" {
|
||||
chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: SummaryReply(req, t.Text)})
|
||||
chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: t.Text})
|
||||
}
|
||||
for i := range t.ToolCalls {
|
||||
tc := t.ToolCalls[i]
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// SummaryReply is what a double that follows the summary contract answers: the
|
||||
// summarizer prompt requires headings, and the host refuses a reply with
|
||||
// none. A plain reply to a summary request gets one, so a test that only cares
|
||||
// about the digest's text keeps reading it.
|
||||
func SummaryReply(req provider.Request, text string) string {
|
||||
if !req.Summary {
|
||||
return text
|
||||
}
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
if hashes := len(line) - len(strings.TrimLeft(line, "#")); hashes >= 1 && hashes <= 6 && strings.HasPrefix(line[hashes:], " ") {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return "## Summary\n" + text
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -65,7 +64,7 @@ func (p *scriptedProvider) Stream(ctx context.Context, req provider.Request) (<-
|
||||
ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: resp.reasoning}
|
||||
}
|
||||
if resp.text != "" {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, resp.text)}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: resp.text}
|
||||
}
|
||||
if resp.usage != nil {
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: resp.usage}
|
||||
|
||||
Reference in new issue
Block a user