Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion chat/src/components/chat-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export function ChatProvider({ children }: PropsWithChildren) {
const [serverStatus, setServerStatus] = useState<ServerStatus>("unknown");
const eventSourceRef = useRef<EventSource | null>(null);
const searchParams = useSearchParams();
const agentAPIUrl = searchParams.get("url") || window.location.origin;
const defaultAgentAPIURL = new URL("../../", window.location.href).toString();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid this won't always work. Chat is hosted both at /chat and /chat/embed - the /chat/embed route has a simplified UI.

I created #42 to suggest a workaround.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof, TIL

const agentAPIUrl = searchParams.get("url") || defaultAgentAPIURL;

// Set up SSE connection to the events endpoint
useEffect(() => {
Expand Down
26 changes: 20 additions & 6 deletions lib/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"sync"
"time"

Expand Down Expand Up @@ -33,6 +34,7 @@ type Server struct {
agentio *termexec.Process
agentType mf.AgentType
emitter *EventEmitter
chatBasePath string
}

func (s *Server) GetOpenAPI() string {
Expand Down Expand Up @@ -95,14 +97,20 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr
agentio: process,
agentType: agentType,
emitter: emitter,
chatBasePath: chatBasePath,
}

// Register API routes
s.registerRoutes(chatBasePath)
s.registerRoutes()

return s
}

// Handler returns the underlying chi.Router for testing purposes.
func (s *Server) Handler() http.Handler {
return s.router
}

func (s *Server) StartSnapshotLoop(ctx context.Context) {
s.conversation.StartSnapshotLoop(ctx)
go func() {
Expand All @@ -116,7 +124,7 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) {
}

// registerRoutes sets up all API endpoints
func (s *Server) registerRoutes(chatBasePath string) {
func (s *Server) registerRoutes() {
// GET /status endpoint
huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) {
o.Description = "Returns the current status of the agent."
Expand Down Expand Up @@ -158,7 +166,7 @@ func (s *Server) registerRoutes(chatBasePath string) {
s.router.Handle("/", http.HandlerFunc(s.redirectToChat))

// Serve static files for the chat interface under /chat
s.registerStaticFileRoutes(chatBasePath)
s.registerStaticFileRoutes()
}

// getStatus handles GET /status
Expand Down Expand Up @@ -305,14 +313,20 @@ func (s *Server) Stop(ctx context.Context) error {
}

// registerStaticFileRoutes sets up routes for serving static files
func (s *Server) registerStaticFileRoutes(chatBasePath string) {
chatHandler := FileServerWithIndexFallback(chatBasePath)
func (s *Server) registerStaticFileRoutes() {
chatHandler := FileServerWithIndexFallback(s.chatBasePath)

// Mount the file server at /chat
s.router.Handle("/chat", http.StripPrefix("/chat", chatHandler))
s.router.Handle("/chat/*", http.StripPrefix("/chat", chatHandler))
}

func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect)
rdir, err := url.JoinPath(s.chatBasePath, "embed")
if err != nil {
s.logger.Error("Failed to construct redirect URL", "error", err)
http.Error(w, "Failed to redirect", http.StatusInternalServerError)
return
}
http.Redirect(w, r, rdir, http.StatusTemporaryRedirect)
}
37 changes: 37 additions & 0 deletions lib/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sort"
"testing"
Expand Down Expand Up @@ -73,3 +75,38 @@ func TestOpenAPISchema(t *testing.T) {

require.Equal(t, currentSchema, diskSchema)
}

func TestServer_redirectToChat(t *testing.T) {
cases := []struct {
name string
chatBasePath string
expectedResponseCode int
expectedLocation string
}{
{"default base path", "/chat", http.StatusTemporaryRedirect, "/chat/embed"},
{"custom base path", "/custom", http.StatusTemporaryRedirect, "/custom/embed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil)))
s := httpapi.NewServer(tCtx, msgfmt.AgentTypeClaude, nil, 0, tc.chatBasePath)
tsServer := httptest.NewServer(s.Handler())
t.Cleanup(tsServer.Close)

client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(tsServer.URL + "/")
require.NoError(t, err, "unexpected error making GET request")
t.Cleanup(func() {
_ = resp.Body.Close()
})
require.Equal(t, tc.expectedResponseCode, resp.StatusCode, "expected %d status code", tc.expectedResponseCode)
loc := resp.Header.Get("Location")
require.Equal(t, tc.expectedLocation, loc, "expected Location %q, got %q", tc.expectedLocation, loc)
})
}
}