将计划清单、运行中插话和个人记忆接入云端 Agent;全站模型选择器统一为左右二级菜单。画布摘要按预算截断不再整轮拒绝,首页视频未知宽高可入库。README 补充贡献者 morrow。 Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
6.6 KiB
Go
180 lines
6.6 KiB
Go
package app
|
||
|
||
// 自研(2026-09-17):运行中插话。见 cloud_agent_interjection.go。
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
// agentInterjectionState 取回并解码某个运行的运行态。
|
||
func agentInterjectionState(t *testing.T, s *Service, id string) (*model.CloudAgentExecution, cloudAgentRuntime) {
|
||
t.Helper()
|
||
run, err := s.repo.CloudAgent("user", id)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
state, err := cloudAgentDecode(run)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return run, state
|
||
}
|
||
|
||
// 插话必须在**下一次模型调用**里真的被送到 —— 这是整个功能的验收点:
|
||
// 只入队不算数,要能在发给模型的那份 canonical 里找到原文。
|
||
func TestCloudAgentInterjectionReachesNextModelRequest(t *testing.T) {
|
||
s, db, root := reliableAgentRoot(t)
|
||
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-1", "先别生成视频,改成 16:9"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
run, state := agentInterjectionState(t, s, root.ID)
|
||
if len(state.PendingInterjections) != 1 {
|
||
t.Fatalf("插话没有入队: %+v", state.PendingInterjections)
|
||
}
|
||
if !agentHasEvent(state, "user_interjection") {
|
||
t.Fatal("入队时没有发 user_interjection 事件(前端时间线看不到自己的话)")
|
||
}
|
||
// 让首个模型任务以「没有工具调用」结束 —— 这正是最容易丢插话的形态:
|
||
// 模型自认为答完了,而同一条路径的默认行为是**收尾本轮**。
|
||
if err := db.Model(&model.Task{}).Where("id = ?", root.ID).Updates(map[string]any{
|
||
"status": model.TaskStatusSucceeded, "result_json": `{"text":"好的"}`,
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
run, state = agentInterjectionState(t, s, root.ID)
|
||
if run.Status != "running" {
|
||
t.Fatalf("有未送达插话时不该收尾,实际 status=%s", run.Status)
|
||
}
|
||
if len(state.PendingInterjections) != 1 {
|
||
t.Fatal("插话在收尾闸处被吞掉了")
|
||
}
|
||
// 下一拍:游标已空 → 应当先把插话注入,再发新的模型任务。
|
||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
run, state = agentInterjectionState(t, s, root.ID)
|
||
if len(state.PendingInterjections) != 0 {
|
||
t.Fatal("插话已送达却没有出队")
|
||
}
|
||
if !agentHasEvent(state, "user_interjection_delivered") {
|
||
t.Fatal("送达时没有发 user_interjection_delivered 事件")
|
||
}
|
||
var delivered bool
|
||
for _, message := range state.Canonical.Messages {
|
||
content, _ := message["content"].(string)
|
||
if strings.Contains(content, "先别生成视频,改成 16:9") && message["role"] == "user" {
|
||
delivered = true
|
||
}
|
||
}
|
||
if !delivered {
|
||
t.Fatalf("插话没有进入本轮的对话:%+v", state.Canonical.Messages)
|
||
}
|
||
// 最关键的一条:它必须出现在**真正发给模型**的那份输入里。
|
||
last := state.TaskIDs[len(state.TaskIDs)-1]
|
||
var task model.Task
|
||
if err := db.First(&task, "id = ?", last).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(task.InputJSON, "先别生成视频,改成 16:9") {
|
||
t.Fatalf("插话没有进入模型请求的输入: %s", task.InputJSON)
|
||
}
|
||
}
|
||
|
||
// 步数/预算已经把下一次模型调用用光时,插话送不出去 —— 必须如实退回,
|
||
// 不能静默吞掉,也不能替用户自动开新一轮(那是用户没点过的扣费)。
|
||
func TestCloudAgentInterjectionDroppedWhenNoStepLeft(t *testing.T) {
|
||
s, db, root := reliableAgentRoot(t)
|
||
run, state := agentInterjectionState(t, s, root.ID)
|
||
state.Request.Budget.MaxSteps = 1
|
||
if err := cloudAgentSave(run, &state); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Save(run).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-budget", "改一下方向"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Model(&model.Task{}).Where("id = ?", root.ID).Updates(map[string]any{
|
||
"status": model.TaskStatusSucceeded, "result_json": `{"text":"好的"}`,
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
run, state = agentInterjectionState(t, s, root.ID)
|
||
if len(state.PendingInterjections) != 0 {
|
||
t.Fatal("开不起下一步了,插话却还挂在队列里")
|
||
}
|
||
if !agentHasEvent(state, "user_interjection_dropped") {
|
||
t.Fatal("插话被静默吞掉了:没有 user_interjection_dropped 事件")
|
||
}
|
||
// 退回不等于放行:不能因为插话又把这一轮续起来(那会突破步数上限)。
|
||
if run.Status == "running" {
|
||
t.Fatal("步数已满却因为插话继续开步")
|
||
}
|
||
}
|
||
|
||
// 同一 messageId 重发(网络重试 / 用户连点)只能入队一次。
|
||
func TestCloudAgentInterjectionIsIdempotentByMessageID(t *testing.T) {
|
||
s, _, root := reliableAgentRoot(t)
|
||
first, err := s.InterjectCloudAgent("user", root.ID, "msg-same", "同一句话")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := s.InterjectCloudAgent("user", root.ID, "msg-same", "同一句话")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if first != 1 || second != 1 {
|
||
t.Fatalf("重试把同一条插话入队了两次: first=%d second=%d", first, second)
|
||
}
|
||
_, state := agentInterjectionState(t, s, root.ID)
|
||
if len(state.PendingInterjections) != 1 {
|
||
t.Fatalf("队列里应当只有一条: %+v", state.PendingInterjections)
|
||
}
|
||
}
|
||
|
||
// 运行已经结束就不再收插话 —— 前端据此回落成「新一轮」。
|
||
func TestCloudAgentInterjectionRejectedOnFinishedRun(t *testing.T) {
|
||
s, db, root := reliableAgentRoot(t)
|
||
if err := db.Model(&model.CloudAgentExecution{}).Where("id = ?", root.ID).Update("status", "completed").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-done", "太晚了"); err == nil {
|
||
t.Fatal("已结束的运行仍然收下了插话")
|
||
}
|
||
}
|
||
|
||
// 输入校验:空内容、超长、坏 id 一律拒绝(服务端不能只靠 handler 早筛)。
|
||
func TestCloudAgentInterjectionValidation(t *testing.T) {
|
||
s, _, root := reliableAgentRoot(t)
|
||
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-empty", " "); err == nil {
|
||
t.Fatal("空插话被接受了")
|
||
}
|
||
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-long", strings.Repeat("字", cloudAgentInterjectionMaxRunes+1)); err == nil {
|
||
t.Fatal("超长插话被接受了")
|
||
}
|
||
if _, err := s.InterjectCloudAgent("user", root.ID, "", "没有 id"); err == nil {
|
||
t.Fatal("缺 messageId 被接受了")
|
||
}
|
||
if _, err := s.InterjectCloudAgent("someone-else", root.ID, "msg-other", "别人的运行"); err == nil {
|
||
t.Fatal("别人的运行被插话了")
|
||
}
|
||
}
|
||
|
||
func agentHasEvent(state cloudAgentRuntime, kind string) bool {
|
||
for _, event := range state.Events {
|
||
if event.Type == kind {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|