Files
tutti/services/tuttid/api/daemon_agent_session_fork_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

532 lines
16 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
storesqlite "github.com/tutti-os/tutti/packages/agent/store-sqlite"
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
agentservice "github.com/tutti-os/tutti/services/tuttid/service/agent"
)
func TestForkWorkspaceAgentSessionPreservesExactThroughTurnIdentity(t *testing.T) {
var observedWorkspaceID, observedSourceID string
var observedInput agentservice.ForkSessionInput
service := stubAgentSessionService{
forkFn: func(
_ context.Context,
workspaceID, sourceID string,
input agentservice.ForkSessionInput,
) (agentservice.SessionForkOperation, error) {
observedWorkspaceID, observedSourceID, observedInput = workspaceID, sourceID, input
now := time.UnixMilli(100)
session := agentservice.Session{
ID: input.TargetAgentSessionID, Kind: storesqlite.SessionKindRoot,
Provider: "codex", RailSectionKey: "conversations", CreatedAt: now,
LifecycleCapabilities: agentservice.SessionLifecycleCapabilities{
ForkThroughTurn: true,
},
}
lineage := agentservice.SessionForkLineage{
SourceAgentSessionID: sourceID, SourceTurnID: input.ThroughTurnID,
TargetTurnID: "target-turn-7",
OperationID: "operation-1", ForkedAtUnixMS: 100,
}
return agentservice.SessionForkOperation{
OperationID: "operation-1", RequestID: input.RequestID,
SourceAgentSessionID: sourceID,
TargetAgentSessionID: input.TargetAgentSessionID,
Point: agentservice.SessionForkPoint{
Type: "throughTurn", TurnID: input.ThroughTurnID,
},
Status: agentservice.SessionForkOperationCommitted,
Phase: "committed",
Session: &session, Lineage: &lineage,
}, nil
},
}
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: service,
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/agent-sessions/source-1/fork",
map[string]any{
"targetAgentSessionId": "f0f21d94-af7c-4bdd-8f24-bffd169474bc",
"requestId": "request-1",
"point": map[string]any{
"type": "throughTurn", "turnId": "turn-7",
},
},
)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String())
}
if observedWorkspaceID != "workspace-1" || observedSourceID != "source-1" ||
observedInput.TargetAgentSessionID != "f0f21d94-af7c-4bdd-8f24-bffd169474bc" ||
observedInput.RequestID != "request-1" ||
observedInput.ThroughTurnID != "turn-7" {
t.Fatalf(
"fork identity = workspace=%q source=%q input=%#v",
observedWorkspaceID,
observedSourceID,
observedInput,
)
}
var body struct {
Operation struct {
OperationID string `json:"operationId"`
Status string `json:"status"`
Phase string `json:"phase"`
Session *struct {
ID string `json:"id"`
LifecycleCapabilities struct {
ForkThroughTurn bool `json:"forkThroughTurn"`
} `json:"lifecycleCapabilities"`
} `json:"session"`
Lineage *struct {
SourceTurnID string `json:"sourceTurnId"`
TargetTurnID string `json:"targetTurnId"`
} `json:"lineage"`
} `json:"operation"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Operation.OperationID != "operation-1" ||
body.Operation.Status != "committed" ||
body.Operation.Phase != "committed" ||
body.Operation.Session == nil ||
body.Operation.Session.ID != "f0f21d94-af7c-4bdd-8f24-bffd169474bc" ||
!body.Operation.Session.LifecycleCapabilities.ForkThroughTurn ||
body.Operation.Lineage == nil ||
body.Operation.Lineage.SourceTurnID != "turn-7" ||
body.Operation.Lineage.TargetTurnID != "target-turn-7" {
t.Fatalf("operation response=%#v", body.Operation)
}
}
func TestForkWorkspaceAgentSessionUnsupportedIsConflict(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
forkFn: func(
context.Context,
string,
string,
agentservice.ForkSessionInput,
) (agentservice.SessionForkOperation, error) {
return agentservice.SessionForkOperation{}, agentservice.ErrSessionForkUnsupported
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/agent-sessions/source-1/fork",
map[string]any{
"targetAgentSessionId": "f0f21d94-af7c-4bdd-8f24-bffd169474bc",
"requestId": "request-1",
"point": map[string]any{
"type": "throughTurn", "turnId": "turn-7",
},
},
)
if recorder.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", recorder.Code, recorder.Body.String())
}
}
func TestForkWorkspaceAgentSessionReportsBoundaryRejectionReason(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
forkFn: func(
context.Context,
string,
string,
agentservice.ForkSessionInput,
) (agentservice.SessionForkOperation, error) {
boundaryErr := &storesqlite.SessionForkBoundaryError{
Reason: storesqlite.SessionForkBoundaryReasonTurnSequenceUnverified,
}
return agentservice.SessionForkOperation{}, fmt.Errorf(
"%w: %w",
agentservice.ErrSessionForkConflict,
boundaryErr,
)
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/agent-sessions/source-1/fork",
map[string]any{
"targetAgentSessionId": "f0f21d94-af7c-4bdd-8f24-bffd169474bc",
"requestId": "request-1",
"point": map[string]any{
"type": "throughTurn", "turnId": "turn-7",
},
},
)
if recorder.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body=%s", recorder.Code, recorder.Body.String())
}
var body tuttigenerated.ApiErrorResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Reason == nil || *body.Error.Reason != "agent_session_fork_conflict" {
t.Fatalf("reason=%v", body.Error.Reason)
}
if body.Error.Params == nil ||
(*body.Error.Params)["forkBoundaryReason"] !=
string(storesqlite.SessionForkBoundaryReasonTurnSequenceUnverified) {
t.Fatalf("params=%v", body.Error.Params)
}
if body.Error.DeveloperMessage == nil ||
!strings.Contains(*body.Error.DeveloperMessage, storesqlite.ErrSessionForkTurnState.Error()) {
t.Fatalf("developerMessage=%v", body.Error.DeveloperMessage)
}
}
func TestForkWorkspaceAgentSessionReturnsDurableOutcomesAsOperationSnapshots(t *testing.T) {
for _, test := range []struct {
name string
status agentservice.SessionForkOperationStatus
errorValue string
wantHTTP int
}{
{name: "accepted", status: agentservice.SessionForkOperationAccepted, wantHTTP: http.StatusAccepted},
{name: "failed", status: agentservice.SessionForkOperationFailed, errorValue: "provider rejected fork", wantHTTP: http.StatusOK},
{name: "unknown", status: agentservice.SessionForkOperationUnknown, errorValue: "delivery outcome unknown", wantHTTP: http.StatusOK},
} {
t.Run(test.name, func(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
forkFn: func(
context.Context,
string,
string,
agentservice.ForkSessionInput,
) (agentservice.SessionForkOperation, error) {
var operationError *string
if test.errorValue != "" {
value := test.errorValue
operationError = &value
}
return agentservice.SessionForkOperation{
OperationID: "operation-1", RequestID: "request-1",
SourceAgentSessionID: "source-1",
TargetAgentSessionID: "f0f21d94-af7c-4bdd-8f24-bffd169474bc",
Point: agentservice.SessionForkPoint{
Type: "throughTurn", TurnID: "turn-7",
},
Status: test.status, Error: operationError,
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/agent-sessions/source-1/fork",
map[string]any{
"targetAgentSessionId": "f0f21d94-af7c-4bdd-8f24-bffd169474bc",
"requestId": "request-1",
"point": map[string]any{
"type": "throughTurn", "turnId": "turn-7",
},
},
)
if recorder.Code != test.wantHTTP {
t.Fatalf(
"status=%d, want %d; body=%s",
recorder.Code,
test.wantHTTP,
recorder.Body.String(),
)
}
var body struct {
Operation struct {
Status string `json:"status"`
Error *string `json:"error"`
} `json:"operation"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Operation.Status != string(test.status) {
t.Fatalf("operation=%#v", body.Operation)
}
if test.errorValue != "" &&
(body.Operation.Error == nil || *body.Operation.Error != test.errorValue) {
t.Fatalf("operation error=%#v", body.Operation.Error)
}
})
}
}
func TestGetWorkspaceAgentSessionForkOperationReturnsDurableSnapshot(t *testing.T) {
var observedWorkspaceID, observedOperationID string
acknowledgeCalls := 0
now := time.UnixMilli(200)
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
getSessionForkOperationFn: func(
_ context.Context,
workspaceID, operationID string,
) (agentservice.SessionForkOperation, error) {
observedWorkspaceID, observedOperationID = workspaceID, operationID
lineage := agentservice.SessionForkLineage{
SourceAgentSessionID: "source-1",
SourceTurnID: "turn-7",
TargetTurnID: "target-turn-7",
OperationID: operationID,
ForkedAtUnixMS: 200,
}
session := agentservice.Session{
ID: "target-1", Kind: storesqlite.SessionKindRoot,
Provider: "codex", RailSectionKey: "conversations",
CreatedAt: now, ForkedFrom: &lineage,
}
return agentservice.SessionForkOperation{
OperationID: operationID, RequestID: "request-1",
SourceAgentSessionID: "source-1", TargetAgentSessionID: "target-1",
Point: agentservice.SessionForkPoint{
Type: "throughTurn", TurnID: "turn-7",
},
Status: agentservice.SessionForkOperationCommitted,
Session: &session,
Lineage: &lineage,
}, nil
},
acknowledgeSessionForkOperationFn: func(
context.Context,
string,
string,
) (agentservice.SessionForkOperation, error) {
acknowledgeCalls++
return agentservice.SessionForkOperation{}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/workspace-1/agent-session-fork-operations/operation-1",
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", recorder.Code, recorder.Body.String())
}
if observedWorkspaceID != "workspace-1" || observedOperationID != "operation-1" {
t.Fatalf(
"lookup identity=workspace %q operation %q",
observedWorkspaceID,
observedOperationID,
)
}
if acknowledgeCalls != 0 {
t.Fatalf("GET implicitly acknowledged operation %d times", acknowledgeCalls)
}
var body struct {
Operation struct {
Status string `json:"status"`
Session *struct {
ForkedFrom *struct {
TargetTurnID string `json:"targetTurnId"`
} `json:"forkedFrom"`
} `json:"session"`
Lineage *struct {
TargetTurnID string `json:"targetTurnId"`
} `json:"lineage"`
} `json:"operation"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Operation.Status != "committed" ||
body.Operation.Session == nil ||
body.Operation.Session.ForkedFrom == nil ||
body.Operation.Session.ForkedFrom.TargetTurnID != "target-turn-7" ||
body.Operation.Lineage == nil ||
body.Operation.Lineage.TargetTurnID != "target-turn-7" {
t.Fatalf("operation response=%#v", body.Operation)
}
}
func TestAcknowledgeWorkspaceAgentSessionForkOperationReturnsDurableSnapshot(t *testing.T) {
var observedWorkspaceID, observedOperationID string
now := time.UnixMilli(200)
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
acknowledgeSessionForkOperationFn: func(
_ context.Context,
workspaceID, operationID string,
) (agentservice.SessionForkOperation, error) {
observedWorkspaceID, observedOperationID = workspaceID, operationID
session := agentservice.Session{
ID: "target-1", Kind: storesqlite.SessionKindRoot,
Provider: "codex", RailSectionKey: "conversations",
CreatedAt: now,
}
lineage := agentservice.SessionForkLineage{
SourceAgentSessionID: "source-1", SourceTurnID: "turn-7",
TargetTurnID: "target-turn-7",
OperationID: operationID, ForkedAtUnixMS: 200,
}
session.ForkedFrom = &lineage
return agentservice.SessionForkOperation{
OperationID: operationID, RequestID: "request-1",
SourceAgentSessionID: "source-1",
TargetAgentSessionID: "target-1",
Point: agentservice.SessionForkPoint{
Type: "throughTurn", TurnID: "turn-7",
},
Status: agentservice.SessionForkOperationCommitted,
Session: &session, Lineage: &lineage,
}, nil
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/agent-session-fork-operations/operation-1/acknowledge",
nil,
)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", recorder.Code, recorder.Body.String())
}
if observedWorkspaceID != "workspace-1" || observedOperationID != "operation-1" {
t.Fatalf(
"acknowledge identity=workspace %q operation %q",
observedWorkspaceID,
observedOperationID,
)
}
var body struct {
Operation struct {
OperationID string `json:"operationId"`
Status string `json:"status"`
Session *struct {
ID string `json:"id"`
ForkedFrom *struct {
TargetTurnID string `json:"targetTurnId"`
} `json:"forkedFrom"`
} `json:"session"`
Lineage *struct {
TargetTurnID string `json:"targetTurnId"`
} `json:"lineage"`
} `json:"operation"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Operation.OperationID != "operation-1" ||
body.Operation.Status != "committed" ||
body.Operation.Session == nil ||
body.Operation.Session.ID != "target-1" ||
body.Operation.Session.ForkedFrom == nil ||
body.Operation.Session.ForkedFrom.TargetTurnID != "target-turn-7" ||
body.Operation.Lineage == nil ||
body.Operation.Lineage.TargetTurnID != "target-turn-7" {
t.Fatalf("operation response=%#v", body.Operation)
}
}
func TestGetWorkspaceAgentSessionForkOperationNotFound(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
getSessionForkOperationFn: func(
context.Context,
string,
string,
) (agentservice.SessionForkOperation, error) {
return agentservice.SessionForkOperation{},
agentservice.ErrSessionForkOperationNotFound
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodGet,
"/v1/workspaces/workspace-1/agent-session-fork-operations/missing",
nil,
)
if recorder.Code != http.StatusNotFound {
t.Fatalf("status=%d, want 404; body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAcknowledgeWorkspaceAgentSessionForkOperationNotFound(t *testing.T) {
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{
acknowledgeSessionForkOperationFn: func(
context.Context,
string,
string,
) (agentservice.SessionForkOperation, error) {
return agentservice.SessionForkOperation{},
agentservice.ErrSessionForkOperationNotFound
},
},
}))
recorder := performGeneratedRouteRequest(
t,
mux,
http.MethodPost,
"/v1/workspaces/workspace-1/agent-session-fork-operations/missing/acknowledge",
nil,
)
if recorder.Code != http.StatusNotFound {
t.Fatalf("status=%d, want 404; body=%s", recorder.Code, recorder.Body.String())
}
}
func TestForkWorkspaceAgentSessionRejectsUnknownPoint(t *testing.T) {
recorder := httptest.NewRecorder()
mux := http.NewServeMux()
RegisterRoutes(mux, NewRoutes(DaemonAPI{
AgentSessionService: stubAgentSessionService{},
}))
request := httptest.NewRequest(
http.MethodPost,
"/v1/workspaces/workspace-1/agent-sessions/source-1/fork",
nil,
)
request.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", recorder.Code, recorder.Body.String())
}
}