Files
tutti/services/tuttid/api/daemon_workspace_workflows_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

364 lines
14 KiB
Go

package api
import (
"context"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
workflowbiz "github.com/tutti-os/tutti/services/tuttid/biz/workspaceworkflow"
tuttimodeplanservice "github.com/tutti-os/tutti/services/tuttid/service/tuttimodeplan"
)
const (
testWorkflowID = "11111111-1111-4111-8111-111111111111"
testRevisionID = "22222222-2222-4222-8222-222222222222"
testCheckpointID = "33333333-3333-4333-8333-333333333333"
)
type stubTuttiModePlanService struct {
views []tuttimodeplanservice.SnapshotView
getInput tuttimodeplanservice.GetInput
listWorkspaceID string
listSessionID string
listKind string
decideInput tuttimodeplanservice.DecideInput
decideFn func(context.Context, tuttimodeplanservice.DecideInput) (tuttimodeplanservice.DecisionResult, error)
}
func (service *stubTuttiModePlanService) GetView(_ context.Context, input tuttimodeplanservice.GetInput) (tuttimodeplanservice.SnapshotView, error) {
service.getInput = input
return service.views[0], nil
}
func (service *stubTuttiModePlanService) ListPendingBySourceSession(_ context.Context, workspaceID string, sessionID string) ([]tuttimodeplanservice.SnapshotView, error) {
service.listWorkspaceID = workspaceID
service.listSessionID = sessionID
service.listKind = "pending"
return service.views, nil
}
func (service *stubTuttiModePlanService) ListBySourceSession(_ context.Context, workspaceID string, sessionID string) ([]tuttimodeplanservice.SnapshotView, error) {
service.listWorkspaceID = workspaceID
service.listSessionID = sessionID
service.listKind = "all"
return service.views, nil
}
func (service *stubTuttiModePlanService) Decide(ctx context.Context, input tuttimodeplanservice.DecideInput) (tuttimodeplanservice.DecisionResult, error) {
if service.decideFn != nil {
return service.decideFn(ctx, input)
}
service.decideInput = input
service.views[0].Checkpoints[0].Status = input.Decision
return tuttimodeplanservice.DecisionResult{Checkpoint: service.views[0].Checkpoints[0], Changed: true}, nil
}
func TestListWorkspaceWorkflowsReturnsAuthoritativeMarkdownSnapshot(t *testing.T) {
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{workflowViewFixture()}}
response, err := (DaemonAPI{TuttiModePlanService: service}).ListWorkspaceWorkflows(context.Background(), tuttigenerated.ListWorkspaceWorkflowsRequestObject{
WorkspaceID: "workspace-1",
Params: tuttigenerated.ListWorkspaceWorkflowsParams{
SourceSessionId: "session-1",
},
})
if err != nil {
t.Fatalf("ListWorkspaceWorkflows() error = %v", err)
}
result, ok := response.(tuttigenerated.ListWorkspaceWorkflows200JSONResponse)
if !ok {
t.Fatalf("response = %T", response)
}
if service.listWorkspaceID != "workspace-1" || service.listSessionID != "session-1" {
t.Fatalf("list inputs = %q/%q", service.listWorkspaceID, service.listSessionID)
}
if service.listKind != "all" {
t.Fatalf("list kind = %q, want all", service.listKind)
}
if len(result.Workflows) != 1 || result.Workflows[0].Revisions[0].Document.MarkdownBody != "Plan body\n" {
t.Fatalf("workflows = %#v", result.Workflows)
}
if len(result.Workflows[0].ActionableItems) != 1 || result.Workflows[0].ActionableItems[0].Id != testWorkflowID+"/"+testRevisionID+"/task-1" {
t.Fatalf("actionable items = %#v", result.Workflows[0].ActionableItems)
}
}
func TestListWorkspaceWorkflowsFiltersPendingWhenRequested(t *testing.T) {
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{workflowViewFixture()}}
status := tuttigenerated.ListWorkspaceWorkflowsParamsCheckpointStatusPending
response, err := (DaemonAPI{TuttiModePlanService: service}).ListWorkspaceWorkflows(context.Background(), tuttigenerated.ListWorkspaceWorkflowsRequestObject{
WorkspaceID: "workspace-1",
Params: tuttigenerated.ListWorkspaceWorkflowsParams{
SourceSessionId: "session-1",
CheckpointStatus: &status,
},
})
if err != nil {
t.Fatalf("ListWorkspaceWorkflows() error = %v", err)
}
if _, ok := response.(tuttigenerated.ListWorkspaceWorkflows200JSONResponse); !ok {
t.Fatalf("response = %T", response)
}
if service.listKind != "pending" {
t.Fatalf("list kind = %q, want pending", service.listKind)
}
}
func TestListWorkspaceWorkflowsRejectsUnsupportedCheckpointStatusAtTransportBoundary(t *testing.T) {
t.Parallel()
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{workflowViewFixture()}}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{TuttiModePlanService: service}))
response := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodGet,
"/v1/workspaces/workspace-1/workflows?sourceSessionId=session-1&checkpointStatus=accepted",
nil,
)
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String())
}
if service.listWorkspaceID != "" || service.listSessionID != "" {
t.Fatalf("list service received invalid request: workspace=%q session=%q", service.listWorkspaceID, service.listSessionID)
}
}
func TestDecideWorkspaceWorkflowCheckpointForwardsUserDecisionOutsideAgentInteraction(t *testing.T) {
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{workflowViewFixture()}}
reason := "Split the task"
response, err := (DaemonAPI{TuttiModePlanService: service}).DecideWorkspaceWorkflowCheckpoint(context.Background(), tuttigenerated.DecideWorkspaceWorkflowCheckpointRequestObject{
WorkspaceID: "workspace-1",
WorkflowID: uuid.MustParse(testWorkflowID),
CheckpointID: uuid.MustParse(testCheckpointID),
Body: &tuttigenerated.DecideWorkspaceWorkflowCheckpointRequest{
Decision: tuttigenerated.DecideWorkspaceWorkflowCheckpointRequestDecisionRejected,
DecidedBy: "user-1",
Reason: &reason,
},
})
if err != nil {
t.Fatalf("DecideWorkspaceWorkflowCheckpoint() error = %v", err)
}
if _, ok := response.(tuttigenerated.DecideWorkspaceWorkflowCheckpoint200JSONResponse); !ok {
t.Fatalf("response = %T", response)
}
if service.decideInput.WorkflowID != testWorkflowID || service.decideInput.CheckpointID != testCheckpointID || service.decideInput.Decision != workflowbiz.CheckpointStatusRejected || service.decideInput.DecidedBy != "user-1" || service.decideInput.DecisionReason != reason {
t.Fatalf("decision input = %#v", service.decideInput)
}
}
func TestDecideWorkspaceWorkflowCheckpointRejectsInvalidDecisionAtTransportBoundary(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body map[string]any
}{
{name: "invalid decision", body: map[string]any{"decision": "deferred", "decidedBy": "user-1"}},
{name: "missing decision", body: map[string]any{"decidedBy": "user-1"}},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
service := &stubTuttiModePlanService{decideFn: func(context.Context, tuttimodeplanservice.DecideInput) (tuttimodeplanservice.DecisionResult, error) {
t.Fatal("Decide should not be called for an invalid decision enum")
return tuttimodeplanservice.DecisionResult{}, nil
}}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{TuttiModePlanService: service}))
response := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/workflows/"+testWorkflowID+"/checkpoints/"+testCheckpointID+"/decision",
test.body,
)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String())
}
})
}
}
func TestWorkspaceWorkflowRoutesAreRegistered(t *testing.T) {
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{workflowViewFixture()}}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{TuttiModePlanService: service}))
t.Run("list by source session", func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/v1/workspaces/workspace-1/workflows?sourceSessionId=session-1", nil)
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if service.listWorkspaceID != "workspace-1" || service.listSessionID != "session-1" {
t.Fatalf("list inputs = %q/%q", service.listWorkspaceID, service.listSessionID)
}
})
t.Run("get authoritative snapshot", func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/v1/workspaces/workspace-1/workflows/"+testWorkflowID, nil)
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if service.getInput.WorkspaceID != "workspace-1" || service.getInput.WorkflowID != testWorkflowID {
t.Fatalf("get input = %#v", service.getInput)
}
})
t.Run("decide checkpoint", func(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/v1/workspaces/workspace-1/workflows/"+testWorkflowID+"/checkpoints/"+testCheckpointID+"/decision",
strings.NewReader(`{"decision":"rejected","decidedBy":"user-1","reason":"Split the task"}`),
)
request.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
if service.decideInput.WorkflowID != testWorkflowID || service.decideInput.CheckpointID != testCheckpointID {
t.Fatalf("decision input = %#v", service.decideInput)
}
})
}
func TestListWorkspaceWorkflowsSerializesEmptyTaskDependenciesAsArray(t *testing.T) {
t.Parallel()
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{workflowViewFixture()}}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{TuttiModePlanService: service}))
response := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodGet,
"/v1/workspaces/workspace-1/workflows?sourceSessionId=session-1&checkpointStatus=pending",
nil,
)
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
}
var payload struct {
Workflows []struct {
Revisions []struct {
Document struct {
Tasks []struct {
DependsOn json.RawMessage `json:"dependsOn"`
} `json:"tasks"`
} `json:"document"`
} `json:"revisions"`
} `json:"workflows"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v; body: %s", err, response.Body.String())
}
got := payload.Workflows[0].Revisions[0].Document.Tasks[0].DependsOn
if string(got) != "[]" {
t.Fatalf("dependsOn = %s, want []", got)
}
}
func TestGetWorkspaceWorkflowRecoversNonFinitePersistedBudgetAsJSONError(t *testing.T) {
t.Parallel()
view := workflowViewFixture()
view.Revisions[0].Document.Budget.QuotaWaterlinePercent = math.NaN()
service := &stubTuttiModePlanService{views: []tuttimodeplanservice.SnapshotView{view}}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{TuttiModePlanService: service}))
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/v1/workspaces/workspace-1/workflows/"+testWorkflowID, nil)
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusBadGateway, response.Body.String())
}
if !json.Valid(response.Body.Bytes()) || strings.Contains(response.Body.String(), "NaN") {
t.Fatalf("response is not a finite JSON recovery error: %q", response.Body.String())
}
}
func workflowViewFixture() tuttimodeplanservice.SnapshotView {
now := time.UnixMilli(1_700_000_000_000).UTC()
task := tuttimodeplanservice.PlanTask{ID: "task-1", Title: "Implement", Content: "Ship it", Priority: "high", DependsOn: []string{}}
execution := tuttimodeplanservice.PlanExecution{Mode: "sequential", ReasoningIntensity: 60, OrchestrationIntensity: 70}
budget := tuttimodeplanservice.PlanBudget{Mode: "auto", TokenLimit: 0, QuotaWaterlinePercent: 20}
return tuttimodeplanservice.SnapshotView{
Workflow: workflowbiz.Workflow{
ID: testWorkflowID,
WorkspaceID: "workspace-1",
Type: workflowbiz.WorkflowTypeTuttiModePlan,
Owner: workflowbiz.WorkflowOwnerTutti,
TriggerKind: workflowbiz.TriggerKindAgentCLI,
SourceSessionID: "session-1",
SourceToolCallID: "tool-1",
Status: workflowbiz.WorkflowStatusAccepted,
CurrentRevisionID: testRevisionID,
CreatedAt: now,
UpdatedAt: now,
},
Revisions: []tuttimodeplanservice.RevisionView{{
Revision: workflowbiz.PlanRevision{
ID: testRevisionID,
WorkflowID: testWorkflowID,
Sequence: 1,
SchemaVersion: tuttimodeplanservice.SchemaV1,
DocumentPath: "tutti-mode-plans/11111111-1111-4111-8111-111111111111/revisions/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.md",
SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
CreatedAt: now,
},
Document: tuttimodeplanservice.PlanDocument{
Schema: tuttimodeplanservice.SchemaV1,
Phase: tuttimodeplanservice.PhaseTaskGraph,
Title: "Plan",
TopicID: "topic-1",
Execution: execution,
Budget: budget,
Tasks: []tuttimodeplanservice.PlanTask{task},
Body: "Plan body\n",
},
}},
Checkpoints: []workflowbiz.WorkflowCheckpoint{{
ID: testCheckpointID,
WorkflowID: testWorkflowID,
Kind: workflowbiz.CheckpointKindTaskReview,
RevisionID: testRevisionID,
Status: workflowbiz.CheckpointStatusAccepted,
CreatedAt: now,
UpdatedAt: now,
}},
ActionableItems: []tuttimodeplanservice.ActionableItem{{
ID: testWorkflowID + "/" + testRevisionID + "/task-1",
SourceWorkflowID: testWorkflowID,
SourceRevisionID: testRevisionID,
Ordinal: 1,
TopicID: "topic-1",
Execution: execution,
Budget: budget,
Task: task,
}},
}
}