fix(agent): 插话 - 独立运行时路径的送达与退回 (#673)

复现:Agent 运行中发送插话,独立 Agent 运行时(Pi)下既不送达模型也不退回用户,插话一直停在待送队列里。

原因与修法:插话送达点原本只有旧 Go 执行循环(advanceCloudAgent 开步前出队);Pi 运行时接手执行后调度循环不再推进。现把 Pi 路径的送达搬到 /model 模型步:出队、规范历史追加与 delivered 事件同在入队的一次 CAS 写入里,响应携带 steeringMessages 交给运行时 session.steer。完成、失败、取消、审批拒绝与步数耗尽统一退回;最终回答之后的插话在预算富余时续跑一步。完成闸一次带走多条插话时,合并后的续跑提示词也记入指纹,避免被恢复闸当成遗留提示词清空。
This commit is contained in:
jiabx
2026-10-05 13:39:57 +08:00
committed by GitHub
parent dd563d3373
commit 5bf838ebbe
6 changed files with 730 additions and 6 deletions

View File

@@ -0,0 +1,497 @@
package app
// Pi 运行时路径的插话送达与退回测试。见 cloud_agent_interjection_pi.go。
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// interjectionPiRoot 建立 Pi 模式的运行(root 任务不占活动任务位,插话送达发生在 /model 桥)。
func interjectionPiRoot(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
}
// interjectionApprovalRun 把运行摆进等待审批状态:挂起的工具调用与审批一一对应
// (检查点校验要求 Approval.Call 与 Calls[CallIndex] 一致)。
func interjectionApprovalRun(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{}
}
interjectionSaveState(t, s, db, run, state)
}
// interjectionModelPayload 组装运行时风格的 /model 请求(conversation)。
func interjectionModelPayload(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"`),
}
}
// interjectionModelStep 桥接一次模型步:等待步任务入队(跳过建轮时的活动任务)、
// 把任务改成给定结果、等待桥返回。
func interjectionModelStep(t *testing.T, s *Service, db *gorm.DB, userID, runID string, messages []map[string]any, resultJSON string) map[string]any {
t.Helper()
stored, err := s.repo.CloudAgent(userID, runID)
if err != nil {
t.Fatal(err)
}
initialState, err := cloudAgentDecode(stored)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
type outcome struct {
result any
err error
}
done := make(chan outcome, 1)
go func() {
result, stepErr := s.cloudAgentPiModel(ctx, userID, runID, interjectionModelPayload(t, messages))
done <- outcome{result: result, err: stepErr}
}()
var taskID string
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
stored, readErr := s.repo.CloudAgent(userID, runID)
if readErr != nil {
t.Fatal(readErr)
}
state, decodeErr := cloudAgentDecode(stored)
if decodeErr != nil {
t.Fatal(decodeErr)
}
if state.ActiveTaskID != "" && state.ActiveTaskID != initialState.ActiveTaskID {
taskID = state.ActiveTaskID
break
}
select {
case got := <-done:
t.Fatalf("model bridge returned before scheduling: result=%v err=%v", got.result, got.err)
case <-time.After(25 * time.Millisecond):
}
}
if taskID == "" {
t.Fatal("model step was never scheduled")
}
if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]any{
"status": model.TaskStatusSucceeded, "result_json": resultJSON,
}).Error; err != nil {
t.Fatal(err)
}
select {
case got := <-done:
if got.err != nil {
t.Fatalf("model bridge failed: %v", got.err)
}
result, _ := got.result.(map[string]any)
if result == nil {
t.Fatalf("unexpected bridge result: %#v", got.result)
}
return result
case <-ctx.Done():
t.Fatal("model bridge did not return")
}
return nil
}
// interjectionSteerTexts 取响应里的 steer 正文(Go 内直接组装,是 []string)。
func interjectionSteerTexts(result map[string]any) []string {
texts, _ := result["steeringMessages"].([]string)
return texts
}
// interjectionSaveState 用生产同款 CAS 路径落库改过的运行态(直接 db.Save 会把
// Transcript 关联行追加而不是替换,污染下一次 decode 的 canonical)。
func interjectionSaveState(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)
}
}
// 运行中插话在下一次 /model 出队:进入本次请求的 canonical(带来源标记)、
// 发 delivered 事件、响应携带 steeringMessages 交给运行时 session.steer。
func TestCloudAgentPiInterjectionReachesModelRequest(t *testing.T) {
s, db, root := interjectionPiRoot(t)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-1", "改成 16:9"); err != nil {
t.Fatal(err)
}
messages := []map[string]any{{"role": "user", "content": []any{map[string]any{"type": "text", "text": "继续"}}}}
result := interjectionModelStep(t, s, db, "user", root.ID, messages, `{"text":"好的","toolCalls":[]}`)
steered := interjectionSteerTexts(result)
if len(steered) != 1 || steered[0] != "【用户插话】改成 16:9" {
t.Fatalf("响应没有携带 steer 正文: %#v", result["steeringMessages"])
}
_, state := agentInterjectionState(t, s, root.ID)
if len(state.PendingInterjections) != 0 {
t.Fatalf("插话已送达却没有出队: %+v", state.PendingInterjections)
}
if !agentHasEvent(state, "user_interjection_delivered") {
t.Fatal("送达时没有发 user_interjection_delivered 事件")
}
if len(state.InterjectionIDs) == 0 || state.InterjectionIDs[0] != "msg-1" {
t.Fatalf("送达没有记住插话 id: %#v", state.InterjectionIDs)
}
// 最关键的一条:插话必须出现在真正发给模型的输入里,并带来源标记。
var task model.Task
last := state.TaskIDs[len(state.TaskIDs)-1]
if err := db.First(&task, "id = ?", last).Error; err != nil {
t.Fatal(err)
}
var input struct {
AgentRequests struct {
Canonical struct {
Messages []map[string]any `json:"messages"`
} `json:"canonical"`
} `json:"agentRequests"`
}
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
t.Fatal(err)
}
delivered := false
for _, message := range input.AgentRequests.Canonical.Messages {
content, _ := message["content"].(string)
if message["role"] == "user" && strings.Contains(content, "改成 16:9") {
delivered = true
if message[cloudAgentContextSourceKey] != "user_interjection" {
t.Fatalf("送达进 canonical 的插话缺少来源标记: %#v", message)
}
}
}
if !delivered {
t.Fatalf("插话没有进入模型请求的输入: %s", task.InputJSON)
}
}
// 等审批期间入队的插话,在恢复后的第一次 /model 送达(模型这一步就能看到,
// 不用等 steer 在下一轮生效)。
func TestCloudAgentPiInterjectionQueuedDuringApprovalDeliversOnResume(t *testing.T) {
s, db, root := interjectionPiRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
interjectionApprovalRun(t, s, db, run, &state)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-approval", "批准后按新要求来"); err != nil {
t.Fatal(err)
}
// 审批批准、被暂停的工具执行完毕后的状态:审批已清、回到 running。
// (真实链路里 advanceCloudAgentTool 执行工具时清理审批,之后才恢复会话。)
run, state = agentInterjectionState(t, s, root.ID)
run.Status = "running"
state.Approval = nil
state.Calls = nil
state.CallIndex = 0
interjectionSaveState(t, s, db, run, &state)
messages := []map[string]any{{"role": "user", "content": []any{map[string]any{"type": "text", "text": "继续"}}}}
interjectionModelStep(t, s, db, "user", root.ID, messages, `{"text":"好的","toolCalls":[]}`)
_, state = agentInterjectionState(t, s, root.ID)
if len(state.PendingInterjections) != 0 {
t.Fatal("恢复后的第一步没有送达等审批时的插话")
}
if !agentHasEvent(state, "user_interjection_delivered") {
t.Fatal("恢复送达没有发 user_interjection_delivered 事件")
}
}
// 已送达插话经过运行时投影回写后来源标记被洗掉:下一步模型步按指纹补回,
// 且插话在两份历史之间不重复(长度相等时采用运行时投影,插话只出现一次)。
func TestCloudAgentPiInterjectionMarkerSurvivesRuntimeProjection(t *testing.T) {
s, db, root := interjectionPiRoot(t)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-1", "改成 16:9"); err != nil {
t.Fatal(err)
}
first := []map[string]any{{"role": "user", "content": []any{map[string]any{"type": "text", "text": "继续"}}}}
interjectionModelStep(t, s, db, "user", root.ID, first, `{"text":"好的","toolCalls":[]}`)
_, state := agentInterjectionState(t, s, root.ID)
if len(state.Canonical.Messages) == 0 || stringField(state.Canonical.Messages[len(state.Canonical.Messages)-1], "role") != "assistant" {
t.Fatalf("这一步的助手回复没有落检查点: %+v", state.Canonical.Messages)
}
// 下一拍的运行时会话:插话(steer 落盘)排在助手回复之后,且不带标记。
second := []map[string]any{
{"role": "user", "content": []any{map[string]any{"type": "text", "text": "继续"}}},
{"role": "assistant", "content": []any{map[string]any{"type": "text", "text": "好的"}}},
{"role": "user", "content": []any{map[string]any{"type": "text", "text": "【用户插话】改成 16:9"}}},
}
result := interjectionModelStep(t, s, db, "user", root.ID, second, `{"text":"收到","toolCalls":[]}`)
if len(interjectionSteerTexts(result)) != 0 {
t.Fatalf("队列已空却再次返回 steer 正文: %#v", result["steeringMessages"])
}
_, state = agentInterjectionState(t, s, root.ID)
var task model.Task
last := state.TaskIDs[len(state.TaskIDs)-1]
if err := db.First(&task, "id = ?", last).Error; err != nil {
t.Fatal(err)
}
if got := strings.Count(task.InputJSON, "改成 16:9"); got != 1 {
t.Fatalf("插话在两份历史之间重复或丢失: count=%d input=%s", got, task.InputJSON)
}
marked := false
for _, message := range state.Canonical.Messages {
content, _ := message["content"].(string)
if strings.Contains(content, "改成 16:9") && message[cloudAgentContextSourceKey] == "user_interjection" {
marked = true
}
}
if !marked {
t.Fatalf("运行时投影回写后插话标记没有被补回: %+v", state.Canonical.Messages)
}
}
// 运行自然结束但队列里还有插话:预算够就作为续跑提示词再走一步(不标记完成)。
func TestCloudAgentPiInterjectionResumesAfterFinalAnswer(t *testing.T) {
s, db, root := interjectionPiRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
run.Status = "running"
state.Request.Budget.MaxSteps = 3
state.Step = 1
state.PiAssistantResponses = 1
state.Canonical.Messages = []map[string]any{
{"role": "user", "content": "分析剧情"},
{"role": "assistant", "content": "好的"},
}
interjectionSaveState(t, s, db, run, &state)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-late", "补一句:加字幕"); err != nil {
t.Fatal(err)
}
if err := s.completeCloudAgentPiRun("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) != 0 {
t.Fatal("完成闸没有出队插话")
}
if state.PiResumePrompt != "【用户插话】补一句:加字幕" {
t.Fatalf("插话没有被写进续跑提示词: %q", state.PiResumePrompt)
}
if !agentHasEvent(state, "user_interjection_delivered") {
t.Fatal("完成闸送达没有发 user_interjection_delivered 事件")
}
// 续跑提示词必须被识别为插话投递:恢复会话遇到定格快照时照常跑出去。
if !cloudAgentPiResumePromptDeliversInterjection(&state) {
t.Fatal("恢复闸没有识别插话续跑提示词")
}
}
// 步数已尽时插话退回、运行照常完成。
func TestCloudAgentPiInterjectionDroppedWhenStepBudgetExhausted(t *testing.T) {
s, db, root := interjectionPiRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
run.Status = "running"
state.Request.Budget.MaxSteps = 1
state.Step = 1
state.PiAssistantResponses = 1
state.Canonical.Messages = []map[string]any{
{"role": "user", "content": "分析剧情"},
{"role": "assistant", "content": "好的"},
}
interjectionSaveState(t, s, db, run, &state)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-late", "补一句:加字幕"); err != nil {
t.Fatal(err)
}
if err := s.completeCloudAgentPiRun("user", root.ID); err != nil {
t.Fatal(err)
}
run, state = agentInterjectionState(t, s, root.ID)
if run.Status != "completed" {
t.Fatalf("步数已尽时应完成收尾,实际 status=%s", run.Status)
}
if len(state.PendingInterjections) != 0 {
t.Fatal("步数已尽时插话还挂在队列里")
}
if !agentHasEvent(state, "user_interjection_dropped") {
t.Fatal("步数已尽时没有发 user_interjection_dropped 事件")
}
}
// 插话 steer 续步撞上步数上限:模型已带着插话给出最终回答,收尾为 completed
// 并退回剩余插话,而不是把整轮判失败;停在工具结果上的普通步数耗尽不受影响。
func TestCloudAgentPiSteeredContinuationCompletesAtFinalAnswer(t *testing.T) {
s, db, root := interjectionPiRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
run.Status = "running"
state.Request.Budget.MaxSteps = 1
state.Step = 1
state.PiAssistantResponses = 1
state.Canonical.Messages = []map[string]any{
{"role": "user", "content": "分析剧情"},
{"role": "assistant", "content": "好的"},
}
interjectionSaveState(t, s, db, run, &state)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-late", "补一句"); err != nil {
t.Fatal(err)
}
s.settleCloudAgentPiFinalAnswer("user", root.ID)
run, state = agentInterjectionState(t, s, root.ID)
if run.Status != "completed" {
t.Fatalf("最终回答边界上应完成收尾,实际 status=%s", run.Status)
}
if len(state.PendingInterjections) != 0 || !agentHasEvent(state, "user_interjection_dropped") {
t.Fatal("收尾时没有退回插话")
}
// 对照:canonical 停在工具结果上(工具批次之后想再开一步)不是最终回答边界,
// 保持原有失败路径。
run, state = agentInterjectionState(t, s, root.ID)
run.Status = "running"
state.Canonical.Messages = []map[string]any{
{"role": "user", "content": "分析剧情"},
{"role": "assistant", "content": "画一张", "tool_calls": []any{map[string]any{"id": "call-1"}}},
{"role": "tool", "tool_call_id": "call-1", "content": "{}"},
}
interjectionSaveState(t, s, db, run, &state)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-late-2", "补一句"); err != nil {
t.Fatal(err)
}
s.settleCloudAgentPiFinalAnswer("user", root.ID)
run, _ = agentInterjectionState(t, s, root.ID)
if run.Status != "running" {
t.Fatalf("工具批次后的步数耗尽不属于最终回答边界,实际 status=%s", run.Status)
}
}
// 运行失败、被取消、审批被拒时,队列里的插话全部退回。
func TestCloudAgentPiInterjectionsDroppedOnTerminalPaths(t *testing.T) {
t.Run("failed", func(t *testing.T) {
s, _, root := interjectionPiRoot(t)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-f", "还在吗"); err != nil {
t.Fatal(err)
}
s.failPiRunner(root.ID, "user", errors.New("模型调用失败"))
run, state := agentInterjectionState(t, s, root.ID)
if run.Status != "failed" || len(state.PendingInterjections) != 0 || !agentHasEvent(state, "user_interjection_dropped") {
t.Fatalf("失败收尾没有退回插话: status=%s pending=%+v", run.Status, state.PendingInterjections)
}
})
t.Run("cancelled", func(t *testing.T) {
s, _, root := interjectionPiRoot(t)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-c", "还在吗"); err != nil {
t.Fatal(err)
}
if err := s.CancelCloudAgent(context.Background(), "user", root.ID); err != nil {
t.Fatal(err)
}
run, state := agentInterjectionState(t, s, root.ID)
if run.Status != "cancelled" || len(state.PendingInterjections) != 0 || !agentHasEvent(state, "user_interjection_dropped") {
t.Fatalf("取消没有退回插话: status=%s pending=%+v", run.Status, state.PendingInterjections)
}
})
t.Run("rejected", func(t *testing.T) {
s, db, root := interjectionPiRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
interjectionApprovalRun(t, s, db, run, &state)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-r", "还在吗"); err != nil {
t.Fatal(err)
}
if err := s.DecideCloudAgentApproval("user", root.ID, "ap-1", "reject", "不要生成"); err != nil {
t.Fatal(err)
}
run, state = agentInterjectionState(t, s, root.ID)
if run.Status != "rejected" || len(state.PendingInterjections) != 0 || !agentHasEvent(state, "user_interjection_dropped") {
t.Fatalf("拒绝审批没有退回插话: status=%s pending=%+v", run.Status, state.PendingInterjections)
}
})
}
// 同一 messageId 重复提交不会送达两次:已送达的 id 被记住,重发直接短路。
func TestCloudAgentPiInterjectionRedeliveryIsIdempotent(t *testing.T) {
s, db, root := interjectionPiRoot(t)
if _, err := s.InterjectCloudAgent("user", root.ID, "msg-1", "改成 16:9"); err != nil {
t.Fatal(err)
}
messages := []map[string]any{{"role": "user", "content": []any{map[string]any{"type": "text", "text": "继续"}}}}
interjectionModelStep(t, s, db, "user", root.ID, messages, `{"text":"好的","toolCalls":[]}`)
count, err := s.InterjectCloudAgent("user", root.ID, "msg-1", "改成 16:9")
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("已送达的插话被重新入队: pending=%d", count)
}
_, state := agentInterjectionState(t, s, root.ID)
if len(state.PendingInterjections) != 0 {
t.Fatal("重复提交把插话又放回了队列")
}
delivered := 0
for _, event := range state.Events {
if event.Type == "user_interjection_delivered" {
delivered++
}
}
if delivered != 1 {
t.Fatalf("delivered 事件出现 %d 次,应当只有 1 次", delivered)
}
}
// 完成闸一次带走多条插话时,合并后的续跑提示词也必须被恢复闸识别;
// 否则它会被当成遗留提示词清空,已标记送达的插话到不了模型。
func TestCloudAgentPiInterjectionResumePromptRecognisedForSeveralInterjections(t *testing.T) {
s, db, root := interjectionPiRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
run.Status = "running"
state.Request.Budget.MaxSteps = 3
state.Step = 1
state.PiAssistantResponses = 1
state.Canonical.Messages = []map[string]any{
{"role": "user", "content": "分析剧情"},
{"role": "assistant", "content": "好的"},
}
interjectionSaveState(t, s, db, run, &state)
for id, text := range map[string]string{"msg-late-1": "加字幕", "msg-late-2": "再配个背景音乐"} {
if _, err := s.InterjectCloudAgent("user", root.ID, id, text); err != nil {
t.Fatal(err)
}
}
if err := s.completeCloudAgentPiRun("user", root.ID); err != nil {
t.Fatal(err)
}
run, state = agentInterjectionState(t, s, root.ID)
if run.Status != "running" || len(state.PendingInterjections) != 0 {
t.Fatalf("status=%s pending=%d", run.Status, len(state.PendingInterjections))
}
if !strings.Contains(state.PiResumePrompt, "加字幕") || !strings.Contains(state.PiResumePrompt, "再配个背景音乐") {
t.Fatalf("续跑提示词缺少插话: %q", state.PiResumePrompt)
}
if !cloudAgentPiResumePromptDeliversInterjection(&state) {
t.Fatal("恢复闸没有识别多条插话合并后的续跑提示词")
}
}