shelley: don't auto-start agent on 'continue in new conversation'

Philip Zeyliger and Shelley created

Prompt: When we click on 'continue in new conversation', we send the
message right away to the agent. The user often wants to add extra
instructions. What would be a good way to get the conversation created,
but not call the agent? Propose a solution for me.
Follow-up: What do you think of option C where we just don't send the
message? It's not really pending, and it's not really editable, but the
user can add another message and hit send, and both will get sent.
Follow-up: Yes, let's do it.

When users click 'Continue in new conversation', they often want to add
extra instructions before the agent starts working. This change:

1. Records the conversation summary as a user message
2. Does NOT start the agent loop automatically
3. Returns status 'created' instead of 'accepted'

The user sees their summary message displayed and can then type additional
instructions before sending. When they send a message, the agent will
receive both the summary and their new instructions in context.

This gives users control over when the agent starts, rather than
immediately kicking off a potentially long-running task.

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

server/handlers.go  | 64 +++++++++++++------------------------------
test/server_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 88 insertions(+), 44 deletions(-)

Detailed changes

server/handlers.go 🔗

@@ -782,7 +782,8 @@ type ContinueConversationRequest struct {
 }
 
 // handleContinueConversation handles POST /api/conversations/continue
-// Creates a new conversation with a summary of the source conversation as the initial message
+// Creates a new conversation with a summary of the source conversation as the initial user message,
+// but does NOT start the agent. The user can then add additional instructions before sending.
 func (s *Server) handleContinueConversation(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
 		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -826,7 +827,7 @@ func (s *Server) handleContinueConversation(w http.ResponseWriter, r *http.Reque
 	}
 	summary := buildConversationSummary(sourceSlug, messages)
 
-	// Get LLM service for the requested model
+	// Determine model to use
 	modelID := req.Model
 	if modelID == "" && sourceConv.Model != nil {
 		modelID = *sourceConv.Model
@@ -835,13 +836,6 @@ func (s *Server) handleContinueConversation(w http.ResponseWriter, r *http.Reque
 		modelID = "qwen3-coder-fireworks"
 	}
 
-	llmService, err := s.llmManager.GetService(modelID)
-	if err != nil {
-		s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
-		http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
-		return
-	}
-
 	// Create new conversation with cwd from request or source conversation
 	var cwdPtr *string
 	if req.Cwd != "" {
@@ -863,19 +857,8 @@ func (s *Server) handleContinueConversation(w http.ResponseWriter, r *http.Reque
 		Conversation: conversation,
 	})
 
-	// Get or create conversation manager
-	manager, err := s.getOrCreateConversationManager(ctx, conversationID)
-	if errors.Is(err, errConversationModelMismatch) {
-		http.Error(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-	if err != nil {
-		s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
-		http.Error(w, "Internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// Create user message with the summary
+	// Create and record the user message with the summary, but do NOT start the agent loop.
+	// This allows the user to see the summary and add additional instructions before sending.
 	userMessage := llm.Message{
 		Role: llm.MessageRoleUser,
 		Content: []llm.Content{
@@ -883,36 +866,29 @@ func (s *Server) handleContinueConversation(w http.ResponseWriter, r *http.Reque
 		},
 	}
 
-	firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
-	if errors.Is(err, errConversationModelMismatch) {
-		http.Error(w, err.Error(), http.StatusBadRequest)
-		return
-	}
-	if err != nil {
-		s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
+	if err := s.recordMessage(ctx, conversationID, userMessage, llm.Usage{}); err != nil {
+		s.logger.Error("Failed to record summary message", "conversationID", conversationID, "error", err)
 		http.Error(w, "Internal server error", http.StatusInternalServerError)
 		return
 	}
 
-	// Generate slug for the new conversation
-	if firstMessage {
-		ctxNoCancel := context.WithoutCancel(ctx)
-		go func() {
-			slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
-			defer cancel()
-			_, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, summary, modelID)
-			if err != nil {
-				s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
-			} else {
-				go s.notifySubscribers(ctxNoCancel, conversationID)
-			}
-		}()
-	}
+	// Generate slug for the new conversation in background
+	ctxNoCancel := context.WithoutCancel(ctx)
+	go func() {
+		slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
+		defer cancel()
+		_, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, summary, modelID)
+		if err != nil {
+			s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
+		} else {
+			go s.notifySubscribers(ctxNoCancel, conversationID)
+		}
+	}()
 
 	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(http.StatusCreated)
 	json.NewEncoder(w).Encode(map[string]interface{}{
-		"status":          "accepted",
+		"status":          "created",
 		"conversation_id": conversationID,
 	})
 }

test/server_test.go 🔗

@@ -1455,5 +1455,73 @@ func TestContinueConversation(t *testing.T) {
 		t.Error("Summary should contain tool name")
 	}
 
+	// Verify that the agent was NOT started - there should only be a user message,
+	// no agent response. The user should be able to add instructions before sending.
+	var hasAgentMessage bool
+	for _, msg := range messages {
+		if msg.Type == string(db.MessageTypeAgent) {
+			hasAgentMessage = true
+			break
+		}
+	}
+	if hasAgentMessage {
+		t.Error("Expected no agent message - the agent should NOT be started automatically")
+	}
+
+	// Verify the status is "created" not "accepted" (since agent wasn't started)
+	if status, ok := result["status"].(string); !ok || status != "created" {
+		t.Errorf("Expected status 'created', got %v", result["status"])
+	}
+
 	t.Logf("Successfully continued conversation from %s to %s", sourceConv.ConversationID, newConversationID)
+
+	// Now test that sending a follow-up message works correctly.
+	// The agent should receive both the summary message AND the new message.
+	followUpReq := map[string]string{
+		"message": "Please focus on the bash commands.",
+		"model":   "predictable",
+	}
+	followUpBody, _ := json.Marshal(followUpReq)
+
+	followUpResp, err := http.Post(testServer.URL+"/api/conversation/"+newConversationID+"/chat", "application/json", bytes.NewBuffer(followUpBody))
+	if err != nil {
+		t.Fatalf("Failed to send follow-up message: %v", err)
+	}
+	defer followUpResp.Body.Close()
+
+	if followUpResp.StatusCode != http.StatusAccepted {
+		bodyBytes, _ := io.ReadAll(followUpResp.Body)
+		t.Fatalf("Expected status 202 for follow-up, got %d: %s", followUpResp.StatusCode, string(bodyBytes))
+	}
+
+	// Wait briefly for the agent to process
+	time.Sleep(200 * time.Millisecond)
+
+	// Verify we now have messages: summary (user), follow-up (user), and agent response
+	updatedMessages, err := database.ListMessages(ctx, newConversationID)
+	if err != nil {
+		t.Fatalf("Failed to list updated messages: %v", err)
+	}
+
+	// Count message types
+	userCount := 0
+	agentCount := 0
+	for _, msg := range updatedMessages {
+		switch msg.Type {
+		case string(db.MessageTypeUser):
+			userCount++
+		case string(db.MessageTypeAgent):
+			agentCount++
+		}
+	}
+
+	// Should have 2 user messages (summary + follow-up) and at least 1 agent response
+	if userCount != 2 {
+		t.Errorf("Expected 2 user messages, got %d", userCount)
+	}
+	if agentCount < 1 {
+		t.Errorf("Expected at least 1 agent message after follow-up, got %d", agentCount)
+	}
+
+	t.Logf("Follow-up message processed: %d user messages, %d agent messages", userCount, agentCount)
 }