Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
665 changes: 597 additions & 68 deletions cmd/sgai/mcp_external.go

Large diffs are not rendered by default.

845 changes: 807 additions & 38 deletions cmd/sgai/mcp_external_test.go

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions cmd/sgai/serve_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2147,6 +2147,150 @@ func TestHandleAPIForkWorkspace(t *testing.T) {
})
}

func TestHandleAPIForkWorkspaceCopiesRootFrontmatterAndPreservesSubmittedBodyText(t *testing.T) {
server, rootDir := setupTestServer(t)
workspaceDir := setupTestWorkspace(t, server, rootDir, "fork-http")
require.NoError(t, initializeWorkspace(workspaceDir))

rootGoalContent := strings.Join([]string{
"---",
"title: Root Goal",
"flow: |",
" \"coordinator\" -> \"go-developer\"",
"models:",
" coordinator: root-model",
"---",
"",
"# Root Goal",
}, "\n")
require.NoError(t, os.WriteFile(filepath.Join(workspaceDir, "GOAL.md"), []byte(rootGoalContent), 0o644))

submittedGoalContent := strings.Join([]string{
"---",
"title: Edited In Browser",
"flow: |",
" \"browser\" -> \"editor\"",
"---",
"",
"# Fork Goal",
"",
"Implement the HTTP fork.",
}, "\n")
requestBody, errMarshal := json.Marshal(apiForkRequest{GoalContent: submittedGoalContent})
require.NoError(t, errMarshal)

w := serveHTTP(server, "POST", "/api/v1/workspaces/fork-http/fork", string(requestBody))
require.Equal(t, http.StatusCreated, w.Code)

var response apiForkResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&response))
forkGoalContent, errRead := os.ReadFile(filepath.Join(response.Dir, "GOAL.md"))
require.NoError(t, errRead)

expectedGoalContent := strings.Join([]string{
"---",
"title: Root Goal",
"flow: |",
" \"coordinator\" -> \"go-developer\"",
"models:",
" coordinator: root-model",
"---",
}, "\n") + "\n\n" + submittedGoalContent
assert.Equal(t, expectedGoalContent, string(forkGoalContent))
}

func TestHandleAPIForkWorkspacePreservesSubmittedBodyTextThatLooksLikeFrontmatter(t *testing.T) {
server, rootDir := setupTestServer(t)
workspaceDir := setupTestWorkspace(t, server, rootDir, "fork-http-leading-whitespace")
require.NoError(t, initializeWorkspace(workspaceDir))

rootGoalContent := strings.Join([]string{
"---",
"title: Root Goal",
"flow: |",
" \"coordinator\" -> \"go-developer\"",
"models:",
" coordinator: root-model",
"---",
"",
"# Root Goal",
}, "\n")
require.NoError(t, os.WriteFile(filepath.Join(workspaceDir, "GOAL.md"), []byte(rootGoalContent), 0o644))

submittedGoalContent := strings.Join([]string{
"",
" \t",
"---",
"title: Edited In Browser",
"flow: |",
" \"browser\" -> \"editor\"",
"---",
"",
"# Fork Goal",
"",
"Implement the HTTP fork.",
}, "\n")
requestBody, errMarshal := json.Marshal(apiForkRequest{GoalContent: submittedGoalContent})
require.NoError(t, errMarshal)

w := serveHTTP(server, "POST", "/api/v1/workspaces/fork-http-leading-whitespace/fork", string(requestBody))
require.Equal(t, http.StatusCreated, w.Code)

var response apiForkResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&response))
forkGoalContent, errRead := os.ReadFile(filepath.Join(response.Dir, "GOAL.md"))
require.NoError(t, errRead)

expectedGoalContent := strings.Join([]string{
"---",
"title: Root Goal",
"flow: |",
" \"coordinator\" -> \"go-developer\"",
"models:",
" coordinator: root-model",
"---",
}, "\n") + "\n\n" + submittedGoalContent
assert.Equal(t, expectedGoalContent, string(forkGoalContent))
}

func TestHandleAPIForkWorkspaceAcceptsFrontmatterLookingBodyText(t *testing.T) {
server, rootDir := setupTestServer(t)
workspaceDir := setupTestWorkspace(t, server, rootDir, "fork-http-empty-frontmatter")
require.NoError(t, initializeWorkspace(workspaceDir))
require.NoError(t, os.WriteFile(filepath.Join(workspaceDir, "GOAL.md"), []byte("---\ntitle: Root Goal\nflow: |\n \"a\" -> \"b\"\n---\n# Goal"), 0o644))

submittedGoalContent := strings.Join([]string{
"",
" \t",
"---",
"title: Edited In Browser",
"flow: |",
" \"browser\" -> \"editor\"",
"---",
"",
" \t",
}, "\n")
requestBody, errMarshal := json.Marshal(apiForkRequest{GoalContent: submittedGoalContent})
require.NoError(t, errMarshal)

w := serveHTTP(server, "POST", "/api/v1/workspaces/fork-http-empty-frontmatter/fork", string(requestBody))
require.Equal(t, http.StatusCreated, w.Code)

var response apiForkResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&response))
forkGoalContent, errRead := os.ReadFile(filepath.Join(response.Dir, "GOAL.md"))
require.NoError(t, errRead)

expectedGoalContent := strings.Join([]string{
"---",
"title: Root Goal",
"flow: |",
" \"a\" -> \"b\"",
"---",
}, "\n") + "\n\n" + submittedGoalContent
assert.Equal(t, expectedGoalContent, string(forkGoalContent))
}

func TestHandleAPIDeleteFork(t *testing.T) {
t.Run("missingWorkspace", func(t *testing.T) {
server, _ := setupTestServer(t)
Expand Down
128 changes: 109 additions & 19 deletions cmd/sgai/service_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,23 @@ import (
)

var (
errRootWorkspaceCannotStart = errors.New("root workspace cannot start agentic work")
errSessionResetWhileRunning = errors.New("cannot reset while session is running")
errNoPendingQuestion = errors.New("no pending question")
errPromptTokenRequired = errors.New("prompt token is required")
errResponseCannotBeEmpty = errors.New("response cannot be empty")
errQuestionNotAvailable = errors.New("question not available")
errSteerMessageEmpty = errors.New("message cannot be empty")
errRootWorkspaceCannotStart = errors.New("root workspace cannot start agentic work")
errSessionResetWhileRunning = errors.New("cannot reset while session is running")
errInteractiveStartRequiresContinuousConfig = errors.New("interactive start requires continuous configuration")
errContinuousModeNotConfigured = errors.New("continuous mode is not configured")
errNoPendingQuestion = errors.New("no pending question")
errPromptTokenRequired = errors.New("prompt token is required")
errResponseCannotBeEmpty = errors.New("response cannot be empty")
errQuestionNotAvailable = errors.New("question not available")
errSteerMessageEmpty = errors.New("message cannot be empty")
)

type sessionStartMode string

const (
sessionStartModeSelfDrive sessionStartMode = "self-drive"
sessionStartModeInteractive sessionStartMode = "interactive"
sessionStartModeContinuous sessionStartMode = "continuous"
)

type sessionStartResult struct {
Expand All @@ -29,41 +39,53 @@ type sessionStartResult struct {
Running bool
Message string
AlreadyRunning bool
RunningMode string
}

func (s *Server) startSessionService(workspacePath string, auto bool) (sessionStartResult, error) {
requestedMode := requestedSessionStartMode(auto, readContinuousModePrompt(workspacePath) != "")
return s.startSessionInModeService(workspacePath, requestedMode)
}

func requestedSessionStartMode(auto, continuousConfigured bool) sessionStartMode {
switch {
case continuousConfigured:
return sessionStartModeContinuous
case auto:
return sessionStartModeSelfDrive
default:
return sessionStartModeInteractive
}
}

func (s *Server) startSessionInModeService(workspacePath string, requestedMode sessionStartMode) (sessionStartResult, error) {
if s.classifyWorkspaceCached(workspacePath) == workspaceRoot {
return sessionStartResult{}, errRootWorkspaceCannotStart
}

name := filepath.Base(workspacePath)

if s.sessionRunning(workspacePath) {
if runningMode, okRunningMode := s.runningSessionMode(workspacePath); okRunningMode {
return sessionStartResult{
Name: name,
Status: "running",
Running: true,
Message: "session already running",
AlreadyRunning: true,
RunningMode: string(runningMode),
}, nil
}

if errValidateStartMode := validateStartModeRequest(workspacePath, requestedMode); errValidateStartMode != nil {
return sessionStartResult{}, errValidateStartMode
}

if errValidateStart := validateStartSessionWorkspace(workspacePath); errValidateStart != nil {
return sessionStartResult{}, errValidateStart
}

coord := s.workspaceCoordinator(workspacePath)
continuousPrompt := readContinuousModePrompt(workspacePath)

var interactionMode string
switch {
case continuousPrompt != "":
interactionMode = state.ModeContinuous
case auto:
interactionMode = state.ModeSelfDrive
default:
interactionMode = state.ModeBrainstorming
}
interactionMode := interactionModeForSessionStart(requestedMode)

if errUpdate := coord.UpdateState(func(wf *state.Workflow) {
wf.InteractionMode = interactionMode
Expand All @@ -74,12 +96,17 @@ func (s *Server) startSessionService(workspacePath string, auto bool) (sessionSt
result := s.startSession(workspacePath)

if result.alreadyRunning {
runningMode, okRunningMode := s.runningSessionMode(workspacePath)
if !okRunningMode {
runningMode = requestedMode
}
return sessionStartResult{
Name: name,
Status: "running",
Running: true,
Message: "session already running",
AlreadyRunning: true,
RunningMode: string(runningMode),
}, nil
}

Expand All @@ -93,9 +120,72 @@ func (s *Server) startSessionService(workspacePath string, auto bool) (sessionSt
Running: true,
Message: "session started",
AlreadyRunning: false,
RunningMode: string(requestedMode),
}, nil
}

func validateStartModeRequest(workspacePath string, requestedMode sessionStartMode) error {
continuousConfigured := readContinuousModePrompt(workspacePath) != ""
switch requestedMode {
case sessionStartModeSelfDrive:
return nil
case sessionStartModeInteractive:
if continuousConfigured {
return errInteractiveStartRequiresContinuousConfig
}
case sessionStartModeContinuous:
if !continuousConfigured {
return errContinuousModeNotConfigured
}
}
return nil
}

func interactionModeForSessionStart(requestedMode sessionStartMode) string {
switch requestedMode {
case sessionStartModeInteractive:
return state.ModeBrainstorming
case sessionStartModeSelfDrive:
return state.ModeSelfDrive
case sessionStartModeContinuous:
return state.ModeContinuous
default:
return state.ModeBrainstorming
}
}

func runningModeFromInteractionMode(interactionMode string) sessionStartMode {
switch interactionMode {
case state.ModeSelfDrive:
return sessionStartModeSelfDrive
case state.ModeContinuous:
return sessionStartModeContinuous
default:
return sessionStartModeInteractive
}
}

func (s *Server) runningSessionMode(workspacePath string) (sessionStartMode, bool) {
s.mu.Lock()
sess := s.sessions[workspacePath]
s.mu.Unlock()
if sess == nil {
return "", false
}

sess.mu.Lock()
running := sess.running
coord := sess.coord
sess.mu.Unlock()
if !running {
return "", false
}
if coord != nil {
return runningModeFromInteractionMode(coord.State().InteractionMode), true
}
return runningModeFromInteractionMode(s.loadWorkspaceState(workspacePath).InteractionMode), true
}

func (s *Server) sessionRunning(workspacePath string) bool {
s.mu.Lock()
sess := s.sessions[workspacePath]
Expand Down
Loading