Files
Agent-System-Backend/internal/runtime/workflow_exec_test.go
T
weichenandCursor fb5f071761 Run workflow scripts through a capped callback host
Why: Plugin scripts and model-written scripts need the same step runner, without giving the script a way to call interpreters or start another workflow.
Evidence: go test ./... passed. Callbacks, denied tools, the 50-call cap, a 1MB line, and cancel-kills-the-process are covered. A model script does not run until the switch is on and the user confirms.
Impact: runtime.workflowScripts stays off by default. Plugin script bodies are copied into the launch. Squad entry still has no run_workflow.
Refs: feature/agent-harness-p4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 16:18:59 +00:00

313 lines
11 KiB
Go

package runtime
import (
"context"
"fmt"
"strings"
"testing"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
type wfFake struct {
ctx context.Context
launch workflow.Launch
hitl []core.HITLResponse
hitlQ []string
plans []core.PlanSnapshot
tools []core.ToolCallRequest
prompts []string
writes []string
cards []core.ActivityCard
assistant []core.Part
arts map[string]core.Artifact
byID map[string]core.WorkflowDef
spawnText string
spawnIDs []string
toolNames []string
settings core.EffectiveSettings
sandbox string
scripts map[string]string
}
func (f *wfFake) Context() context.Context {
if f.ctx == nil {
return context.Background()
}
return f.ctx
}
func (f *wfFake) Run() core.Run { return core.Run{ID: "run_wf"} }
func (f *wfFake) Profile() core.AgentProfile { return core.AgentProfile{} }
func (f *wfFake) Settings() core.EffectiveSettings {
if f.settings == nil {
return core.EffectiveSettings{}
}
return f.settings
}
func (f *wfFake) ConversationSandbox() string { return f.sandbox }
func (f *wfFake) Plan() (core.PlanSnapshot, bool) {
if len(f.plans) == 0 {
return core.PlanSnapshot{}, false
}
p := f.plans[len(f.plans)-1]
p.Steps = append([]core.PlanStep(nil), p.Steps...)
return p, true
}
func (f *wfFake) WorkflowScript(id, path string) (string, bool) {
body, ok := f.scripts[workflow.ScriptKey(id, path)]
return body, ok
}
func (f *wfFake) Emit(core.StreamEventType, any) error { return nil }
func (f *wfFake) SetAssistantParts(parts []core.Part) error {
f.assistant = parts
return nil
}
func (f *wfFake) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
f.hitlQ = append(f.hitlQ, card.Question)
if len(f.hitl) == 0 {
return core.HITLResponse{}, context.Canceled
}
resp := f.hitl[0]
f.hitl = f.hitl[1:]
return resp, nil
}
func (f *wfFake) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
return f.SpawnAgent("agent.sub.general", name, task, hint)
}
func (f *wfFake) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
f.prompts = append(f.prompts, agentID+"|"+task)
f.spawnIDs = append(f.spawnIDs, agentID)
text := f.spawnText
if text == "" {
text = "正文 art_0123456789ab"
}
return core.AgentResultPayload{Text: text, Status: core.RunCompleted}, nil
}
func (f *wfFake) UpdatePlan(p core.PlanSnapshot) error {
p.Steps = append([]core.PlanStep(nil), p.Steps...)
f.plans = append(f.plans, p)
return nil
}
func (f *wfFake) UpdateTodos(core.TodoList) error { return nil }
func (f *wfFake) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
f.writes = append(f.writes, name+"="+string(content))
art := core.Artifact{ID: "art_aaaaaaaaaaaa", Name: name, Kind: kind}
if f.arts == nil {
f.arts = map[string]core.Artifact{}
}
f.arts[string(art.ID)] = art
return art, nil
}
func (f *wfFake) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (f *wfFake) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
f.tools = append(f.tools, call)
if call.Name == "make_file" {
art := core.Artifact{ID: "art_bbbbbbbbbbbb", Name: "made.md"}
if f.arts == nil {
f.arts = map[string]core.Artifact{}
}
f.arts[string(art.ID)] = art
return core.ToolCallResult{Status: "ok", Output: "wrote", Artifact: &art}
}
return core.ToolCallResult{Status: "ok", Output: "12:00"}
}
func (f *wfFake) History() []core.Message { return nil }
func (f *wfFake) Attachments() []core.ParsedDocument { return nil }
func (f *wfFake) Tools() []core.ToolDesc {
var out []core.ToolDesc
for _, name := range f.toolNames {
out = append(out, core.ToolDesc{Name: name})
}
return out
}
func (f *wfFake) Subtask() string { return "" }
func (f *wfFake) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
card.ID = "card_1"
f.cards = append(f.cards, card)
return card, nil
}
func (f *wfFake) SetCardStatus(string, core.CardStatus, string, string) error { return nil }
func (f *wfFake) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
return card, nil
}
func (f *wfFake) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
return card, nil
}
func (f *wfFake) AppendStreamCard(string) error { return nil }
func (f *wfFake) CommitStreamCard() (core.ActivityCard, error) { return core.ActivityCard{}, nil }
func (f *wfFake) Card(string) *core.ActivityCard { return nil }
func (f *wfFake) Artifact(id string) (core.Artifact, []byte, bool) {
if f.arts == nil {
f.arts = map[string]core.Artifact{}
}
if id == "art_0123456789ab" {
return core.Artifact{ID: core.ID(id), Name: "note.md"}, []byte("x"), true
}
art, ok := f.arts[id]
return art, nil, ok
}
func (f *wfFake) SetContextObservation(core.ContextObservation) {}
func (f *wfFake) Workflow() (workflow.Launch, bool) { return f.launch, true }
func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) {
def, ok := f.byID[id]
return def, ok
}
func TestWorkflowRunsFiveKindsAndAsksMissingSlot(t *testing.T) {
f := &wfFake{hitl: []core.HITLResponse{{FreeText: "核能"}, {ChoiceID: "short"}}}
f.launch = workflow.Launch{
Missing: []workflow.SlotQuestion{{Key: "topic", Question: "主题是什么?"}},
Def: core.WorkflowDef{ID: "demo.brief", Title: "简报", Steps: []core.WorkflowStep{
{ID: "len", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{Question: "篇幅?", Kind: core.HITLKindChoice, Choices: []core.HITLChoice{{ID: "short", Label: "短"}}}},
{ID: "draft", Title: "起草", Kind: "prompt", Agent: "research", Prompt: "写 {{slots.topic}}"},
{ID: "now", Title: "时间", Kind: "tool", Tool: "time_now", Args: []byte(`{"q":"{{slots.topic}}"}`)},
{ID: "save", Title: "存稿", Kind: "artifact", Artifact: "brief.md", Prompt: "{{steps.draft.output}}"},
{ID: "note", Title: "通知", Kind: "card", Card: &core.ActivityCardTemplate{Kind: core.CardKindResult, Title: "好了"}},
}},
}
if err := (WorkflowRunner{}).Execute(f); err != nil {
t.Fatal(err)
}
if f.hitlQ[0] != "主题是什么?" || f.hitlQ[1] != "篇幅?" {
t.Fatalf("questions %v", f.hitlQ)
}
if len(f.prompts) != 1 || !strings.Contains(f.prompts[0], "agent.sub.research|写 核能") {
t.Fatalf("prompt %v", f.prompts)
}
if len(f.tools) != 1 || string(f.tools[0].Arguments) == "" || !strings.Contains(string(f.tools[0].Arguments), "核能") {
t.Fatalf("tool %#v", f.tools)
}
if len(f.writes) != 1 || f.writes[0] != "brief.md=正文 art_0123456789ab" {
t.Fatalf("writes %v", f.writes)
}
if len(f.cards) != 1 || f.cards[0].Title != "好了" {
t.Fatalf("cards %+v", f.cards)
}
var sawProgress, sawDone bool
for _, p := range f.plans {
for _, s := range p.Steps {
if s.ID == "draft" && s.Status == "in_progress" {
sawProgress = true
}
if s.ID == "note" && s.Status == "done" {
sawDone = true
}
}
}
if !sawProgress || !sawDone {
t.Fatal("plan did not move from in_progress to done")
}
text := f.assistant[0].Text
if !strings.Contains(text, "art_0123456789ab") || !strings.Contains(text, "art_aaaaaaaaaaaa") {
t.Fatalf("summary %s", text)
}
}
func TestCriticRetriesThenStops(t *testing.T) {
f := &wfFake{}
f.launch = workflow.Launch{Def: core.WorkflowDef{
ID: "a", Title: "验收", MaxRetries: 6,
Steps: []core.WorkflowStep{{
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
Critic: &core.StepCritic{Criteria: "必须点明主题", MaxRetries: 1},
}},
}}
runner := WorkflowRunner{JudgeStream: passedStream(
`{"passed":false,"reason":"没点明主题","nextAction":"补上主题"}`,
`{"passed":true,"reason":"可以","nextAction":""}`,
)}
if err := runner.Execute(f); err != nil {
t.Fatal(err)
}
if len(f.prompts) != 2 || !strings.Contains(f.prompts[1], "没点明主题") || !strings.Contains(f.prompts[1], "补上主题") {
t.Fatalf("prompts %v", f.prompts)
}
if !strings.Contains(f.assistant[0].Text, "已丢弃") || !strings.Contains(f.assistant[0].Text, "没点明主题") {
t.Fatalf("summary %s", f.assistant[0].Text)
}
f2 := &wfFake{}
f2.launch = f.launch
bad := WorkflowRunner{JudgeStream: passedStream("不是 json", "不是 json")}
err := bad.Execute(f2)
if err == nil || len(f2.prompts) != 2 {
t.Fatalf("err %v prompts %d", err, len(f2.prompts))
}
if !strings.Contains(f2.assistant[0].Text, "无法解析") {
t.Fatalf("summary %s", f2.assistant[0].Text)
}
f3 := &wfFake{}
f3.launch = workflow.Launch{Def: core.WorkflowDef{
ID: "a", Title: "验收", MaxRetries: 1,
Steps: []core.WorkflowStep{{
ID: "draft", Title: "起草", Kind: "prompt", Prompt: "写",
Critic: &core.StepCritic{Criteria: "要过", MaxRetries: 3},
}},
}}
fail := `{"passed":false,"reason":"不行","nextAction":"重写"}`
stop := WorkflowRunner{JudgeStream: passedStream(fail, fail, fail)}
if err := stop.Execute(f3); err == nil || len(f3.prompts) != 2 {
t.Fatalf("whole cap err %v attempts %d", err, len(f3.prompts))
}
}
func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
f := &wfFake{hitl: []core.HITLResponse{{ChoiceID: "yes"}}}
f.byID = map[string]core.WorkflowDef{
"demo.brief": {ID: "demo.brief", Title: "简报", Steps: []core.WorkflowStep{
{ID: "ask", Title: "确认", Kind: "hitl", Hitl: &core.HITLCardTemplate{Question: "可以吗?", Kind: core.HITLKindConfirm}},
}},
}
st := &directRun{rc: f}
out := st.runRegisteredWorkflow(`{"workflow":"demo.brief","inputs":{"topic":"核能"}}`)
if !strings.Contains(out, "完成") || f.hitlQ[0] != "可以吗?" {
t.Fatalf("out %s questions %v", out, f.hitlQ)
}
if got := st.runRegisteredWorkflow(`{"workflow":"missing"}`); !strings.Contains(got, "未注册") {
t.Fatalf("%s", got)
}
if got := st.runRegisteredWorkflow(`{"goal":"做一件事","workflow":"demo.brief"}`); !strings.Contains(got, "只能") {
t.Fatalf("%s", got)
}
}
func TestPlannedWorkflowNeedsConfirm(t *testing.T) {
plan := `{"id":"plan.brief","title":"简报","steps":[{"id":"ask","title":"确认","kind":"hitl","hitl":{"question":"可以吗?","kind":"confirm","allowFreeInput":false}}]}`
f := &wfFake{
spawnText: plan,
hitl: []core.HITLResponse{{ChoiceID: "yes"}, {ChoiceID: "ok"}},
}
st := &directRun{rc: f}
out := st.runRegisteredWorkflow(`{"goal":"写一份简报"}`)
if f.spawnIDs[0] != "agent.sub.workflow-plan" || !strings.Contains(f.hitlQ[0], "按这个计划执行") {
t.Fatalf("spawn %v questions %v", f.spawnIDs, f.hitlQ)
}
if !strings.Contains(out, "完成") || f.hitlQ[1] != "可以吗?" {
t.Fatalf("out %s questions %v", out, f.hitlQ)
}
f2 := &wfFake{spawnText: plan, hitl: []core.HITLResponse{{ChoiceID: "no"}}}
out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"goal":"写一份简报"}`)
if out != "未确认,未执行" || len(f2.hitlQ) != 1 {
t.Fatalf("out %s questions %v", out, f2.hitlQ)
}
var steps []string
for i := 0; i < 9; i++ {
steps = append(steps, fmt.Sprintf(`{"id":"s%d","title":"步","kind":"hitl"}`, i))
}
f3 := &wfFake{spawnText: `{"id":"too.big","title":"太多","steps":[` + strings.Join(steps, ",") + `]}`}
out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"goal":"做很多事"}`)
if !strings.Contains(out, "超出上限") || len(f3.hitlQ) != 0 {
t.Fatalf("out %s questions %v", out, f3.hitlQ)
}
f4 := &wfFake{spawnText: `{"id":"bad.script","title":"脚本","steps":[{"id":"run","title":"跑","kind":"script","script":"a.py"}]}`}
out = (&directRun{rc: f4}).runRegisteredWorkflow(`{"goal":"跑脚本"}`)
if !strings.Contains(out, "script") || len(f4.hitlQ) != 0 {
t.Fatalf("out %s questions %v", out, f4.hitlQ)
}
}