Files
Agent-System-Backend/internal/server/workflow_route_test.go
T
weichenandCursor 44aa764b56
ci / vet-and-test (pull_request) Successful in 43s
Recover interrupted runs and commit terminal state once
Why: A hard kill left runs in pending, running, or waiting_hitl, a busy send stored a message before the 409, finishRun could mark a run completed without an assistant message, and a full SSE buffer dropped events without closing the stream.
Evidence: GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... passed, including store, runmgr, bus, and server recovery tests.
Impact: Migration v14 adds runs.instance_id and messages.request_id. Startup reconciliation and shutdown share one seal. Turn and workflow sends claim a slot before writing. Duplicate request ids return the existing message. SettleRun updates the run and appends the assistant message in one transaction. A full subscriber buffer closes the channel so SSE can replay from the cursor.
Refs: Dev feature/pi-durable-borrow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 14:37:49 +00:00

154 lines
4.8 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 server
import (
"context"
"encoding/json"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
type routeCapture struct {
ch chan workflow.Launch
}
func (c routeCapture) Name() string { return "route-capture" }
func (c routeCapture) Execute(rc runtime.RunCtx) error {
src, _ := rc.(interface {
Workflow() (workflow.Launch, bool)
})
if src == nil {
return nil
}
if launch, ok := src.Workflow(); ok {
c.ch <- launch
}
return nil
}
func TestCommandBusBeatsWorkflow(t *testing.T) {
srv, st := newTestServer(t)
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
t.Fatal(err)
}
ch := make(chan workflow.Launch, 1)
srv.mgr.SetWorkflowRunner(routeCapture{ch})
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
reg := workflow.NewRegistry()
reg.Set([]core.WorkflowDef{{
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
}}, nil)
srv.Workflows = reg
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.brief", Title: "简报命令"}, "", func(context.Context, cmdbus.Request) (any, error) {
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
_, convID := setupConversation(t, srv.Handler())
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
if code != 200 || out["command"] != "demo.brief" {
t.Fatalf("status %d body %v", code, out)
}
if _, ok := out["run"]; ok {
t.Fatal("a command hit must not start a workflow run")
}
select {
case <-ch:
t.Fatal("workflow runner started for a command")
default:
}
}
func TestIntentStartsWorkflowRun(t *testing.T) {
srv, st := newTestServer(t)
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
t.Fatal(err)
}
ch := make(chan workflow.Launch, 1)
srv.mgr.SetWorkflowRunner(routeCapture{ch})
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
reg := workflow.NewRegistry()
reg.Set([]core.WorkflowDef{{
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
}}, nil)
srv.Workflows = reg
_, convID := setupConversation(t, srv.Handler())
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
if code != 200 || out["run"] == nil {
t.Fatalf("status %d body %v", code, out)
}
select {
case launch := <-ch:
if launch.Def.ID != "demo.brief" || len(launch.Missing) != 1 || launch.Missing[0].Question != "主题是什么?" {
t.Fatalf("%+v", launch)
}
case <-time.After(time.Second):
t.Fatal("workflow runner was not selected")
}
}
func TestWorkflowRouteClaimsBeforePersist(t *testing.T) {
srv, st := newTestServer(t)
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
t.Fatal(err)
}
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
reg := workflow.NewRegistry()
reg.Set([]core.WorkflowDef{{
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
}}, nil)
srv.Workflows = reg
_, convID := setupConversation(t, srv.Handler())
conv, err := st.GetConversation(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
if err := srv.mgr.ClaimConversation(convID); err != nil {
t.Fatal(err)
}
before, err := st.ListMessages(convID)
if err != nil {
t.Fatal(err)
}
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
if code != 409 {
t.Fatalf("status %d body %v", code, out)
}
after, err := st.ListMessages(convID)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before) {
t.Fatalf("workflow route wrote a message while busy")
}
srv.mgr.ReleaseConversation(conv.ID)
}
func briefIntent() json.RawMessage {
return json.RawMessage(`{"id":"demo.brief","domain":"demo","version":1,"matcher":{"includes_any":["简报"]},"slots":[{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"}]}`)
}