mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: move chatd and related packages to /x/ subpackage (#23445)
- Moves `coderd/chatd/`, `coderd/gitsync/`, `enterprise/coderd/chatd/` under `x/` parent directories to signal instability - Adds `Experimental:` glue code comments in `coderd/coderd.go` > 🤖 This PR was created with the help of Coder Agents, and was reviewed by my human. 🧑💻
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,847 @@
|
||||
package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"iter"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const activeToolName = "read_file"
|
||||
|
||||
func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedCall fantasy.Call
|
||||
model := &loopTestModel{
|
||||
provider: fantasyanthropic.Name,
|
||||
streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
capturedCall = call
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
persistStepCalls := 0
|
||||
var persistedStep PersistedStep
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "sys-1"),
|
||||
textMessage(fantasy.MessageRoleSystem, "sys-2"),
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
textMessage(fantasy.MessageRoleAssistant, "working"),
|
||||
textMessage(fantasy.MessageRoleUser, "continue"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool(activeToolName),
|
||||
newNoopTool("write_file"),
|
||||
},
|
||||
MaxSteps: 3,
|
||||
ActiveTools: []string{activeToolName},
|
||||
ContextLimitFallback: 4096,
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
persistStepCalls++
|
||||
persistedStep = step
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, persistStepCalls)
|
||||
require.True(t, persistedStep.ContextLimit.Valid)
|
||||
require.Equal(t, int64(4096), persistedStep.ContextLimit.Int64)
|
||||
require.Greater(t, persistedStep.Runtime, time.Duration(0),
|
||||
"step runtime should be positive")
|
||||
|
||||
require.NotEmpty(t, capturedCall.Prompt)
|
||||
require.False(t, containsPromptSentinel(capturedCall.Prompt))
|
||||
require.Len(t, capturedCall.Tools, 1)
|
||||
require.Equal(t, activeToolName, capturedCall.Tools[0].GetName())
|
||||
|
||||
require.Len(t, capturedCall.Prompt, 5)
|
||||
require.False(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[0]))
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[1]))
|
||||
require.False(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[2]))
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[3]))
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[4]))
|
||||
}
|
||||
|
||||
func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan struct{})
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) {
|
||||
parts := []fantasy.StreamPart{
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolInputStart,
|
||||
ID: "interrupt-tool-1",
|
||||
ToolCallName: "read_file",
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolInputDelta,
|
||||
ID: "interrupt-tool-1",
|
||||
ToolCallName: "read_file",
|
||||
Delta: `{"path":"main.go"`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "partial assistant output"},
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !yield(part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
default:
|
||||
close(started)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
_ = yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeError,
|
||||
Error: ctx.Err(),
|
||||
})
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
defer cancel(nil)
|
||||
|
||||
go func() {
|
||||
<-started
|
||||
cancel(ErrInterrupted)
|
||||
}()
|
||||
|
||||
persistedAssistantCtxErr := xerrors.New("unset")
|
||||
var persistedContent []fantasy.Content
|
||||
|
||||
err := Run(ctx, RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 3,
|
||||
PersistStep: func(persistCtx context.Context, step PersistedStep) error {
|
||||
persistedAssistantCtxErr = persistCtx.Err()
|
||||
persistedContent = append([]fantasy.Content(nil), step.Content...)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrInterrupted)
|
||||
require.NoError(t, persistedAssistantCtxErr)
|
||||
|
||||
require.NotEmpty(t, persistedContent)
|
||||
var (
|
||||
foundText bool
|
||||
foundToolCall bool
|
||||
foundToolResult bool
|
||||
)
|
||||
for _, block := range persistedContent {
|
||||
if text, ok := fantasy.AsContentType[fantasy.TextContent](block); ok {
|
||||
if strings.Contains(text.Text, "partial assistant output") {
|
||||
foundText = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok {
|
||||
if toolCall.ToolCallID == "interrupt-tool-1" &&
|
||||
toolCall.ToolName == "read_file" &&
|
||||
strings.Contains(toolCall.Input, `"path":"main.go"`) {
|
||||
foundToolCall = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok {
|
||||
if toolResult.ToolCallID == "interrupt-tool-1" &&
|
||||
toolResult.ToolName == "read_file" {
|
||||
_, isErr := toolResult.Result.(fantasy.ToolResultOutputContentError)
|
||||
require.True(t, isErr, "interrupted tool result should be an error")
|
||||
foundToolResult = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundText)
|
||||
require.True(t, foundToolCall)
|
||||
require.True(t, foundToolResult)
|
||||
}
|
||||
|
||||
type loopTestModel struct {
|
||||
provider string
|
||||
model string
|
||||
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
|
||||
streamFn func(context.Context, fantasy.Call) (fantasy.StreamResponse, error)
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Provider() string {
|
||||
if m.provider != "" {
|
||||
return m.provider
|
||||
}
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Model() string {
|
||||
if m.model != "" {
|
||||
return m.model
|
||||
}
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
if m.generateFn != nil {
|
||||
return m.generateFn(ctx, call)
|
||||
}
|
||||
return &fantasy.Response{}, nil
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
if m.streamFn != nil {
|
||||
return m.streamFn(ctx, call)
|
||||
}
|
||||
return streamFromParts([]fantasy.StreamPart{{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
}}), nil
|
||||
}
|
||||
|
||||
func (*loopTestModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
return nil, xerrors.New("not implemented")
|
||||
}
|
||||
|
||||
func (*loopTestModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||
return nil, xerrors.New("not implemented")
|
||||
}
|
||||
|
||||
func streamFromParts(parts []fantasy.StreamPart) fantasy.StreamResponse {
|
||||
return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) {
|
||||
for _, part := range parts {
|
||||
if !yield(part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newNoopTool(name string) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
name,
|
||||
"test noop tool",
|
||||
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.ToolResponse{}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func textMessage(role fantasy.MessageRole, text string) fantasy.Message {
|
||||
return fantasy.Message{
|
||||
Role: role,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: text},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsPromptSentinel(prompt []fantasy.Message) bool {
|
||||
for _, message := range prompt {
|
||||
if message.Role != fantasy.MessageRoleUser || len(message.Content) != 1 {
|
||||
continue
|
||||
}
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(textPart.Text, "__chatd_agent_prompt_sentinel_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestRun_MultiStepToolExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCalls int
|
||||
var secondCallPrompt []fantasy.Message
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCalls
|
||||
streamCalls++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// Step 0: produce a tool call.
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{"path":"main.go"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "read_file",
|
||||
ToolCallInput: `{"path":"main.go"}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
default:
|
||||
// Step 1: capture the prompt the loop sent us,
|
||||
// then return plain text.
|
||||
mu.Lock()
|
||||
secondCallPrompt = append([]fantasy.Message(nil), call.Prompt...)
|
||||
mu.Unlock()
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "all done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var persistStepCalls int
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "please read main.go"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
persistStepCalls++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stream was called twice: once for the tool-call step,
|
||||
// once for the follow-up text step.
|
||||
require.Equal(t, 2, streamCalls)
|
||||
|
||||
// PersistStep is called once per step.
|
||||
require.Equal(t, 2, persistStepCalls)
|
||||
|
||||
// The second call's prompt must contain the assistant message
|
||||
// from step 0 (with the tool call) and a tool-result message.
|
||||
require.NotEmpty(t, secondCallPrompt)
|
||||
|
||||
var foundAssistantToolCall bool
|
||||
var foundToolResult bool
|
||||
for _, msg := range secondCallPrompt {
|
||||
if msg.Role == fantasy.MessageRoleAssistant {
|
||||
for _, part := range msg.Content {
|
||||
if tc, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part); ok {
|
||||
if tc.ToolCallID == "tc-1" && tc.ToolName == "read_file" {
|
||||
foundAssistantToolCall = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.Role == fantasy.MessageRoleTool {
|
||||
for _, part := range msg.Content {
|
||||
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
|
||||
if tr.ToolCallID == "tc-1" {
|
||||
foundToolResult = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundAssistantToolCall, "second call prompt should contain assistant tool call from step 0")
|
||||
require.True(t, foundToolResult, "second call prompt should contain tool result message")
|
||||
}
|
||||
|
||||
func TestRun_PersistStepErrorPropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "hello"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
persistErr := xerrors.New("database write failed")
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return persistErr
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "database write failed")
|
||||
}
|
||||
|
||||
// TestRun_ShutdownDuringToolExecutionReturnsContextCanceled verifies that
|
||||
// when the parent context is canceled (simulating server shutdown) while
|
||||
// a tool is blocked, Run returns context.Canceled — not ErrInterrupted.
|
||||
// This matters because the caller uses the error type to decide whether
|
||||
// to set chat status to "pending" (retryable on another worker) vs
|
||||
// "waiting" (stuck forever).
|
||||
func TestRun_ShutdownDuringToolExecutionReturnsContextCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
toolStarted := make(chan struct{})
|
||||
|
||||
// Model returns a single tool call, then finishes.
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-block", ToolCallName: "blocking_tool"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-block", Delta: `{}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-block"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-block",
|
||||
ToolCallName: "blocking_tool",
|
||||
ToolCallInput: `{}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
// Tool that blocks until its context is canceled, simulating
|
||||
// a long-running operation like wait_agent.
|
||||
blockingTool := fantasy.NewAgentTool(
|
||||
"blocking_tool",
|
||||
"blocks until context canceled",
|
||||
func(ctx context.Context, _ struct{}, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
close(toolStarted)
|
||||
<-ctx.Done()
|
||||
return fantasy.ToolResponse{}, ctx.Err()
|
||||
},
|
||||
)
|
||||
|
||||
// Simulate the server context (parent) and chat context
|
||||
// (child). Canceling the parent simulates graceful shutdown.
|
||||
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||
defer serverCancel()
|
||||
|
||||
serverCancelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverCancelDone)
|
||||
<-toolStarted
|
||||
t.Logf("tool started, canceling server context to simulate shutdown")
|
||||
serverCancel()
|
||||
}()
|
||||
|
||||
// persistStep mirrors the FIXED chatd.go code: it only returns
|
||||
// ErrInterrupted when the context was actually canceled due to
|
||||
// an interruption (cause is ErrInterrupted). For shutdown
|
||||
// (plain context.Canceled), it returns the original error so
|
||||
// callers can distinguish the two.
|
||||
persistStep := func(persistCtx context.Context, _ PersistedStep) error {
|
||||
if persistCtx.Err() != nil {
|
||||
if errors.Is(context.Cause(persistCtx), ErrInterrupted) {
|
||||
return ErrInterrupted
|
||||
}
|
||||
return persistCtx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := Run(serverCtx, RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "run the blocking tool"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{blockingTool},
|
||||
MaxSteps: 3,
|
||||
PersistStep: persistStep,
|
||||
})
|
||||
// Wait for the cancel goroutine to finish to aid flake
|
||||
// diagnosis if the test ever hangs.
|
||||
<-serverCancelDone
|
||||
|
||||
require.Error(t, err)
|
||||
// The error must NOT be ErrInterrupted — it should propagate
|
||||
// as context.Canceled so the caller can distinguish shutdown
|
||||
// from user interruption. Use assert (not require) so both
|
||||
// checks are evaluated even if the first fails.
|
||||
assert.NotErrorIs(t, err, ErrInterrupted, "shutdown cancellation must not be converted to ErrInterrupted")
|
||||
assert.ErrorIs(t, err, context.Canceled, "shutdown should propagate as context.Canceled")
|
||||
}
|
||||
|
||||
func TestToResponseMessages_ProviderExecutedToolResultInAssistantMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sr := stepResult{
|
||||
content: []fantasy.Content{
|
||||
// Provider-executed tool call (e.g. web_search).
|
||||
fantasy.ToolCallContent{
|
||||
ToolCallID: "provider-tc-1",
|
||||
ToolName: "web_search",
|
||||
Input: `{"query":"coder"}`,
|
||||
ProviderExecuted: true,
|
||||
},
|
||||
// Provider-executed tool result — must stay in
|
||||
// assistant message.
|
||||
fantasy.ToolResultContent{
|
||||
ToolCallID: "provider-tc-1",
|
||||
ToolName: "web_search",
|
||||
ProviderExecuted: true,
|
||||
ProviderMetadata: fantasy.ProviderMetadata{"anthropic": nil},
|
||||
},
|
||||
// Local tool call (e.g. read_file).
|
||||
fantasy.ToolCallContent{
|
||||
ToolCallID: "local-tc-1",
|
||||
ToolName: "read_file",
|
||||
Input: `{"path":"main.go"}`,
|
||||
ProviderExecuted: false,
|
||||
},
|
||||
// Local tool result — should go into tool message.
|
||||
fantasy.ToolResultContent{
|
||||
ToolCallID: "local-tc-1",
|
||||
ToolName: "read_file",
|
||||
Result: fantasy.ToolResultOutputContentText{Text: "some result"},
|
||||
ProviderExecuted: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := sr.toResponseMessages()
|
||||
require.Len(t, msgs, 2, "expected assistant + tool messages")
|
||||
|
||||
// First message: assistant role.
|
||||
assistantMsg := msgs[0]
|
||||
assert.Equal(t, fantasy.MessageRoleAssistant, assistantMsg.Role)
|
||||
require.Len(t, assistantMsg.Content, 3,
|
||||
"assistant message should have provider ToolCallPart, provider ToolResultPart, and local ToolCallPart")
|
||||
|
||||
// Part 0: provider tool call.
|
||||
providerTC, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](assistantMsg.Content[0])
|
||||
require.True(t, ok, "part 0 should be ToolCallPart")
|
||||
assert.Equal(t, "provider-tc-1", providerTC.ToolCallID)
|
||||
assert.True(t, providerTC.ProviderExecuted)
|
||||
|
||||
// Part 1: provider tool result (inline in assistant turn).
|
||||
providerTR, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](assistantMsg.Content[1])
|
||||
require.True(t, ok, "part 1 should be ToolResultPart")
|
||||
assert.Equal(t, "provider-tc-1", providerTR.ToolCallID)
|
||||
assert.True(t, providerTR.ProviderExecuted)
|
||||
|
||||
// Part 2: local tool call.
|
||||
localTC, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](assistantMsg.Content[2])
|
||||
require.True(t, ok, "part 2 should be ToolCallPart")
|
||||
assert.Equal(t, "local-tc-1", localTC.ToolCallID)
|
||||
assert.False(t, localTC.ProviderExecuted)
|
||||
|
||||
// Second message: tool role.
|
||||
toolMsg := msgs[1]
|
||||
assert.Equal(t, fantasy.MessageRoleTool, toolMsg.Role)
|
||||
require.Len(t, toolMsg.Content, 1,
|
||||
"tool message should have only the local ToolResultPart")
|
||||
|
||||
localTR, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](toolMsg.Content[0])
|
||||
require.True(t, ok, "tool part should be ToolResultPart")
|
||||
assert.Equal(t, "local-tc-1", localTR.ToolCallID)
|
||||
assert.False(t, localTR.ProviderExecuted)
|
||||
}
|
||||
|
||||
func TestToResponseMessages_FiltersEmptyTextAndReasoningParts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sr := stepResult{
|
||||
content: []fantasy.Content{
|
||||
// Empty text — should be filtered.
|
||||
fantasy.TextContent{Text: ""},
|
||||
// Whitespace-only text — should be filtered.
|
||||
fantasy.TextContent{Text: " \t\n"},
|
||||
// Empty reasoning — should be filtered.
|
||||
fantasy.ReasoningContent{Text: ""},
|
||||
// Whitespace-only reasoning — should be filtered.
|
||||
fantasy.ReasoningContent{Text: " \n"},
|
||||
// Non-empty text — should pass through.
|
||||
fantasy.TextContent{Text: "hello world"},
|
||||
// Leading/trailing whitespace with content — kept
|
||||
// with the original value (not trimmed).
|
||||
fantasy.TextContent{Text: " hello "},
|
||||
// Non-empty reasoning — should pass through.
|
||||
fantasy.ReasoningContent{Text: "let me think"},
|
||||
// Tool call — should be unaffected by filtering.
|
||||
fantasy.ToolCallContent{
|
||||
ToolCallID: "tc-1",
|
||||
ToolName: "read_file",
|
||||
Input: `{"path":"main.go"}`,
|
||||
},
|
||||
// Local tool result — should be unaffected by filtering.
|
||||
fantasy.ToolResultContent{
|
||||
ToolCallID: "tc-1",
|
||||
ToolName: "read_file",
|
||||
Result: fantasy.ToolResultOutputContentText{Text: "file contents"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := sr.toResponseMessages()
|
||||
require.Len(t, msgs, 2, "expected assistant + tool messages")
|
||||
|
||||
// First message: assistant role with non-empty text, reasoning,
|
||||
// and the tool call. The four empty/whitespace-only parts must
|
||||
// have been dropped.
|
||||
assistantMsg := msgs[0]
|
||||
assert.Equal(t, fantasy.MessageRoleAssistant, assistantMsg.Role)
|
||||
require.Len(t, assistantMsg.Content, 4,
|
||||
"assistant message should have 2x TextPart, ReasoningPart, and ToolCallPart")
|
||||
|
||||
// Part 0: non-empty text.
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](assistantMsg.Content[0])
|
||||
require.True(t, ok, "part 0 should be TextPart")
|
||||
assert.Equal(t, "hello world", textPart.Text)
|
||||
|
||||
// Part 1: padded text — original whitespace preserved.
|
||||
paddedPart, ok := fantasy.AsMessagePart[fantasy.TextPart](assistantMsg.Content[1])
|
||||
require.True(t, ok, "part 1 should be TextPart")
|
||||
assert.Equal(t, " hello ", paddedPart.Text)
|
||||
|
||||
// Part 2: non-empty reasoning.
|
||||
reasoningPart, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](assistantMsg.Content[2])
|
||||
require.True(t, ok, "part 2 should be ReasoningPart")
|
||||
assert.Equal(t, "let me think", reasoningPart.Text)
|
||||
|
||||
// Part 3: tool call (unaffected by text/reasoning filtering).
|
||||
toolCallPart, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](assistantMsg.Content[3])
|
||||
require.True(t, ok, "part 3 should be ToolCallPart")
|
||||
assert.Equal(t, "tc-1", toolCallPart.ToolCallID)
|
||||
assert.Equal(t, "read_file", toolCallPart.ToolName)
|
||||
|
||||
// Second message: tool role with the local tool result.
|
||||
toolMsg := msgs[1]
|
||||
assert.Equal(t, fantasy.MessageRoleTool, toolMsg.Role)
|
||||
require.Len(t, toolMsg.Content, 1,
|
||||
"tool message should have only the local ToolResultPart")
|
||||
|
||||
toolResultPart, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](toolMsg.Content[0])
|
||||
require.True(t, ok, "tool part should be ToolResultPart")
|
||||
assert.Equal(t, "tc-1", toolResultPart.ToolCallID)
|
||||
}
|
||||
|
||||
func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool {
|
||||
if len(message.ProviderOptions) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
options, ok := message.ProviderOptions[fantasyanthropic.Name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
cacheOptions, ok := options.(*fantasyanthropic.ProviderCacheControlOptions)
|
||||
return ok && cacheOptions.CacheControl.Type == "ephemeral"
|
||||
}
|
||||
|
||||
// TestRun_InterruptedDuringToolExecutionPersistsStep verifies that when
|
||||
// tools are executing and the chat is interrupted, the accumulated step
|
||||
// content (assistant blocks + tool results) is persisted via the
|
||||
// interrupt-safe path rather than being lost.
|
||||
func TestRun_InterruptedDuringToolExecutionPersistsStep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
toolStarted := make(chan struct{})
|
||||
|
||||
// Model returns a completed tool call in the stream.
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "calling tool"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeReasoningStart, ID: "reason-1"},
|
||||
{Type: fantasy.StreamPartTypeReasoningDelta, ID: "reason-1", Delta: "let me think"},
|
||||
{Type: fantasy.StreamPartTypeReasoningEnd, ID: "reason-1"},
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "slow_tool"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{"key":"value"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "slow_tool",
|
||||
ToolCallInput: `{"key":"value"}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
// Tool that blocks until context is canceled, simulating
|
||||
// a long-running operation interrupted by the user.
|
||||
slowTool := fantasy.NewAgentTool(
|
||||
"slow_tool",
|
||||
"blocks until canceled",
|
||||
func(ctx context.Context, _ struct{}, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
close(toolStarted)
|
||||
<-ctx.Done()
|
||||
return fantasy.ToolResponse{}, ctx.Err()
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
defer cancel(nil)
|
||||
|
||||
go func() {
|
||||
<-toolStarted
|
||||
cancel(ErrInterrupted)
|
||||
}()
|
||||
|
||||
var persistedContent []fantasy.Content
|
||||
persistedCtxErr := xerrors.New("unset")
|
||||
|
||||
err := Run(ctx, RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "run the slow tool"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{slowTool},
|
||||
MaxSteps: 3,
|
||||
PersistStep: func(persistCtx context.Context, step PersistedStep) error {
|
||||
persistedCtxErr = persistCtx.Err()
|
||||
persistedContent = append([]fantasy.Content(nil), step.Content...)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrInterrupted)
|
||||
// persistInterruptedStep uses context.WithoutCancel, so the
|
||||
// persist callback should see a non-canceled context.
|
||||
require.NoError(t, persistedCtxErr)
|
||||
require.NotEmpty(t, persistedContent)
|
||||
|
||||
var (
|
||||
foundText bool
|
||||
foundReasoning bool
|
||||
foundToolCall bool
|
||||
foundToolResult bool
|
||||
)
|
||||
for _, block := range persistedContent {
|
||||
if text, ok := fantasy.AsContentType[fantasy.TextContent](block); ok {
|
||||
if strings.Contains(text.Text, "calling tool") {
|
||||
foundText = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](block); ok {
|
||||
if strings.Contains(reasoning.Text, "let me think") {
|
||||
foundReasoning = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok {
|
||||
if toolCall.ToolCallID == "tc-1" && toolCall.ToolName == "slow_tool" {
|
||||
foundToolCall = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok {
|
||||
if toolResult.ToolCallID == "tc-1" {
|
||||
foundToolResult = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundText, "persisted content should include text from the stream")
|
||||
require.True(t, foundReasoning, "persisted content should include reasoning from the stream")
|
||||
require.True(t, foundToolCall, "persisted content should include the tool call")
|
||||
require.True(t, foundToolResult, "persisted content should include the tool result (error from cancellation)")
|
||||
}
|
||||
|
||||
// TestRun_PersistStepInterruptedFallback verifies that when the normal
|
||||
// PersistStep call returns ErrInterrupted (e.g., context canceled in a
|
||||
// race), the step is retried via the interrupt-safe path.
|
||||
func TestRun_PersistStepInterruptedFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "hello world"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
persistCalls int
|
||||
savedContent []fantasy.Content
|
||||
)
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
persistCalls++
|
||||
if persistCalls == 1 {
|
||||
// First call: simulate an interrupt race by
|
||||
// returning ErrInterrupted without persisting.
|
||||
return ErrInterrupted
|
||||
}
|
||||
// Second call (from persistInterruptedStep fallback):
|
||||
// accept the content.
|
||||
savedContent = append([]fantasy.Content(nil), step.Content...)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrInterrupted)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, 2, persistCalls, "PersistStep should be called twice: once normally (failing), once via fallback")
|
||||
require.NotEmpty(t, savedContent)
|
||||
|
||||
var foundText bool
|
||||
for _, block := range savedContent {
|
||||
if text, ok := fantasy.AsContentType[fantasy.TextContent](block); ok {
|
||||
if strings.Contains(text.Text, "hello world") {
|
||||
foundText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundText, "fallback should persist the text content")
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package chatloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCompactionThresholdPercent = int32(70)
|
||||
minCompactionThresholdPercent = int32(0)
|
||||
maxCompactionThresholdPercent = int32(100)
|
||||
|
||||
defaultCompactionSummaryPrompt = "You are performing a context compaction. " +
|
||||
"Summarize the conversation so a new assistant can seamlessly " +
|
||||
"continue the work in progress.\n\n" +
|
||||
"Include:\n" +
|
||||
"- The user's overall goal and current task\n" +
|
||||
"- Key decisions made and their rationale\n" +
|
||||
"- Concrete technical details: file paths, function names, " +
|
||||
"commands, APIs, and configurations\n" +
|
||||
"- Errors encountered and how they were resolved\n" +
|
||||
"- Current state of the work: what is DONE, what is IN PROGRESS, " +
|
||||
"and what REMAINS to be done\n" +
|
||||
"- The specific action the assistant was performing or about to " +
|
||||
"perform when this summary was triggered\n\n" +
|
||||
"Be dense and factual. Every sentence should convey essential " +
|
||||
"context for continuation. Do not include pleasantries or " +
|
||||
"conversational filler."
|
||||
defaultCompactionSystemSummaryPrefix = "The following is a summary of " +
|
||||
"the earlier conversation. The assistant was actively working when " +
|
||||
"the context was compacted. Continue the work described below:"
|
||||
defaultCompactionTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
type CompactionOptions struct {
|
||||
ThresholdPercent int32
|
||||
ContextLimit int64
|
||||
SummaryPrompt string
|
||||
SystemSummaryPrefix string
|
||||
Timeout time.Duration
|
||||
Persist func(context.Context, CompactionResult) error
|
||||
|
||||
// ToolCallID and ToolName identify the synthetic tool call
|
||||
// used to represent compaction in the message stream.
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
|
||||
// PublishMessagePart publishes streaming parts to connected
|
||||
// clients so they see "Summarizing..." / "Summarized" UI
|
||||
// transitions during compaction.
|
||||
PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)
|
||||
|
||||
OnError func(error)
|
||||
}
|
||||
|
||||
type CompactionResult struct {
|
||||
SystemSummary string
|
||||
SummaryReport string
|
||||
ThresholdPercent int32
|
||||
UsagePercent float64
|
||||
ContextTokens int64
|
||||
ContextLimit int64
|
||||
}
|
||||
|
||||
// tryCompact checks whether context usage exceeds the compaction
|
||||
// threshold and, if so, generates and persists a summary. Returns
|
||||
// (true, nil) when compaction was performed, (false, nil) when not
|
||||
// needed, and (false, err) on failure.
|
||||
func tryCompact(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
compaction *CompactionOptions,
|
||||
contextLimitFallback int64,
|
||||
stepUsage fantasy.Usage,
|
||||
stepMetadata fantasy.ProviderMetadata,
|
||||
allMessages []fantasy.Message,
|
||||
) (bool, error) {
|
||||
config, ok := normalizedCompactionConfig(compaction)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
contextTokens := contextTokensFromUsage(stepUsage)
|
||||
if contextTokens <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
metadataLimit := extractContextLimit(stepMetadata)
|
||||
contextLimit := resolveContextLimit(
|
||||
metadataLimit.Int64,
|
||||
config.ContextLimit,
|
||||
contextLimitFallback,
|
||||
)
|
||||
|
||||
usagePercent, compact := shouldCompact(
|
||||
contextTokens, contextLimit, config.ThresholdPercent,
|
||||
)
|
||||
if !compact {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Publish the "Summarizing..." tool-call indicator so
|
||||
// connected clients see activity during summary generation.
|
||||
if config.PublishMessagePart != nil && config.ToolCallID != "" {
|
||||
config.PublishMessagePart(
|
||||
codersdk.ChatMessageRoleAssistant,
|
||||
codersdk.ChatMessageToolCall(config.ToolCallID, config.ToolName, nil),
|
||||
)
|
||||
}
|
||||
|
||||
summary, err := generateCompactionSummary(
|
||||
ctx, model, allMessages, config,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if summary == "" {
|
||||
// Publish a tool-result error so connected clients
|
||||
// see the compaction failure.
|
||||
publishCompactionError(config, "compaction produced an empty summary")
|
||||
return false, xerrors.New("compaction produced an empty summary")
|
||||
}
|
||||
|
||||
systemSummary := strings.TrimSpace(
|
||||
config.SystemSummaryPrefix + "\n\n" + summary,
|
||||
)
|
||||
|
||||
persistCtx := context.WithoutCancel(ctx)
|
||||
err = config.Persist(persistCtx, CompactionResult{
|
||||
SystemSummary: systemSummary,
|
||||
SummaryReport: summary,
|
||||
ThresholdPercent: config.ThresholdPercent,
|
||||
UsagePercent: usagePercent,
|
||||
ContextTokens: contextTokens,
|
||||
ContextLimit: contextLimit,
|
||||
})
|
||||
if err != nil {
|
||||
publishCompactionError(config, "failed to persist compaction result")
|
||||
return false, xerrors.Errorf("persist compaction: %w", err)
|
||||
}
|
||||
|
||||
// Publish the "Summarized" tool-result part so the client
|
||||
// transitions from the in-progress indicator to the final
|
||||
// state.
|
||||
if config.PublishMessagePart != nil && config.ToolCallID != "" {
|
||||
resultJSON, _ := json.Marshal(map[string]any{
|
||||
"summary": summary,
|
||||
"source": "automatic",
|
||||
"threshold_percent": config.ThresholdPercent,
|
||||
"usage_percent": usagePercent,
|
||||
"context_tokens": contextTokens,
|
||||
"context_limit_tokens": contextLimit,
|
||||
})
|
||||
config.PublishMessagePart(
|
||||
codersdk.ChatMessageRoleTool,
|
||||
codersdk.ChatMessageToolResult(config.ToolCallID, config.ToolName, resultJSON, false),
|
||||
)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// publishCompactionError sends a tool-result error part so
|
||||
// connected clients see that compaction failed.
|
||||
func publishCompactionError(config CompactionOptions, msg string) {
|
||||
if config.PublishMessagePart == nil || config.ToolCallID == "" {
|
||||
return
|
||||
}
|
||||
errJSON, _ := json.Marshal(map[string]any{
|
||||
"error": msg,
|
||||
})
|
||||
config.PublishMessagePart(
|
||||
codersdk.ChatMessageRoleTool,
|
||||
codersdk.ChatMessageToolResult(config.ToolCallID, config.ToolName, errJSON, true),
|
||||
)
|
||||
}
|
||||
|
||||
// normalizedCompactionConfig returns a copy of the compaction options
|
||||
// with defaults applied. The bool is false when compaction is
|
||||
// disabled (nil options, missing Persist callback, or threshold at
|
||||
// 100%).
|
||||
func normalizedCompactionConfig(opts *CompactionOptions) (CompactionOptions, bool) {
|
||||
if opts == nil {
|
||||
return CompactionOptions{}, false
|
||||
}
|
||||
|
||||
config := *opts
|
||||
if config.Persist == nil {
|
||||
return CompactionOptions{}, false
|
||||
}
|
||||
if strings.TrimSpace(config.SummaryPrompt) == "" {
|
||||
config.SummaryPrompt = defaultCompactionSummaryPrompt
|
||||
}
|
||||
if strings.TrimSpace(config.SystemSummaryPrefix) == "" {
|
||||
config.SystemSummaryPrefix = defaultCompactionSystemSummaryPrefix
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = defaultCompactionTimeout
|
||||
}
|
||||
if config.ThresholdPercent < minCompactionThresholdPercent ||
|
||||
config.ThresholdPercent > maxCompactionThresholdPercent {
|
||||
config.ThresholdPercent = defaultCompactionThresholdPercent
|
||||
}
|
||||
if config.ThresholdPercent == maxCompactionThresholdPercent {
|
||||
return CompactionOptions{}, false
|
||||
}
|
||||
|
||||
return config, true
|
||||
}
|
||||
|
||||
// contextTokensFromUsage returns the total context token count from
|
||||
// a step's usage report. It sums input, cache-read, and
|
||||
// cache-creation tokens when available, falling back to TotalTokens
|
||||
// if none of the granular fields are set.
|
||||
func contextTokensFromUsage(usage fantasy.Usage) int64 {
|
||||
total := int64(0)
|
||||
hasContextTokens := false
|
||||
|
||||
if usage.InputTokens > 0 {
|
||||
total += usage.InputTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if usage.CacheReadTokens > 0 {
|
||||
total += usage.CacheReadTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if usage.CacheCreationTokens > 0 {
|
||||
total += usage.CacheCreationTokens
|
||||
hasContextTokens = true
|
||||
}
|
||||
if !hasContextTokens && usage.TotalTokens > 0 {
|
||||
total = usage.TotalTokens
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// resolveContextLimit picks the first positive value from metadata,
|
||||
// configured limit, and fallback — in that priority order. Returns
|
||||
// 0 when none are positive.
|
||||
func resolveContextLimit(metadataLimit, configLimit, fallback int64) int64 {
|
||||
if metadataLimit > 0 {
|
||||
return metadataLimit
|
||||
}
|
||||
if configLimit > 0 {
|
||||
return configLimit
|
||||
}
|
||||
if fallback > 0 {
|
||||
return fallback
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// shouldCompact returns the usage percentage and whether it exceeds
|
||||
// the threshold. Returns (0, false) when contextLimit is
|
||||
// non-positive.
|
||||
func shouldCompact(contextTokens, contextLimit int64, thresholdPercent int32) (float64, bool) {
|
||||
if contextLimit <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
|
||||
return usagePercent, usagePercent >= float64(thresholdPercent)
|
||||
}
|
||||
|
||||
// generateCompactionSummary asks the model to summarize the
|
||||
// conversation so far. The provided messages should contain the
|
||||
// complete history (system prompt, user/assistant turns, tool
|
||||
// results). A final user message with the summary prompt is appended
|
||||
// before calling the model.
|
||||
func generateCompactionSummary(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
options CompactionOptions,
|
||||
) (string, error) {
|
||||
summaryPrompt := make([]fantasy.Message, 0, len(messages)+1)
|
||||
summaryPrompt = append(summaryPrompt, messages...)
|
||||
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: options.SummaryPrompt},
|
||||
},
|
||||
})
|
||||
toolChoice := fantasy.ToolChoiceNone
|
||||
|
||||
summaryCtx, cancel := context.WithTimeout(ctx, options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
response, err := model.Generate(summaryCtx, fantasy.Call{
|
||||
Prompt: summaryPrompt,
|
||||
ToolChoice: &toolChoice,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("generate summary text: %w", err)
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(response.Content))
|
||||
for _, block := range response.Content {
|
||||
textBlock, ok := fantasy.AsContentType[fantasy.TextContent](block)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(textBlock.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, text)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " ")), nil
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestRun_Compaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PersistsWhenThresholdReached", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
persistCompactionCalls := 0
|
||||
var persistedCompaction CompactionResult
|
||||
const summaryText = "summary text for compaction"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
require.NotEmpty(t, call.Prompt)
|
||||
lastPrompt := call.Prompt[len(call.Prompt)-1]
|
||||
require.Equal(t, fantasy.MessageRoleUser, lastPrompt.Role)
|
||||
require.Len(t, lastPrompt.Content, 1)
|
||||
|
||||
instruction, ok := fantasy.AsMessagePart[fantasy.TextPart](lastPrompt.Content[0])
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "summarize now", instruction.Text)
|
||||
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, result CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
persistedCompaction = result
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Compaction fires twice: once inline when the threshold is
|
||||
// reached on step 0 (the only step, since MaxSteps=1), and
|
||||
// once from the post-run safety net during the re-entry
|
||||
// iteration (where totalSteps already equals MaxSteps so the
|
||||
// inner loop doesn't execute, but lastUsage still exceeds
|
||||
// the threshold).
|
||||
require.Equal(t, 2, persistCompactionCalls)
|
||||
require.Contains(t, persistedCompaction.SystemSummary, summaryText)
|
||||
require.Equal(t, summaryText, persistedCompaction.SummaryReport)
|
||||
require.Equal(t, int64(80), persistedCompaction.ContextTokens)
|
||||
require.Equal(t, int64(100), persistedCompaction.ContextLimit)
|
||||
require.InDelta(t, 80.0, persistedCompaction.UsagePercent, 0.0001)
|
||||
})
|
||||
|
||||
t.Run("PublishesPartsBeforeAndAfterPersist", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const summaryText = "compaction summary for ordering test"
|
||||
|
||||
// Track the order of callbacks to verify the tool-call
|
||||
// part publishes before Generate (summary generation)
|
||||
// and the tool-result part publishes after Persist.
|
||||
var callOrder []string
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
callOrder = append(callOrder, "generate")
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
ToolCallID: "test-tool-call-id",
|
||||
ToolName: "chat_summarized",
|
||||
PublishMessagePart: func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeToolCall:
|
||||
callOrder = append(callOrder, "publish_tool_call")
|
||||
case codersdk.ChatMessagePartTypeToolResult:
|
||||
callOrder = append(callOrder, "publish_tool_result")
|
||||
}
|
||||
},
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
callOrder = append(callOrder, "persist")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Compaction fires twice (see PersistsWhenThresholdReached
|
||||
// for the full explanation). Each cycle follows the order:
|
||||
// publish_tool_call → generate → persist → publish_tool_result.
|
||||
require.Equal(t, []string{
|
||||
"publish_tool_call",
|
||||
"generate",
|
||||
"persist",
|
||||
"publish_tool_result",
|
||||
"publish_tool_call",
|
||||
"generate",
|
||||
"persist",
|
||||
"publish_tool_result",
|
||||
}, callOrder)
|
||||
})
|
||||
|
||||
t.Run("PublishNotCalledBelowThreshold", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
publishCalled := false
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 10,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
ToolCallID: "test-tool-call-id",
|
||||
ToolName: "chat_summarized",
|
||||
PublishMessagePart: func(_ codersdk.ChatMessageRole, _ codersdk.ChatMessagePart) {
|
||||
publishCalled = true
|
||||
},
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, publishCalled, "PublishMessagePart should not fire when usage is below threshold")
|
||||
})
|
||||
|
||||
t.Run("MidLoopCompactionReloadsMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCallCount int
|
||||
persistCompactionCalls := 0
|
||||
reloadCalls := 0
|
||||
|
||||
const summaryText = "compacted summary"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCallCount
|
||||
streamCallCount++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// Step 0: tool call with high usage (80/100 = 80% > 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "read_file",
|
||||
ToolCallInput: `{}`,
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonToolCalls,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
// Step 1: text with low usage (30/100 = 30% < 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 30,
|
||||
TotalTokens: 35,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
compactedMessages := []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "compacted system"),
|
||||
textMessage(fantasy.MessageRoleUser, "compacted user"),
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
reloadCalls++
|
||||
return compactedMessages, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compaction fired after step 0 (above threshold).
|
||||
require.GreaterOrEqual(t, persistCompactionCalls, 1)
|
||||
// ReloadMessages was called after mid-loop compaction.
|
||||
require.GreaterOrEqual(t, reloadCalls, 1)
|
||||
// Both steps ran (tool-call step + follow-up text step).
|
||||
require.Equal(t, 2, streamCallCount)
|
||||
})
|
||||
|
||||
t.Run("PostRunCompactionSkippedAfterMidLoop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCallCount int
|
||||
persistCompactionCalls := 0
|
||||
|
||||
const summaryText = "compacted summary for skip test"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCallCount
|
||||
streamCallCount++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// Step 0: tool call with high usage (80/100 = 80% > 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-1",
|
||||
ToolCallName: "read_file",
|
||||
ToolCallInput: `{}`,
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonToolCalls,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
// Step 1: text with low usage (20/100 = 20% < 70%).
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 20,
|
||||
TotalTokens: 25,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
compactedMessages := []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "compacted system"),
|
||||
textMessage(fantasy.MessageRoleUser, "compacted user"),
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return compactedMessages, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only mid-loop compaction fires after step 0. The post-run
|
||||
// safety net is skipped because alreadyCompacted is true.
|
||||
require.Equal(t, 1, persistCompactionCalls)
|
||||
})
|
||||
|
||||
t.Run("ErrorsAreReported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return nil, xerrors.New("generate failed")
|
||||
},
|
||||
}
|
||||
|
||||
compactionErr := xerrors.New("unset")
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
return nil
|
||||
},
|
||||
OnError: func(err error) {
|
||||
compactionErr = err
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Error(t, compactionErr)
|
||||
require.ErrorContains(t, compactionErr, "generate summary text")
|
||||
})
|
||||
|
||||
t.Run("PostRunCompactionReEntersStepLoop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// When post-run compaction fires (no mid-loop compaction)
|
||||
// and ReloadMessages is provided, Run should re-enter the
|
||||
// step loop with the reloaded messages so the agent
|
||||
// continues working.
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCallCount int
|
||||
persistCompactionCalls := 0
|
||||
reloadCalls := 0
|
||||
|
||||
const summaryText = "post-run compacted summary"
|
||||
|
||||
compactedMessages := []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "compacted system"),
|
||||
textMessage(fantasy.MessageRoleUser, "compacted user"),
|
||||
}
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCallCount
|
||||
streamCallCount++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
// First turn: text-only response with high usage.
|
||||
// No tool calls, so shouldContinue = false and
|
||||
// the inner step loop breaks. Compaction should
|
||||
// fire, then the outer loop re-enters.
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "initial response"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
// Second turn (after compaction re-entry):
|
||||
// text-only with low usage — should finish.
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-2"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-2", Delta: "continued after compaction"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-2"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 20,
|
||||
TotalTokens: 25,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
reloadCalls++
|
||||
return compactedMessages, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compaction fired on the final step of the first pass.
|
||||
// The inline path fires (ReloadMessages is set) and then
|
||||
// the outer loop re-enters. On the second pass the usage
|
||||
// is below threshold so no further compaction occurs.
|
||||
require.GreaterOrEqual(t, persistCompactionCalls, 1)
|
||||
// ReloadMessages was called (inline + re-entry).
|
||||
require.GreaterOrEqual(t, reloadCalls, 1)
|
||||
// Two stream calls: one before compaction, one after re-entry.
|
||||
require.Equal(t, 2, streamCallCount)
|
||||
})
|
||||
|
||||
t.Run("PostRunCompactionReEntryIncludesUserSummary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// After compaction the summary is stored as a user-role
|
||||
// message. When the loop re-enters, the reloaded prompt
|
||||
// must contain this user message so the LLM provider
|
||||
// receives a valid prompt (providers like Anthropic
|
||||
// require at least one non-system message).
|
||||
|
||||
var mu sync.Mutex
|
||||
var streamCallCount int
|
||||
var reEntryPrompt []fantasy.Message
|
||||
persistCompactionCalls := 0
|
||||
|
||||
const summaryText = "post-run compacted summary"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
mu.Lock()
|
||||
step := streamCallCount
|
||||
streamCallCount++
|
||||
mu.Unlock()
|
||||
|
||||
switch step {
|
||||
case 0:
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "initial response"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
mu.Lock()
|
||||
reEntryPrompt = append([]fantasy.Message(nil), call.Prompt...)
|
||||
mu.Unlock()
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-2"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-2", Delta: "continued"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-2"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 20,
|
||||
TotalTokens: 25,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
// Simulate real post-compaction DB state: the summary is
|
||||
// a user-role message (the only non-system content).
|
||||
compactedMessages := []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "system prompt"),
|
||||
textMessage(fantasy.MessageRoleUser, "Summary of earlier chat context:\n\ncompacted summary"),
|
||||
}
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 5,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
return nil
|
||||
},
|
||||
},
|
||||
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
|
||||
return compactedMessages, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.GreaterOrEqual(t, persistCompactionCalls, 1)
|
||||
// Re-entry happened: stream was called at least twice.
|
||||
require.Equal(t, 2, streamCallCount)
|
||||
// The re-entry prompt must contain the user summary.
|
||||
require.NotEmpty(t, reEntryPrompt)
|
||||
hasUser := false
|
||||
for _, msg := range reEntryPrompt {
|
||||
if msg.Role == fantasy.MessageRoleUser {
|
||||
hasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, hasUser, "re-entry prompt must contain a user message (the compaction summary)")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package chatloop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testProviderData implements fantasy.ProviderOptionsData so we can
|
||||
// construct arbitrary ProviderMetadata for extractContextLimit tests.
|
||||
type testProviderData struct {
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func (*testProviderData) Options() {}
|
||||
|
||||
func (d *testProviderData) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.data)
|
||||
}
|
||||
|
||||
// Required by the ProviderOptionsData interface; unused in tests.
|
||||
func (d *testProviderData) UnmarshalJSON(b []byte) error {
|
||||
return json.Unmarshal(b, &d.data)
|
||||
}
|
||||
|
||||
func TestNormalizeMetadataKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{name: "lowercase", key: "camelCase", want: "camelcase"},
|
||||
{name: "hyphens stripped", key: "kebab-case", want: "kebabcase"},
|
||||
{name: "underscores stripped", key: "snake_case", want: "snakecase"},
|
||||
{name: "uppercase", key: "UPPER", want: "upper"},
|
||||
{name: "spaces stripped", key: "with spaces", want: "withspaces"},
|
||||
{name: "empty", key: "", want: ""},
|
||||
{name: "digits preserved", key: "123", want: "123"},
|
||||
{name: "mixed separators", key: "Max_Context-Tokens", want: "maxcontexttokens"},
|
||||
{name: "dots stripped", key: "context.limit", want: "contextlimit"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := normalizeMetadataKey(tt.key)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContextLimitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want bool
|
||||
skip bool
|
||||
}{ // Exact matches after normalization.
|
||||
{name: "context_limit", key: "context_limit", want: true},
|
||||
{name: "context_window", key: "context_window", want: true},
|
||||
{name: "context_length", key: "context_length", want: true},
|
||||
{name: "max_context", key: "max_context", want: true},
|
||||
{name: "max_context_tokens", key: "max_context_tokens", want: true},
|
||||
{name: "max_input_tokens", key: "max_input_tokens", want: true},
|
||||
{name: "max_input_token", key: "max_input_token", want: true},
|
||||
{name: "input_token_limit", key: "input_token_limit", want: true},
|
||||
|
||||
// Case and separator variations.
|
||||
{name: "Context-Window mixed case", key: "Context-Window", want: true},
|
||||
{name: "MAX_CONTEXT_TOKENS screaming", key: "MAX_CONTEXT_TOKENS", want: true},
|
||||
{name: "contextLimit camelCase", key: "contextLimit", want: true},
|
||||
|
||||
// Fallback heuristic: contains "context" + limit/window/length.
|
||||
{name: "model_context_limit", key: "model_context_limit", want: true},
|
||||
{name: "context_window_size", key: "context_window_size", want: true},
|
||||
{name: "context_length_max", key: "context_length_max", want: true},
|
||||
|
||||
// Fallback heuristic: starts with "max" + contains "context".
|
||||
// BUG(isContextLimitKey): "max_context_version" matches
|
||||
// because it contains "context" and starts with "max",
|
||||
// but a version field is not a context limit.
|
||||
// TODO: Fix the heuristic and remove this skip.
|
||||
{name: "max_context_version false positive", key: "max_context_version", want: false, skip: true}, // Non-matching keys.
|
||||
{name: "context_id no limit keyword", key: "context_id", want: false},
|
||||
{name: "empty string", key: "", want: false},
|
||||
{name: "unrelated key", key: "model_name", want: false},
|
||||
{name: "limit without context", key: "rate_limit", want: false},
|
||||
{name: "max without context", key: "max_tokens", want: false},
|
||||
{name: "context alone", key: "context", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if tt.skip {
|
||||
t.Skip("known bug: isContextLimitKey false positive")
|
||||
}
|
||||
got := isContextLimitKey(tt.key)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericContextLimitValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want int64
|
||||
wantOK bool
|
||||
}{
|
||||
// float64: the default numeric type from json.Unmarshal.
|
||||
{name: "float64 integer", value: float64(128000), want: 128000, wantOK: true},
|
||||
{name: "float64 fractional rejected", value: float64(128000.5), want: 0, wantOK: false},
|
||||
{name: "float64 zero rejected", value: float64(0), want: 0, wantOK: false},
|
||||
{name: "float64 negative rejected", value: float64(-1), want: 0, wantOK: false},
|
||||
|
||||
// int64
|
||||
{name: "int64 positive", value: int64(200000), want: 200000, wantOK: true},
|
||||
{name: "int64 zero rejected", value: int64(0), want: 0, wantOK: false},
|
||||
{name: "int64 negative rejected", value: int64(-1), want: 0, wantOK: false},
|
||||
|
||||
// int32
|
||||
{name: "int32 positive", value: int32(50000), want: 50000, wantOK: true},
|
||||
{name: "int32 zero rejected", value: int32(0), want: 0, wantOK: false},
|
||||
|
||||
// int
|
||||
{name: "int positive", value: int(50000), want: 50000, wantOK: true},
|
||||
{name: "int zero rejected", value: int(0), want: 0, wantOK: false},
|
||||
|
||||
// string
|
||||
{name: "string numeric", value: "128000", want: 128000, wantOK: true},
|
||||
{name: "string trimmed", value: " 128000 ", want: 128000, wantOK: true},
|
||||
{name: "string non-numeric rejected", value: "not a number", want: 0, wantOK: false},
|
||||
{name: "string empty rejected", value: "", want: 0, wantOK: false},
|
||||
{name: "string zero rejected", value: "0", want: 0, wantOK: false},
|
||||
{name: "string negative rejected", value: "-1", want: 0, wantOK: false},
|
||||
|
||||
// json.Number
|
||||
{name: "json.Number valid", value: json.Number("200000"), want: 200000, wantOK: true},
|
||||
{name: "json.Number invalid rejected", value: json.Number("invalid"), want: 0, wantOK: false},
|
||||
{name: "json.Number zero rejected", value: json.Number("0"), want: 0, wantOK: false},
|
||||
|
||||
// Unhandled types.
|
||||
{name: "bool rejected", value: true, want: 0, wantOK: false},
|
||||
{name: "nil rejected", value: nil, want: 0, wantOK: false},
|
||||
{name: "slice rejected", value: []int{1}, want: 0, wantOK: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := numericContextLimitValue(tt.value)
|
||||
require.Equal(t, tt.wantOK, ok)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositiveInt64(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := positiveInt64(42)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(42), got)
|
||||
|
||||
got, ok = positiveInt64(0)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, int64(0), got)
|
||||
|
||||
got, ok = positiveInt64(-1)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, int64(0), got)
|
||||
}
|
||||
|
||||
func TestCollectContextLimitValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("FlatMap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"context_limit": float64(200000),
|
||||
"other_key": float64(999),
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Equal(t, []int64{200000}, collected)
|
||||
})
|
||||
|
||||
t.Run("NestedMaps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"provider": map[string]any{
|
||||
"info": map[string]any{
|
||||
"context_window": float64(100000),
|
||||
},
|
||||
},
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Equal(t, []int64{100000}, collected)
|
||||
})
|
||||
|
||||
t.Run("ArrayTraversal", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []any{
|
||||
map[string]any{"context_limit": float64(50000)},
|
||||
map[string]any{"context_limit": float64(80000)},
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Len(t, collected, 2)
|
||||
require.Contains(t, collected, int64(50000))
|
||||
require.Contains(t, collected, int64(80000))
|
||||
})
|
||||
|
||||
t.Run("MixedNesting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"models": []any{
|
||||
map[string]any{
|
||||
"context_limit": float64(128000),
|
||||
},
|
||||
},
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Equal(t, []int64{128000}, collected)
|
||||
})
|
||||
|
||||
t.Run("NonMatchingKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"model_name": "gpt-4",
|
||||
"tokens": float64(1000),
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Empty(t, collected)
|
||||
})
|
||||
|
||||
t.Run("ScalarIgnored", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var collected []int64
|
||||
collectContextLimitValues("just a string", func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Empty(t, collected)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindContextLimitValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SingleCandidate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"context_limit": float64(200000),
|
||||
}
|
||||
limit, ok := findContextLimitValue(input)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(200000), limit)
|
||||
})
|
||||
|
||||
t.Run("MultipleCandidatesTakesMax", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"a": map[string]any{"context_limit": float64(50000)},
|
||||
"b": map[string]any{"context_limit": float64(200000)},
|
||||
}
|
||||
limit, ok := findContextLimitValue(input)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(200000), limit)
|
||||
})
|
||||
|
||||
t.Run("NoCandidates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"model": "gpt-4",
|
||||
}
|
||||
_, ok := findContextLimitValue(input)
|
||||
require.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("NilInput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := findContextLimitValue(nil)
|
||||
require.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractContextLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("AnthropicStyle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"anthropic": &testProviderData{
|
||||
data: map[string]any{
|
||||
"cache_read_input_tokens": float64(100),
|
||||
"context_limit": float64(200000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(200000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("OpenAIStyle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"openai": &testProviderData{
|
||||
data: map[string]any{
|
||||
"max_context_tokens": float64(128000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(128000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("NestedDeeply", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"provider": &testProviderData{
|
||||
data: map[string]any{
|
||||
"info": map[string]any{
|
||||
"context_window": float64(100000),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(100000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("MultipleCandidatesTakesMax", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"a": &testProviderData{
|
||||
data: map[string]any{
|
||||
"context_limit": float64(50000),
|
||||
},
|
||||
},
|
||||
"b": &testProviderData{
|
||||
data: map[string]any{
|
||||
"context_limit": float64(200000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(200000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("NoMatchingKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"openai": &testProviderData{
|
||||
data: map[string]any{
|
||||
"model": "gpt-4",
|
||||
"tokens": float64(1000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("NilMetadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := extractContextLimit(nil)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("EmptyMetadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := extractContextLimit(fantasy.ProviderMetadata{})
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user