Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_pi_awaiting_approval_test.go
jiabx 4b1ce20d2d fix(agent): 审批暂停 - 暂停后不再接受新的模型步骤 (#675)
运行时在工具进入审批时调用 session.abort(),但中止是异步生效的,SDK 可能抢在它生效前再开一轮并请求 /model。服务端照常入队:多计一次费,返回的新工具调用顶掉待审批的调用,保存时以「approval does not match current call」的检查点校验失败,整轮中断,用户还没来得及审批。

三处防护:运行时在暂停后不再发模型请求;服务端在等审批期间拒绝模型步骤(不入队、不改状态);已因审批暂停的会话收尾报错不再判运行失败,由审批结论接管。
2026-10-05 13:56:07 +08:00

106 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// approvalPauseRoot 建立 Pi 模式的运行(root 任务不占活动任务位,模型步走 /model 桥)。
func approvalPauseRoot(t *testing.T) (*Service, *gorm.DB, *CloudAgentRun) {
t.Helper()
s, db, _, _ := creationTestService(t)
s.legacyCloudAgentRootTask = false
if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[]}`}).Error; err != nil {
t.Fatal(err)
}
root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "")
if err != nil {
t.Fatal(err)
}
return s, db, root
}
// approvalPauseStage 把运行摆进等待审批状态:挂起的工具调用与审批一一对应
// (检查点校验要求 Approval.Call 与 Calls[CallIndex] 一致)。
func approvalPauseStage(t *testing.T, s *Service, db *gorm.DB, run *model.CloudAgentExecution, state *cloudAgentRuntime) {
t.Helper()
call := cloudAgentCall{ID: "call-1"}
call.Function.Name = "generate_media"
call.Function.Arguments = "{}"
run.Status = "waiting_approval"
state.Calls = []cloudAgentCall{call}
state.CallIndex = 0
state.Approval = &cloudAgentApproval{ID: "ap-1", Call: call}
if state.Decisions == nil {
state.Decisions = map[string]string{}
}
approvalPauseSaveState(t, s, db, run, state)
}
// approvalPauseSaveState 用生产同款 CAS 路径落库改过的运行态(直接 db.Save 会把
// Transcript 关联行追加而不是替换,污染下一次 decode 的 canonical)。
func approvalPauseSaveState(t *testing.T, s *Service, db *gorm.DB, run *model.CloudAgentExecution, state *cloudAgentRuntime) {
t.Helper()
if err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
current.Status = run.Status
return cloudAgentSave(current, state)
}); err != nil {
t.Fatal(err)
}
}
// approvalPauseModelPayload 组装运行时风格的 /model 请求(conversation)。
func approvalPauseModelPayload(t *testing.T, messages []map[string]any) map[string]json.RawMessage {
t.Helper()
encoded, err := json.Marshal(messages)
if err != nil {
t.Fatal(err)
}
return map[string]json.RawMessage{
"modelId": json.RawMessage(`"text-test"`),
"purpose": json.RawMessage(`"conversation"`),
"messages": encoded,
"thinkingLevel": json.RawMessage(`"off"`),
}
}
// 运行时在审批暂停后中止会话是异步的,可能抢在中止生效前再请求一步模型。
// 这一步不能入队:否则多计一次费,返回的新工具调用还会顶掉待审批的调用,
// 让整轮以「approval does not match current call」的检查点校验失败收场。
func TestCloudAgentPiModelStepRefusedWhileAwaitingApproval(t *testing.T) {
s, db, root := approvalPauseRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
approvalPauseStage(t, s, db, run, &state)
before, stateBefore := agentInterjectionState(t, s, root.ID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
messages := []map[string]any{{"role": "user", "content": []any{map[string]any{"type": "text", "text": "继续"}}}}
_, err := s.cloudAgentPiModel(ctx, "user", root.ID, approvalPauseModelPayload(t, messages))
if !errors.Is(err, errCloudAgentAwaitingApproval) {
t.Fatalf("model step during approval wait: err=%v, want errCloudAgentAwaitingApproval", err)
}
after, stateAfter := agentInterjectionState(t, s, root.ID)
if after.Status != "waiting_approval" || stateAfter.Approval == nil || stateAfter.Approval.ID != stateBefore.Approval.ID {
t.Fatalf("approval was disturbed: status=%s approval=%#v", after.Status, stateAfter.Approval)
}
if stateAfter.Step != stateBefore.Step || stateAfter.ActiveTaskID != "" || after.Revision != before.Revision {
t.Fatalf("a model step was scheduled: step %d→%d activeTask=%q revision %d→%d", stateBefore.Step, stateAfter.Step, stateAfter.ActiveTaskID, before.Revision, after.Revision)
}
var steps int64
if err := db.Model(&model.Task{}).Where("agent_run_id = ? AND operation = ?", root.ID, cloudAgentStepOperation).Count(&steps).Error; err != nil {
t.Fatal(err)
}
if steps != 0 {
t.Fatalf("%d billable model step task(s) were created while awaiting approval", steps)
}
}