172 lines
6.1 KiB
Go
172 lines
6.1 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
"yingce/backend/internal/database"
|
||
"yingce/backend/internal/model"
|
||
)
|
||
|
||
// piOutputLimitFixture 复用现有测试库入口;PostgreSQL 只写独立临时 schema。
|
||
// 模型结果由测试直接填入任务,不启动 Pi、HTTP 上游或真实生成。
|
||
func piOutputLimitFixture(t *testing.T) (*Service, *gorm.DB, string) {
|
||
t.Helper()
|
||
var s *Service
|
||
var db *gorm.DB
|
||
if os.Getenv("CANVAS_TEST_POSTGRES_DSN") == "" {
|
||
s, db, _, _ = creationTestService(t)
|
||
} else {
|
||
s, db = newProjectNameConflictPostgresService(t)
|
||
s.dataDir = t.TempDir()
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||
if err := db.AutoMigrate(database.Models()...); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
profile := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel("chat-completion", "text-test"))
|
||
for _, row := range []any{
|
||
&model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Enabled: true},
|
||
&model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: profile, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true},
|
||
&model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true},
|
||
&model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000},
|
||
} {
|
||
if err := db.Create(row).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
}
|
||
s.disablePiRuntime, s.legacyCloudAgentRootTask = true, false
|
||
if err := os.WriteFile(filepath.Join(s.dataDir, ".settings-key"), make([]byte, 32), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return s, db, root.ID
|
||
}
|
||
|
||
// TestPiOutputLimitCorrection 验证真实模型桥的边界、纠正上下文与有限重试。
|
||
// 超限批次不得进入 Calls 或执行;最终失败也必须清理活动任务且给出可读原因。
|
||
func TestPiOutputLimitCorrection(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
counts []int
|
||
fail bool
|
||
longText bool
|
||
}{
|
||
{"eight", []int{8}, false, false},
|
||
{"nine-then-eight", []int{9, 8}, false, false},
|
||
{"thirty-one-then-one", []int{31, 1}, false, false},
|
||
{"bounded-failure", []int{31, 31, 31, 31}, true, false},
|
||
{"oversized-text", []int{0, 1}, false, true},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
s, db, runID := piOutputLimitFixture(t)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||
defer cancel()
|
||
type outcome struct {
|
||
result any
|
||
err error
|
||
}
|
||
done := make(chan outcome, 1)
|
||
go func() {
|
||
r, err := s.cloudAgentPiModel(ctx, "user", runID, map[string]json.RawMessage{"messages": json.RawMessage(`[{"role":"user","content":"继续"}]`), "thinkingLevel": json.RawMessage(`"off"`)})
|
||
done <- outcome{r, err}
|
||
}()
|
||
previous := ""
|
||
for attempt, count := range tc.counts {
|
||
var task model.Task
|
||
for task.ID == "" {
|
||
_, state := agentInterjectionState(t, s, runID)
|
||
if state.ActiveTaskID != "" && state.ActiveTaskID != previous {
|
||
if len(state.Calls) != 0 {
|
||
t.Fatal("rejected calls entered the checkpoint")
|
||
}
|
||
if err := db.First(&task, "id = ?", state.ActiveTaskID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
break
|
||
}
|
||
select {
|
||
case got := <-done:
|
||
t.Fatalf("model bridge ended before expected task: %v", got.err)
|
||
case <-ctx.Done():
|
||
t.Fatal("model task not scheduled")
|
||
case <-time.After(20 * time.Millisecond):
|
||
}
|
||
}
|
||
if attempt > 0 && (!strings.Contains(task.InputJSON, "invalid_output") || !strings.Contains(task.InputJSON, "maxToolCalls")) {
|
||
t.Fatal("retry missing output-limit correction")
|
||
}
|
||
calls := make([]cloudAgentCall, count)
|
||
for i := range calls {
|
||
calls[i].ID = fmt.Sprintf("call-%d-%d", attempt, i)
|
||
calls[i].Function.Name = "canvas_get_state"
|
||
calls[i].Function.Arguments = "{}"
|
||
}
|
||
text := ""
|
||
if tc.longText && attempt == 0 {
|
||
text = strings.Repeat("x", cloudAgentMaxOutputBytes+1)
|
||
}
|
||
raw, err := json.Marshal(map[string]any{"text": text, "toolCalls": calls})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": string(raw)}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
previous = task.ID
|
||
}
|
||
select {
|
||
case got := <-done:
|
||
if tc.fail {
|
||
if got.err == nil || !strings.Contains(got.err.Error(), "31 个工具调用") || strings.Contains(got.err.Error(), "checkpoint") {
|
||
t.Fatalf("wrong terminal error: %v", got.err)
|
||
}
|
||
} else if got.err != nil {
|
||
t.Fatal(got.err)
|
||
}
|
||
case <-ctx.Done():
|
||
t.Fatal("model bridge did not finish")
|
||
}
|
||
_, state := agentInterjectionState(t, s, runID)
|
||
want := tc.counts[len(tc.counts)-1]
|
||
if tc.fail {
|
||
want = 0
|
||
}
|
||
if state.ActiveTaskID != "" || len(state.Calls) != want || state.CallIndex != 0 {
|
||
t.Fatal("output admission left invalid state")
|
||
}
|
||
for _, message := range state.Canonical.Messages {
|
||
if strings.Contains(stringField(message, "content"), "invalid_output") {
|
||
t.Fatal("retry correction leaked into canonical history")
|
||
}
|
||
}
|
||
for _, event := range state.Events {
|
||
if event.Type == "tool_completed" || event.Type == "canvas_updated" || event.Type == "approval_requested" {
|
||
t.Fatal("model response admission executed a tool")
|
||
}
|
||
}
|
||
var tasks int64
|
||
if err := db.Model(&model.Task{}).Where("agent_run_id = ? AND operation = ?", runID, cloudAgentStepOperation).Count(&tasks).Error; err != nil || tasks != int64(len(tc.counts)) {
|
||
t.Fatalf("unexpected retry count: %d (%v)", tasks, err)
|
||
}
|
||
})
|
||
}
|
||
}
|