ci / vet-and-test (pull_request) Successful in 43s
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>
154 lines
4.8 KiB
Go
154 lines
4.8 KiB
Go
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":"主题是什么?"}]}`)
|
||
}
|