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
424 lines
12 KiB
Go
424 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
agenthost "github.com/tutti-os/tutti/packages/agent/host"
|
|
"github.com/tutti-os/tutti/services/tuttid/apierrors"
|
|
preferencesbiz "github.com/tutti-os/tutti/services/tuttid/biz/preferences"
|
|
agentservice "github.com/tutti-os/tutti/services/tuttid/service/agent"
|
|
)
|
|
|
|
func enabledSideConversationAPI(service SideConversationService) DaemonAPI {
|
|
return DaemonAPI{
|
|
PreferencesService: gateTestPreferences(map[string]bool{
|
|
preferencesbiz.LabFlagAgentSideConversation: true,
|
|
}, nil),
|
|
SideConversationService: service,
|
|
}
|
|
}
|
|
|
|
type sideConversationServiceStub struct {
|
|
resolveFn func(context.Context, string, string) (agenthost.SideConversationCapabilities, error)
|
|
openFn func(context.Context, string, string, agentservice.OpenSideConversationInput) (agentservice.SideConversation, error)
|
|
respondFn func(context.Context, agenthost.RuntimeSubmitInteractiveInput) (agenthost.RuntimeSubmitInteractiveResult, error)
|
|
}
|
|
|
|
func (s sideConversationServiceStub) ResolveSideConversation(
|
|
ctx context.Context,
|
|
workspaceID string,
|
|
sourceID string,
|
|
) (agenthost.SideConversationCapabilities, error) {
|
|
if s.resolveFn != nil {
|
|
return s.resolveFn(ctx, workspaceID, sourceID)
|
|
}
|
|
return agenthost.SideConversationCapabilities{
|
|
Supported: true, ActiveSourceTurn: true, Ephemeral: true,
|
|
HideInheritedTurns: true, ModelBoundaryInjected: true,
|
|
}, nil
|
|
}
|
|
|
|
func TestResolveWorkspaceAgentSideCapabilitiesFailsClosedWhenDisabled(t *testing.T) {
|
|
service := sideConversationServiceStub{
|
|
resolveFn: func(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
) (agenthost.SideConversationCapabilities, error) {
|
|
t.Fatal("disabled capability lookup reached the Side service")
|
|
return agenthost.SideConversationCapabilities{}, nil
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(mux, NewRoutes(DaemonAPI{
|
|
PreferencesService: gateTestPreferences(map[string]bool{}, nil),
|
|
SideConversationService: service,
|
|
}))
|
|
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodGet,
|
|
"/v1/workspaces/workspace-1/agent-sessions/source-1/side-capabilities",
|
|
nil,
|
|
)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var body struct {
|
|
Capabilities struct {
|
|
Supported bool `json:"supported"`
|
|
} `json:"capabilities"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Capabilities.Supported {
|
|
t.Fatalf("disabled capabilities = %#v, want unsupported", body.Capabilities)
|
|
}
|
|
}
|
|
|
|
func TestResolveWorkspaceAgentSideCapabilitiesMapsDisconnectedToExpiredConflict(
|
|
t *testing.T,
|
|
) {
|
|
service := sideConversationServiceStub{
|
|
resolveFn: func(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
) (agenthost.SideConversationCapabilities, error) {
|
|
return agenthost.SideConversationCapabilities{},
|
|
agentservice.ErrRuntimeSessionDisconnected
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(mux, NewRoutes(enabledSideConversationAPI(service)))
|
|
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodGet,
|
|
"/v1/workspaces/workspace-1/agent-sessions/source-1/side-capabilities",
|
|
nil,
|
|
)
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf(
|
|
"status = %d, want 409; body=%s",
|
|
recorder.Code,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Reason string `json:"reason"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Error.Reason != "agent_side_conversation_expired" {
|
|
t.Fatalf("error reason = %q", body.Error.Reason)
|
|
}
|
|
}
|
|
|
|
func TestResolveWorkspaceAgentSideCapabilitiesMapsWorkspaceNotFound(
|
|
t *testing.T,
|
|
) {
|
|
service := sideConversationServiceStub{
|
|
resolveFn: func(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
) (agenthost.SideConversationCapabilities, error) {
|
|
return agenthost.SideConversationCapabilities{},
|
|
apierrors.WorkspaceNotFound(apierrors.ReasonWorkspaceNotFound)
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(mux, NewRoutes(enabledSideConversationAPI(service)))
|
|
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodGet,
|
|
"/v1/workspaces/missing/agent-sessions/source-1/side-capabilities",
|
|
nil,
|
|
)
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf(
|
|
"status = %d, want 404; body=%s",
|
|
recorder.Code,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s sideConversationServiceStub) OpenSideConversation(
|
|
ctx context.Context,
|
|
workspaceID string,
|
|
sourceID string,
|
|
input agentservice.OpenSideConversationInput,
|
|
) (agentservice.SideConversation, error) {
|
|
if s.openFn != nil {
|
|
return s.openFn(ctx, workspaceID, sourceID, input)
|
|
}
|
|
return agentservice.SideConversation{}, nil
|
|
}
|
|
|
|
func (sideConversationServiceStub) SendSideConversation(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
agentservice.SendSideConversationInput,
|
|
) (agenthost.RuntimeExecResult, error) {
|
|
return agenthost.RuntimeExecResult{}, nil
|
|
}
|
|
|
|
func (sideConversationServiceStub) CancelSideConversation(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
string,
|
|
) (agenthost.RuntimeCancelResult, error) {
|
|
return agenthost.RuntimeCancelResult{}, nil
|
|
}
|
|
|
|
func (s sideConversationServiceStub) SubmitSideConversationInteractive(
|
|
ctx context.Context,
|
|
input agenthost.RuntimeSubmitInteractiveInput,
|
|
) (agenthost.RuntimeSubmitInteractiveResult, error) {
|
|
if s.respondFn != nil {
|
|
return s.respondFn(ctx, input)
|
|
}
|
|
return agenthost.RuntimeSubmitInteractiveResult{}, nil
|
|
}
|
|
|
|
func (sideConversationServiceStub) CloseSideConversation(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
) error {
|
|
return nil
|
|
}
|
|
|
|
func TestOpenWorkspaceAgentSideConversationPreservesTransientIdentity(t *testing.T) {
|
|
var observedWorkspaceID, observedSourceID string
|
|
var observedInput agentservice.OpenSideConversationInput
|
|
service := sideConversationServiceStub{
|
|
openFn: func(
|
|
_ context.Context,
|
|
workspaceID string,
|
|
sourceID string,
|
|
input agentservice.OpenSideConversationInput,
|
|
) (agentservice.SideConversation, error) {
|
|
observedWorkspaceID, observedSourceID, observedInput =
|
|
workspaceID, sourceID, input
|
|
return agentservice.SideConversation{
|
|
WorkspaceID: workspaceID, SourceAgentSessionID: sourceID,
|
|
SideAgentSessionID: input.SideAgentSessionID, Provider: "codex",
|
|
Status: "ready",
|
|
Capabilities: agenthost.SideConversationCapabilities{
|
|
Supported: true, ActiveSourceTurn: true, Ephemeral: true,
|
|
HideInheritedTurns: true, ModelBoundaryInjected: true,
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(mux, NewRoutes(enabledSideConversationAPI(service)))
|
|
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodPost,
|
|
"/v1/workspaces/workspace-1/agent-sessions/source-1/side-conversations",
|
|
map[string]any{
|
|
"sideAgentSessionId": "side-1",
|
|
"requestId": "request-1",
|
|
},
|
|
)
|
|
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.SideAgentSessionID != "side-1" ||
|
|
observedInput.RequestID != "request-1" {
|
|
t.Fatalf(
|
|
"open identity = workspace=%q source=%q input=%#v",
|
|
observedWorkspaceID,
|
|
observedSourceID,
|
|
observedInput,
|
|
)
|
|
}
|
|
var body struct {
|
|
Side struct {
|
|
SideAgentSessionID string `json:"sideAgentSessionId"`
|
|
Status string `json:"status"`
|
|
Capabilities struct {
|
|
ActiveSourceTurn bool `json:"activeSourceTurn"`
|
|
Ephemeral bool `json:"ephemeral"`
|
|
HideInheritedTurns bool `json:"hideInheritedTurns"`
|
|
ModelBoundaryInjected bool `json:"modelBoundaryInjected"`
|
|
} `json:"capabilities"`
|
|
} `json:"side"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Side.SideAgentSessionID != "side-1" ||
|
|
body.Side.Status != "ready" ||
|
|
!body.Side.Capabilities.ActiveSourceTurn ||
|
|
!body.Side.Capabilities.Ephemeral ||
|
|
!body.Side.Capabilities.HideInheritedTurns ||
|
|
!body.Side.Capabilities.ModelBoundaryInjected {
|
|
t.Fatalf("side response = %#v", body.Side)
|
|
}
|
|
}
|
|
|
|
func TestOpenWorkspaceAgentSideConversationRejectsDisabledFeature(t *testing.T) {
|
|
service := sideConversationServiceStub{
|
|
openFn: func(
|
|
context.Context,
|
|
string,
|
|
string,
|
|
agentservice.OpenSideConversationInput,
|
|
) (agentservice.SideConversation, error) {
|
|
t.Fatal("disabled Side open reached the service")
|
|
return agentservice.SideConversation{}, nil
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(mux, NewRoutes(DaemonAPI{
|
|
PreferencesService: gateTestPreferences(map[string]bool{}, nil),
|
|
SideConversationService: service,
|
|
}))
|
|
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodPost,
|
|
"/v1/workspaces/workspace-1/agent-sessions/source-1/side-conversations",
|
|
map[string]any{
|
|
"sideAgentSessionId": "side-1",
|
|
"requestId": "request-1",
|
|
},
|
|
)
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSubmitWorkspaceAgentSideConversationInteractiveUsesExactIdentity(
|
|
t *testing.T,
|
|
) {
|
|
var observed agenthost.RuntimeSubmitInteractiveInput
|
|
service := sideConversationServiceStub{
|
|
respondFn: func(
|
|
_ context.Context,
|
|
input agenthost.RuntimeSubmitInteractiveInput,
|
|
) (agenthost.RuntimeSubmitInteractiveResult, error) {
|
|
observed = input
|
|
return agenthost.RuntimeSubmitInteractiveResult{
|
|
Disposition: agenthost.RuntimeInteractiveDispositionAnswered,
|
|
}, nil
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(mux, NewRoutes(enabledSideConversationAPI(service)))
|
|
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodPost,
|
|
"/v1/workspaces/workspace-1/agent-side-conversations/side-1/turns/turn-1/interactive/request-1",
|
|
map[string]any{
|
|
"action": "approve",
|
|
"optionId": "allow",
|
|
"payload": map[string]any{"reason": "reviewed"},
|
|
},
|
|
)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if observed.WorkspaceID != "workspace-1" ||
|
|
observed.RootAgentSessionID != "side-1" ||
|
|
observed.AgentSessionID != "side-1" ||
|
|
observed.TurnID != "turn-1" ||
|
|
observed.RequestID != "request-1" ||
|
|
observed.Action != "approve" ||
|
|
observed.OptionID != "allow" ||
|
|
observed.Payload["reason"] != "reviewed" {
|
|
t.Fatalf("interactive identity = %#v", observed)
|
|
}
|
|
}
|
|
|
|
func TestSubmitWorkspaceAgentSideConversationInteractiveClassifiesErrors(
|
|
t *testing.T,
|
|
) {
|
|
for _, test := range []struct {
|
|
name string
|
|
err error
|
|
wantStatus int
|
|
wantReason string
|
|
}{
|
|
{
|
|
name: "request no longer live",
|
|
err: agenthost.ErrInteractiveRequestNotLive,
|
|
wantStatus: http.StatusConflict,
|
|
wantReason: "agent_side_interaction_not_live",
|
|
},
|
|
{
|
|
name: "invalid option",
|
|
err: agenthost.ErrInteractiveResponseInvalid,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantReason: "agent_side_interaction_invalid_response",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
service := sideConversationServiceStub{
|
|
respondFn: func(
|
|
context.Context,
|
|
agenthost.RuntimeSubmitInteractiveInput,
|
|
) (agenthost.RuntimeSubmitInteractiveResult, error) {
|
|
return agenthost.RuntimeSubmitInteractiveResult{}, test.err
|
|
},
|
|
}
|
|
mux := http.NewServeMux()
|
|
RegisterRoutes(
|
|
mux,
|
|
NewRoutes(enabledSideConversationAPI(service)),
|
|
)
|
|
recorder := performGeneratedRouteRequest(
|
|
t,
|
|
mux,
|
|
http.MethodPost,
|
|
"/v1/workspaces/workspace-1/agent-side-conversations/side-1/turns/turn-1/interactive/request-1",
|
|
map[string]any{"optionId": "allow"},
|
|
)
|
|
if recorder.Code != test.wantStatus {
|
|
t.Fatalf(
|
|
"status = %d, want %d; body=%s",
|
|
recorder.Code,
|
|
test.wantStatus,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Reason *string `json:"reason"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Error.Reason == nil || *body.Error.Reason != test.wantReason {
|
|
t.Fatalf("reason = %#v, want %q", body.Error.Reason, test.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|