feat: Add ask_user_question MCP tool for agent clarifications (#123)
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/clarification"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
@@ -339,6 +340,53 @@ func (a *messageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, ses
|
||||
return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status)
|
||||
}
|
||||
|
||||
// CreateClarificationRequestMessage creates a message for a clarification request.
|
||||
// This allows clarification requests to appear in the chat as messages.
|
||||
func (a *messageCreatorAdapter) CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error) {
|
||||
// Convert question options to interface{} for metadata storage
|
||||
options := make([]interface{}, len(question.Options))
|
||||
for j, opt := range question.Options {
|
||||
options[j] = map[string]interface{}{
|
||||
"option_id": opt.ID,
|
||||
"label": opt.Label,
|
||||
"description": opt.Description,
|
||||
}
|
||||
}
|
||||
|
||||
questionData := map[string]interface{}{
|
||||
"id": question.ID,
|
||||
"title": question.Title,
|
||||
"prompt": question.Prompt,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"pending_id": pendingID,
|
||||
"question": questionData,
|
||||
"context": clarificationContext,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
||||
TaskSessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
Content: question.Prompt,
|
||||
AuthorType: "agent",
|
||||
Type: "clarification_request",
|
||||
Metadata: metadata,
|
||||
RequestsInput: true, // This marks the session as waiting for input
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg.ID, nil
|
||||
}
|
||||
|
||||
// UpdateClarificationMessage updates a clarification message's status and response
|
||||
func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answer *clarification.Answer) error {
|
||||
return a.svc.UpdateClarificationMessage(ctx, sessionID, pendingID, status, answer)
|
||||
}
|
||||
|
||||
// CreateAgentMessageStreaming creates a new agent message with a pre-generated ID.
|
||||
// This is used for real-time streaming where content arrives incrementally.
|
||||
func (a *messageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
|
||||
|
||||
@@ -40,6 +40,8 @@ import (
|
||||
promptcontroller "github.com/kandev/kandev/internal/prompts/controller"
|
||||
prompthandlers "github.com/kandev/kandev/internal/prompts/handlers"
|
||||
|
||||
"github.com/kandev/kandev/internal/clarification"
|
||||
|
||||
// Task Service packages
|
||||
taskcontroller "github.com/kandev/kandev/internal/task/controller"
|
||||
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
|
||||
@@ -230,6 +232,11 @@ func main() {
|
||||
if cfg.Agent.McpServerURL == "" {
|
||||
cfg.Agent.McpServerURL = mcpServerURL
|
||||
}
|
||||
// Set MCP server URL on controller and ensure default MCP config for all profiles
|
||||
agentSettingsController.SetMcpServerURL(mcpServerURL)
|
||||
if err := agentSettingsController.EnsureDefaultMcpConfig(ctx); err != nil {
|
||||
log.Warn("Failed to ensure default MCP config", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -266,7 +273,7 @@ func main() {
|
||||
// ============================================
|
||||
log.Info("Initializing Orchestrator...")
|
||||
|
||||
orchestratorSvc, err := provideOrchestrator(log, eventBus, taskRepo, taskSvc, userSvc, lifecycleMgr, agentRegistry, services.Workflow)
|
||||
orchestratorSvc, msgCreator, err := provideOrchestrator(log, eventBus, taskRepo, taskSvc, userSvc, lifecycleMgr, agentRegistry, services.Workflow)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize orchestrator", zap.Error(err))
|
||||
}
|
||||
@@ -551,6 +558,11 @@ func main() {
|
||||
prompthandlers.RegisterRoutes(router, promptCtrl, log)
|
||||
log.Debug("Registered Prompts handlers (HTTP)")
|
||||
|
||||
// Clarification routes for agent questions
|
||||
clarificationStore := clarification.NewStore(10 * time.Minute)
|
||||
clarification.RegisterRoutes(router, clarificationStore, gateway.Hub, msgCreator, taskRepo, log)
|
||||
log.Debug("Registered Clarification handlers (HTTP)")
|
||||
|
||||
// Health check (simple HTTP for load balancers/monitoring)
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -24,9 +24,9 @@ func provideOrchestrator(
|
||||
lifecycleMgr *lifecycle.Manager,
|
||||
agentRegistry *registry.Registry,
|
||||
workflowSvc *workflowservice.Service,
|
||||
) (*orchestrator.Service, error) {
|
||||
) (*orchestrator.Service, *messageCreatorAdapter, error) {
|
||||
if lifecycleMgr == nil {
|
||||
return nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)")
|
||||
return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)")
|
||||
}
|
||||
|
||||
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
|
||||
@@ -46,7 +46,7 @@ func provideOrchestrator(
|
||||
orchestratorSvc.SetWorkflowStepGetter(&orchestratorWorkflowStepGetterAdapter{svc: workflowSvc})
|
||||
}
|
||||
|
||||
return orchestratorSvc, nil
|
||||
return orchestratorSvc, msgCreator, nil
|
||||
}
|
||||
|
||||
// orchestratorWorkflowStepGetterAdapter adapts workflow service to orchestrator's WorkflowStepGetter interface.
|
||||
|
||||
@@ -181,6 +181,15 @@ func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentType
|
||||
return resumePrompt
|
||||
}
|
||||
|
||||
// injectSessionContext prepends session context information to the prompt.
|
||||
// This allows the agent to know which session ID to use when calling MCP tools like ask_user_question.
|
||||
func (sm *SessionManager) injectSessionContext(sessionID, prompt string) string {
|
||||
if sessionID == "" {
|
||||
return prompt
|
||||
}
|
||||
return fmt.Sprintf("<session_context>\nKandev Session ID: %s\nUse this session ID when calling Kandev MCP tools that require a session_id parameter.\n</session_context>\n\n%s", sessionID, prompt)
|
||||
}
|
||||
|
||||
// loadSession loads an existing session via ACP session/load
|
||||
func (sm *SessionManager) loadSession(
|
||||
ctx context.Context,
|
||||
@@ -308,8 +317,11 @@ func (sm *SessionManager) InitializeAndPrompt(
|
||||
// For resumed sessions (fork_session pattern): If we have session history but no new task description,
|
||||
// we still need to send a resume context prompt so the agent knows about the prior conversation.
|
||||
if taskDescription != "" {
|
||||
// Inject session ID context so the agent knows which session to use for MCP tools
|
||||
promptWithSessionContext := sm.injectSessionContext(execution.SessionID, taskDescription)
|
||||
|
||||
// Check if we need to inject resume context (fork_session pattern for ACP agents)
|
||||
effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, taskDescription)
|
||||
effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, promptWithSessionContext)
|
||||
if effectivePrompt != taskDescription {
|
||||
sm.logger.Info("injecting resume context into initial prompt",
|
||||
zap.String("execution_id", execution.ID),
|
||||
|
||||
@@ -37,6 +37,7 @@ type Controller struct {
|
||||
mcpService *mcpconfig.Service
|
||||
modelFetcher *modelfetcher.Fetcher
|
||||
logger *logger.Logger
|
||||
mcpServerURL string // URL of the embedded Kandev MCP server
|
||||
}
|
||||
|
||||
type SessionChecker interface {
|
||||
@@ -56,6 +57,80 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry,
|
||||
}
|
||||
}
|
||||
|
||||
// SetMcpServerURL sets the URL of the embedded Kandev MCP server.
|
||||
// This should be called after the MCP server is started.
|
||||
func (c *Controller) SetMcpServerURL(url string) {
|
||||
c.mcpServerURL = url
|
||||
}
|
||||
|
||||
// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have the
|
||||
// Kandev MCP server configured and enabled by default.
|
||||
func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
|
||||
if c.mcpServerURL == "" {
|
||||
c.logger.Debug("MCP server URL not set, skipping default MCP config")
|
||||
return nil
|
||||
}
|
||||
|
||||
agents, err := c.repo.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, agent := range agents {
|
||||
if !agent.SupportsMCP {
|
||||
continue
|
||||
}
|
||||
|
||||
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, profile := range profiles {
|
||||
// Check if MCP config already exists
|
||||
existingConfig, err := c.repo.GetAgentProfileMcpConfig(ctx, profile.ID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip if config already exists (don't overwrite user settings)
|
||||
if existingConfig != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create default MCP config with Kandev server enabled
|
||||
config := &models.AgentProfileMcpConfig{
|
||||
ProfileID: profile.ID,
|
||||
Enabled: true,
|
||||
Servers: map[string]interface{}{
|
||||
"kandev": map[string]interface{}{
|
||||
"type": "sse",
|
||||
"url": c.mcpServerURL,
|
||||
},
|
||||
},
|
||||
Meta: map[string]interface{}{},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := c.repo.UpsertAgentProfileMcpConfig(ctx, config); err != nil {
|
||||
c.logger.Warn("failed to create default MCP config for profile",
|
||||
zap.String("profile_id", profile.ID),
|
||||
zap.String("profile_name", profile.Name),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
c.logger.Info("created default MCP config for profile",
|
||||
zap.String("profile_id", profile.ID),
|
||||
zap.String("profile_name", profile.Name),
|
||||
zap.String("mcp_server_url", c.mcpServerURL))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) {
|
||||
results, err := c.discovery.Detect(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -106,10 +108,25 @@ func NewOpenCodeAdapter(cfg *Config, log *logger.Logger) *OpenCodeAdapter {
|
||||
}
|
||||
|
||||
// PrepareEnvironment performs protocol-specific setup before the agent process starts.
|
||||
// For OpenCode, we return environment variables for server authentication.
|
||||
// For OpenCode, we write MCP servers to ~/.config/opencode/opencode.json and return
|
||||
// environment variables for server authentication.
|
||||
func (a *OpenCodeAdapter) PrepareEnvironment() (map[string]string, error) {
|
||||
a.logger.Info("PrepareEnvironment called",
|
||||
zap.Int("mcp_server_count", len(a.cfg.McpServers)))
|
||||
for i, srv := range a.cfg.McpServers {
|
||||
a.logger.Info("MCP server config",
|
||||
zap.Int("index", i),
|
||||
zap.String("name", srv.Name),
|
||||
zap.String("url", srv.URL),
|
||||
zap.String("type", srv.Type),
|
||||
zap.String("command", srv.Command))
|
||||
}
|
||||
|
||||
// Write MCP servers to OpenCode config file
|
||||
if err := WriteOpenCodeMcpConfig(a.cfg.McpServers, "", a.logger); err != nil {
|
||||
a.logger.Warn("failed to write OpenCode MCP config", zap.Error(err))
|
||||
// Continue - MCP servers are optional
|
||||
}
|
||||
|
||||
env := map[string]string{
|
||||
"OPENCODE_SERVER_PASSWORD": a.password,
|
||||
@@ -856,3 +873,106 @@ func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID str
|
||||
return a.client.ReplyPermission(ctx, requestID, reply, msg)
|
||||
}
|
||||
|
||||
// WriteOpenCodeMcpConfig writes MCP server configuration to OpenCode's config file.
|
||||
// It merges with existing config, preserving other settings and existing MCP servers.
|
||||
// OpenCode reads MCP servers from ~/.config/opencode/opencode.json at startup time.
|
||||
// This function should be called before starting the OpenCode process.
|
||||
// If homeDir is empty, it uses os.UserHomeDir().
|
||||
func WriteOpenCodeMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logger.Logger) error {
|
||||
if len(mcpServers) == 0 {
|
||||
return nil // Nothing to configure
|
||||
}
|
||||
|
||||
// Determine config directory
|
||||
if homeDir == "" {
|
||||
var err error
|
||||
homeDir, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
}
|
||||
configDir := filepath.Join(homeDir, ".config", "opencode")
|
||||
configPath := filepath.Join(configDir, "opencode.json")
|
||||
|
||||
// Create config directory if it doesn't exist
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create opencode config directory: %w", err)
|
||||
}
|
||||
|
||||
// Read existing config if it exists
|
||||
existingData, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read existing opencode config: %w", err)
|
||||
}
|
||||
|
||||
// Parse existing config into a generic map to preserve all fields
|
||||
var rawConfig map[string]interface{}
|
||||
if len(existingData) > 0 {
|
||||
if err := json.Unmarshal(existingData, &rawConfig); err != nil {
|
||||
if log != nil {
|
||||
log.Warn("failed to parse existing opencode config, will create new",
|
||||
zap.String("path", configPath),
|
||||
zap.Error(err))
|
||||
}
|
||||
rawConfig = make(map[string]interface{})
|
||||
}
|
||||
} else {
|
||||
rawConfig = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Get or create mcp section
|
||||
mcpSection, ok := rawConfig["mcp"].(map[string]interface{})
|
||||
if !ok {
|
||||
mcpSection = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Add/update our MCP servers
|
||||
for _, server := range mcpServers {
|
||||
serverConfig := make(map[string]interface{})
|
||||
|
||||
if server.Type == "sse" || server.Type == "http" || server.Type == "remote" {
|
||||
// Remote transport - use url field
|
||||
serverConfig["type"] = "remote"
|
||||
if server.URL != "" {
|
||||
serverConfig["url"] = server.URL
|
||||
}
|
||||
} else {
|
||||
// Local/stdio transport - use command and args
|
||||
serverConfig["type"] = "local"
|
||||
if server.Command != "" {
|
||||
// OpenCode expects command as an array: ["command", "arg1", "arg2"]
|
||||
cmdArray := []string{server.Command}
|
||||
cmdArray = append(cmdArray, server.Args...)
|
||||
serverConfig["command"] = cmdArray
|
||||
}
|
||||
}
|
||||
|
||||
// Always enable the server
|
||||
serverConfig["enabled"] = true
|
||||
|
||||
mcpSection[server.Name] = serverConfig
|
||||
}
|
||||
|
||||
// Update the mcp section in the config
|
||||
rawConfig["mcp"] = mcpSection
|
||||
|
||||
// Marshal back to JSON with indentation
|
||||
output, err := json.MarshalIndent(rawConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal opencode config: %w", err)
|
||||
}
|
||||
|
||||
// Write config file
|
||||
if err := os.WriteFile(configPath, output, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write opencode config: %w", err)
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
log.Info("wrote opencode MCP config",
|
||||
zap.String("path", configPath),
|
||||
zap.Int("server_count", len(mcpServers)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Package clarification provides types and services for agent clarification requests.
|
||||
package clarification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
wsmsg "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Broadcaster interface for sending WebSocket notifications
|
||||
type Broadcaster interface {
|
||||
BroadcastToSession(sessionID string, msg *wsmsg.Message)
|
||||
}
|
||||
|
||||
// MessageCreator interface for creating messages in the database
|
||||
type MessageCreator interface {
|
||||
// CreateClarificationRequestMessage creates a message for a clarification request
|
||||
CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question Question, clarificationContext string) (string, error)
|
||||
// UpdateClarificationMessage updates a clarification message's status and response
|
||||
UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answer *Answer) error
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handlers for clarification requests.
|
||||
type Handlers struct {
|
||||
store *Store
|
||||
hub Broadcaster
|
||||
messageCreator MessageCreator
|
||||
repo repository.Repository
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHandlers creates new clarification handlers.
|
||||
func NewHandlers(store *Store, hub Broadcaster, messageCreator MessageCreator, repo repository.Repository, log *logger.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
store: store,
|
||||
hub: hub,
|
||||
messageCreator: messageCreator,
|
||||
repo: repo,
|
||||
logger: log.WithFields(zap.String("component", "clarification-handlers")),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers clarification HTTP routes.
|
||||
func RegisterRoutes(router *gin.Engine, store *Store, hub Broadcaster, messageCreator MessageCreator, repo repository.Repository, log *logger.Logger) {
|
||||
h := NewHandlers(store, hub, messageCreator, repo, log)
|
||||
api := router.Group("/api/v1/clarification")
|
||||
api.POST("/request", h.httpCreateRequest)
|
||||
api.GET("/:id", h.httpGetRequest)
|
||||
api.GET("/:id/wait", h.httpWaitForResponse)
|
||||
api.POST("/:id/respond", h.httpRespond)
|
||||
}
|
||||
|
||||
// CreateRequestBody is the request body for creating a clarification request.
|
||||
type CreateRequestBody struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
TaskID string `json:"task_id"`
|
||||
Question Question `json:"question" binding:"required"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
// CreateRequestResponse is the response for creating a clarification request.
|
||||
type CreateRequestResponse struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateRequest(c *gin.Context) {
|
||||
var body CreateRequestBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate question has ID, generate if missing
|
||||
if body.Question.ID == "" {
|
||||
body.Question.ID = "q1"
|
||||
}
|
||||
// Validate options have IDs
|
||||
for j := range body.Question.Options {
|
||||
if body.Question.Options[j].ID == "" {
|
||||
body.Question.Options[j].ID = generateOptionID(0, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the task ID for this session
|
||||
sessionID := body.SessionID
|
||||
taskID := body.TaskID
|
||||
if taskID == "" {
|
||||
session, err := h.repo.GetTaskSession(c.Request.Context(), sessionID)
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to look up session",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
taskID = session.TaskID
|
||||
}
|
||||
}
|
||||
|
||||
req := &Request{
|
||||
SessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
Question: body.Question,
|
||||
Context: body.Context,
|
||||
}
|
||||
|
||||
pendingID := h.store.CreateRequest(req)
|
||||
|
||||
// Create a message in the database for the clarification request.
|
||||
// This triggers the session.message.added WebSocket event which the frontend listens to.
|
||||
if h.messageCreator != nil {
|
||||
_, err := h.messageCreator.CreateClarificationRequestMessage(
|
||||
c.Request.Context(),
|
||||
taskID,
|
||||
sessionID,
|
||||
pendingID,
|
||||
body.Question,
|
||||
body.Context,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create clarification request message",
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
// Don't fail the request - the clarification is still created in the store
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, CreateRequestResponse{PendingID: pendingID})
|
||||
}
|
||||
|
||||
func (h *Handlers) httpGetRequest(c *gin.Context) {
|
||||
pendingID := c.Param("id")
|
||||
|
||||
req, ok := h.store.GetRequest(pendingID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "clarification request not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpWaitForResponse(c *gin.Context) {
|
||||
pendingID := c.Param("id")
|
||||
resp, err := h.store.WaitForResponse(c.Request.Context(), pendingID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// RespondBody is the request body for responding to a clarification request.
|
||||
type RespondBody struct {
|
||||
Answers []Answer `json:"answers"` // Keep as array for frontend compatibility, but only first is used
|
||||
Rejected bool `json:"rejected"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpRespond(c *gin.Context) {
|
||||
pendingID := c.Param("id")
|
||||
|
||||
var body RespondBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the pending request to find the session ID
|
||||
pending, ok := h.store.GetRequest(pendingID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "clarification request not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the single answer (first one if provided)
|
||||
var answer *Answer
|
||||
if len(body.Answers) > 0 {
|
||||
answer = &body.Answers[0]
|
||||
}
|
||||
|
||||
resp := &Response{
|
||||
PendingID: pendingID,
|
||||
Answer: answer,
|
||||
Rejected: body.Rejected,
|
||||
RejectReason: body.RejectReason,
|
||||
RespondedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.store.Respond(pendingID, resp); err != nil {
|
||||
h.logger.Warn("failed to respond to clarification",
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the message in the database with status and answer
|
||||
status := "answered"
|
||||
if body.Rejected {
|
||||
status = "rejected"
|
||||
}
|
||||
if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), pending.SessionID, pendingID, status, answer); err != nil {
|
||||
h.logger.Warn("failed to update clarification message",
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func generateOptionID(questionIndex, optionIndex int) string {
|
||||
return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package clarification provides types and services for agent clarification requests.
|
||||
package clarification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store manages pending clarification requests.
|
||||
// It provides thread-safe storage and notification when responses arrive.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
pending map[string]*PendingClarification
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewStore creates a new clarification store.
|
||||
func NewStore(timeout time.Duration) *Store {
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Minute // Default timeout
|
||||
}
|
||||
return &Store{
|
||||
pending: make(map[string]*PendingClarification),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequest creates a new clarification request and returns its pending ID.
|
||||
// The request will be stored until a response is received or it times out.
|
||||
func (s *Store) CreateRequest(req *Request) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if req.PendingID == "" {
|
||||
req.PendingID = uuid.New().String()
|
||||
}
|
||||
req.CreatedAt = time.Now()
|
||||
|
||||
s.pending[req.PendingID] = &PendingClarification{
|
||||
Request: req,
|
||||
ResponseCh: make(chan *Response, 1),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return req.PendingID
|
||||
}
|
||||
|
||||
// GetRequest returns a pending clarification request by ID.
|
||||
func (s *Store) GetRequest(pendingID string) (*Request, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
pending, ok := s.pending[pendingID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return pending.Request, true
|
||||
}
|
||||
|
||||
// WaitForResponse blocks until a response is received or the context is cancelled.
|
||||
// Returns the response or an error if cancelled/timed out.
|
||||
func (s *Store) WaitForResponse(ctx context.Context, pendingID string) (*Response, error) {
|
||||
s.mu.RLock()
|
||||
pending, ok := s.pending[pendingID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("clarification request not found: %s", pendingID)
|
||||
}
|
||||
|
||||
// Create timeout context
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case resp := <-pending.ResponseCh:
|
||||
// Clean up after receiving response
|
||||
s.mu.Lock()
|
||||
delete(s.pending, pendingID)
|
||||
s.mu.Unlock()
|
||||
return resp, nil
|
||||
case <-timeoutCtx.Done():
|
||||
// Clean up on timeout
|
||||
s.mu.Lock()
|
||||
delete(s.pending, pendingID)
|
||||
s.mu.Unlock()
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("clarification request timed out: %s", pendingID)
|
||||
}
|
||||
}
|
||||
|
||||
// Respond submits a response to a pending clarification request.
|
||||
// Returns an error if the request is not found.
|
||||
func (s *Store) Respond(pendingID string, resp *Response) error {
|
||||
s.mu.RLock()
|
||||
pending, ok := s.pending[pendingID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("clarification request not found: %s", pendingID)
|
||||
}
|
||||
|
||||
resp.PendingID = pendingID
|
||||
resp.RespondedAt = time.Now()
|
||||
|
||||
// Non-blocking send (channel has buffer of 1)
|
||||
select {
|
||||
case pending.ResponseCh <- resp:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("response already submitted for: %s", pendingID)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel cancels a pending clarification request.
|
||||
func (s *Store) Cancel(pendingID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
pending, ok := s.pending[pendingID]
|
||||
if !ok {
|
||||
return fmt.Errorf("clarification request not found: %s", pendingID)
|
||||
}
|
||||
|
||||
// Send a cancelled response
|
||||
select {
|
||||
case pending.ResponseCh <- &Response{
|
||||
PendingID: pendingID,
|
||||
Rejected: true,
|
||||
RejectReason: "cancelled",
|
||||
RespondedAt: time.Now(),
|
||||
}:
|
||||
default:
|
||||
}
|
||||
|
||||
delete(s.pending, pendingID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpired removes expired pending requests.
|
||||
// Returns the number of requests cleaned up.
|
||||
func (s *Store) CleanupExpired() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
count := 0
|
||||
now := time.Now()
|
||||
for id, pending := range s.pending {
|
||||
if now.Sub(pending.CreatedAt) > s.timeout {
|
||||
// Send timeout response
|
||||
select {
|
||||
case pending.ResponseCh <- &Response{
|
||||
PendingID: id,
|
||||
Rejected: true,
|
||||
RejectReason: "expired",
|
||||
RespondedAt: now,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
delete(s.pending, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// ListPending returns all pending clarification requests.
|
||||
func (s *Store) ListPending() []*Request {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
requests := make([]*Request, 0, len(s.pending))
|
||||
for _, pending := range s.pending {
|
||||
requests = append(requests, pending.Request)
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package clarification provides types and services for agent clarification requests.
|
||||
// This allows agents to ask structured questions to users and wait for responses.
|
||||
package clarification
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option represents a single choice option for a question.
|
||||
type Option struct {
|
||||
ID string `json:"option_id"`
|
||||
Label string `json:"label"` // Concise 1-5 words
|
||||
Description string `json:"description"` // Explanation of the option
|
||||
}
|
||||
|
||||
// Question represents a single question with multiple choice options.
|
||||
type Question struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"` // Short label (max 12 chars)
|
||||
Prompt string `json:"prompt"` // Full question text
|
||||
Options []Option `json:"options"` // 2-4 options
|
||||
}
|
||||
|
||||
// Request represents a clarification request from an agent.
|
||||
type Request struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Question Question `json:"question"` // Single question
|
||||
Context string `json:"context,omitempty"` // Optional context for the question
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Answer represents the user's answer to a single question.
|
||||
type Answer struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
SelectedOptions []string `json:"selected_options,omitempty"` // Option IDs
|
||||
CustomText string `json:"custom_text,omitempty"` // Free-text input
|
||||
}
|
||||
|
||||
// Response represents the user's response to a clarification request.
|
||||
type Response struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
Answer *Answer `json:"answer,omitempty"`
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"` // If rejected
|
||||
RespondedAt time.Time `json:"responded_at"`
|
||||
}
|
||||
|
||||
// PendingClarification represents a clarification request waiting for a response.
|
||||
type PendingClarification struct {
|
||||
Request *Request
|
||||
ResponseCh chan *Response
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Status represents the status of a clarification request.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusAnswered Status = "answered"
|
||||
StatusRejected Status = "rejected"
|
||||
StatusExpired Status = "expired"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
@@ -106,7 +107,39 @@ func registerTools(s *server.MCPServer, cfg Config, log *logger.Logger) {
|
||||
updateTaskHandler(cfg, log),
|
||||
)
|
||||
|
||||
log.Info("registered MCP tools", zap.Int("count", 6))
|
||||
// Ask User Question tool
|
||||
s.AddTool(
|
||||
mcp.NewTool("ask_user_question",
|
||||
mcp.WithDescription(
|
||||
"Ask the user a clarifying question when you need more information to proceed. "+
|
||||
"Use this tool when you need to:\n"+
|
||||
"1. Gather user preferences or requirements\n"+
|
||||
"2. Clarify ambiguous instructions\n"+
|
||||
"3. Get decisions on implementation choices\n"+
|
||||
"4. Offer choices about what direction to take\n\n"+
|
||||
"The question includes 2-4 multiple-choice options. Users can also provide custom text input.\n"+
|
||||
"Returns the user's answer.",
|
||||
),
|
||||
mcp.WithString("session_id",
|
||||
mcp.Required(),
|
||||
mcp.Description("The session ID for this clarification request"),
|
||||
),
|
||||
mcp.WithString("prompt",
|
||||
mcp.Required(),
|
||||
mcp.Description("The question to ask the user"),
|
||||
),
|
||||
mcp.WithArray("options",
|
||||
mcp.Required(),
|
||||
mcp.Description("2-4 options for the user to choose from. Each option should have: label (concise 1-5 words) and description (explanation of the option)"),
|
||||
),
|
||||
mcp.WithString("context",
|
||||
mcp.Description("Optional context explaining why this question is being asked"),
|
||||
),
|
||||
),
|
||||
askUserQuestionHandler(cfg, log),
|
||||
)
|
||||
|
||||
log.Info("registered MCP tools", zap.Int("count", 7))
|
||||
}
|
||||
|
||||
func listWorkspacesHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
@@ -311,3 +344,190 @@ func updateTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func askUserQuestionHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
sessionID, err := req.RequireString("session_id")
|
||||
if err != nil || sessionID == "" {
|
||||
return mcp.NewToolResultError("session_id is required - this tool can only be used within a Kandev task session"), nil
|
||||
}
|
||||
|
||||
prompt, err := req.RequireString("prompt")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("prompt is required"), nil
|
||||
}
|
||||
|
||||
args := req.GetArguments()
|
||||
optionsRaw, ok := args["options"]
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("options is required"), nil
|
||||
}
|
||||
|
||||
// Parse options from the raw interface
|
||||
optionsJSON, err := json.Marshal(optionsRaw)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse options: %v", err)), nil
|
||||
}
|
||||
|
||||
var options []map[string]interface{}
|
||||
if err := json.Unmarshal(optionsJSON, &options); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse options: %v", err)), nil
|
||||
}
|
||||
|
||||
if len(options) < 2 || len(options) > 4 {
|
||||
return mcp.NewToolResultError("Must provide 2-4 options"), nil
|
||||
}
|
||||
|
||||
// Generate option IDs to ensure consistency between request and response
|
||||
// The backend also generates these, but we need them here for matching
|
||||
for i := range options {
|
||||
options[i]["option_id"] = fmt.Sprintf("q1_opt%d", i+1)
|
||||
}
|
||||
|
||||
context := req.GetString("context", "")
|
||||
|
||||
// Build the single question
|
||||
question := map[string]interface{}{
|
||||
"id": "q1",
|
||||
"title": "Question",
|
||||
"prompt": prompt,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
// Create the clarification request (backend still expects questions array for now)
|
||||
payload := map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"question": question,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
url := fmt.Sprintf("%s/api/v1/clarification/request", cfg.KandevURL)
|
||||
|
||||
log.Debug("creating clarification request",
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
log.Error("failed to create clarification request", zap.Error(err))
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to create clarification request: %v", err)), nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var errBody json.RawMessage
|
||||
_ = json.NewDecoder(resp.Body).Decode(&errBody)
|
||||
return mcp.NewToolResultError(fmt.Sprintf("API error (%d): %s", resp.StatusCode, string(errBody))), nil
|
||||
}
|
||||
|
||||
var createResp struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
|
||||
}
|
||||
|
||||
// Now wait for the user's response
|
||||
waitURL := fmt.Sprintf("%s/api/v1/clarification/%s/wait", cfg.KandevURL, createResp.PendingID)
|
||||
|
||||
log.Debug("waiting for user response",
|
||||
zap.String("pending_id", createResp.PendingID))
|
||||
|
||||
waitReq, err := http.NewRequestWithContext(ctx, http.MethodGet, waitURL, nil)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to create wait request: %v", err)), nil
|
||||
}
|
||||
|
||||
waitResp, err := http.DefaultClient.Do(waitReq)
|
||||
if err != nil {
|
||||
log.Error("failed to wait for response", zap.Error(err))
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to wait for response: %v", err)), nil
|
||||
}
|
||||
defer func() { _ = waitResp.Body.Close() }()
|
||||
|
||||
if waitResp.StatusCode >= 400 {
|
||||
var errBody json.RawMessage
|
||||
_ = json.NewDecoder(waitResp.Body).Decode(&errBody)
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Wait error (%d): %s", waitResp.StatusCode, string(errBody))), nil
|
||||
}
|
||||
|
||||
var userResponse struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
Answer struct {
|
||||
SelectedOptions []string `json:"selected_options"`
|
||||
CustomText string `json:"custom_text"`
|
||||
} `json:"answer"`
|
||||
Rejected bool `json:"rejected"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
if err := json.NewDecoder(waitResp.Body).Decode(&userResponse); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse user response: %v", err)), nil
|
||||
}
|
||||
|
||||
// Format the response for the agent
|
||||
result := formatClarificationResponse(prompt, options, userResponse.Answer, userResponse.Rejected, userResponse.RejectReason)
|
||||
|
||||
log.Debug("clarification response received",
|
||||
zap.String("pending_id", createResp.PendingID),
|
||||
zap.Bool("rejected", userResponse.Rejected))
|
||||
|
||||
return mcp.NewToolResultText(result), nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatClarificationResponse(prompt string, options []map[string]interface{}, answer struct {
|
||||
SelectedOptions []string `json:"selected_options"`
|
||||
CustomText string `json:"custom_text"`
|
||||
}, rejected bool, rejectReason string) string {
|
||||
if rejected {
|
||||
if rejectReason != "" {
|
||||
return fmt.Sprintf("The user declined to answer and said: %s\n\nProceed with your best judgment based on the available information.", rejectReason)
|
||||
}
|
||||
return "The user declined to answer this question. Proceed with your best judgment based on the available information."
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("# User Response\n\n")
|
||||
result.WriteString(fmt.Sprintf("**Question asked:** %s\n\n", prompt))
|
||||
|
||||
hasAnswer := false
|
||||
|
||||
if len(answer.SelectedOptions) > 0 {
|
||||
// Find the label and description for the selected option
|
||||
for _, opt := range options {
|
||||
optID, _ := opt["option_id"].(string)
|
||||
if optID == "" {
|
||||
optID, _ = opt["id"].(string)
|
||||
}
|
||||
for _, selected := range answer.SelectedOptions {
|
||||
if optID == selected {
|
||||
label, _ := opt["label"].(string)
|
||||
description, _ := opt["description"].(string)
|
||||
result.WriteString(fmt.Sprintf("**User's choice:** %s\n", label))
|
||||
if description != "" {
|
||||
result.WriteString(fmt.Sprintf("**Choice description:** %s\n", description))
|
||||
}
|
||||
hasAnswer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if answer.CustomText != "" {
|
||||
result.WriteString(fmt.Sprintf("\n**Additional user input:** %s\n", answer.CustomText))
|
||||
hasAnswer = true
|
||||
}
|
||||
|
||||
if !hasAnswer {
|
||||
result.WriteString("The user did not provide an answer.\n")
|
||||
}
|
||||
|
||||
result.WriteString("\nProceed with the task based on the user's response above.")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
@@ -108,6 +108,8 @@ const (
|
||||
MessageTypeStatus MessageType = "status"
|
||||
// MessageTypePermissionRequest is for agent permission requests
|
||||
MessageTypePermissionRequest MessageType = "permission_request"
|
||||
// MessageTypeClarificationRequest is for agent clarification questions
|
||||
MessageTypeClarificationRequest MessageType = "clarification_request"
|
||||
// MessageTypeScriptExecution is for setup/cleanup script execution messages
|
||||
MessageTypeScriptExecution MessageType = "script_execution"
|
||||
// MessageTypeThinking is for agent thinking/reasoning content
|
||||
|
||||
@@ -2149,6 +2149,73 @@ func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendin
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClarificationMessage updates a clarification request message's status and response.
|
||||
// It includes retry logic to handle race conditions.
|
||||
// The answers parameter should be a slice of answer objects with question_id, selected_options, and custom_text.
|
||||
func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answers interface{}) error {
|
||||
const maxRetries = 5
|
||||
const retryDelay = 100 * time.Millisecond
|
||||
|
||||
var message *models.Message
|
||||
var err error
|
||||
|
||||
// Retry loop to handle race condition
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
s.logger.Debug("clarification message not found, retrying",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Int("max_retries", maxRetries))
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.logger.Warn("clarification message not found for update after retries",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.Int("retries", maxRetries),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if message.Metadata == nil {
|
||||
message.Metadata = make(map[string]interface{})
|
||||
}
|
||||
message.Metadata["status"] = status
|
||||
if answers != nil {
|
||||
message.Metadata["response"] = answers
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateMessage(ctx, message); err != nil {
|
||||
s.logger.Error("failed to update clarification message",
|
||||
zap.String("message_id", message.ID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Publish message.updated event
|
||||
s.publishMessageEvent(ctx, events.MessageUpdated, message)
|
||||
|
||||
s.logger.Info("clarification message updated",
|
||||
zap.String("message_id", message.ID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.String("status", status))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Turn operations
|
||||
|
||||
// StartTurn creates a new turn for a session and publishes the turn.started event.
|
||||
|
||||
@@ -266,7 +266,7 @@ func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate {
|
||||
TaskState: "IN_PROGRESS",
|
||||
AutoStartAgent: true,
|
||||
PlanMode: true,
|
||||
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan to ONLY this file: `.kandev/plans/{task_id}.md`\nDo not create any other files during this phase - only the plan file above.",
|
||||
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan to ONLY this file: `.kandev/plans/{task_id}.md`\nDo not create any other files during this phase - only the plan file above.",
|
||||
OnCompleteStepID: "review-plan",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
@@ -349,7 +349,7 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp
|
||||
TaskState: "IN_PROGRESS",
|
||||
AutoStartAgent: true,
|
||||
PlanMode: true,
|
||||
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nIMPORTANT: Document your design in ONLY this file: `.kandev/designs/{task_id}.md`\nDo not create any other files during this phase - only the design file above.",
|
||||
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nIMPORTANT: Document your design in ONLY this file: `.kandev/designs/{task_id}.md`\nDo not create any other files during this phase - only the design file above.",
|
||||
OnCompleteStepID: "review",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
|
||||
@@ -26,8 +26,10 @@ import { ModelSelector } from '@/components/task/model-selector';
|
||||
import { RichTextInput, type RichTextInputHandle } from './rich-text-input';
|
||||
import { MentionMenu } from './mention-menu';
|
||||
import { SlashCommandMenu } from './slash-command-menu';
|
||||
import { ClarificationInputOverlay } from './clarification-input-overlay';
|
||||
import { useInlineMention } from '@/hooks/use-inline-mention';
|
||||
import { useInlineSlash } from '@/hooks/use-inline-slash';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
|
||||
type ChatInputContainerProps = {
|
||||
onSubmit: (message: string) => void;
|
||||
@@ -44,6 +46,8 @@ type ChatInputContainerProps = {
|
||||
showApproveButton?: boolean;
|
||||
onApprove?: () => void;
|
||||
placeholder?: string;
|
||||
pendingClarification?: Message | null;
|
||||
onClarificationResolved?: () => void;
|
||||
};
|
||||
|
||||
export function ChatInputContainer({
|
||||
@@ -61,6 +65,8 @@ export function ChatInputContainer({
|
||||
showApproveButton,
|
||||
onApprove,
|
||||
placeholder,
|
||||
pendingClarification,
|
||||
onClarificationResolved,
|
||||
}: ChatInputContainerProps) {
|
||||
// Keep input state local to avoid re-rendering parent on every keystroke
|
||||
const [value, setValue] = useState('');
|
||||
@@ -108,12 +114,14 @@ export function ChatInputContainer({
|
||||
}, [mention.isOpen, slash.isOpen, value, onSubmit]);
|
||||
|
||||
const isDisabled = isStarting || isSending;
|
||||
const hasPendingClarification = pendingClarification && onClarificationResolved;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-border bg-background shadow-md overflow-hidden',
|
||||
planModeEnabled && 'border-primary border-dashed'
|
||||
planModeEnabled && 'border-primary border-dashed',
|
||||
hasPendingClarification && 'border-blue-500/50'
|
||||
)}
|
||||
>
|
||||
{/* Popup menus */}
|
||||
@@ -138,17 +146,27 @@ export function ChatInputContainer({
|
||||
setSelectedIndex={slash.setSelectedIndex}
|
||||
/>
|
||||
|
||||
{/* Input area */}
|
||||
<RichTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
planModeEnabled={planModeEnabled}
|
||||
/>
|
||||
{/* Clarification inline (replaces input area when pending) */}
|
||||
{hasPendingClarification ? (
|
||||
<div style={{ height: '88px', overflow: 'auto' }}>
|
||||
<ClarificationInputOverlay
|
||||
message={pendingClarification}
|
||||
onResolved={onClarificationResolved}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal input area */
|
||||
<RichTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
planModeEnabled={planModeEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Integrated toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-2 border-t border-border">
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { IconX, IconMessageQuestion } from '@tabler/icons-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type { Message, ClarificationRequestMetadata, ClarificationAnswer } from '@/lib/types/http';
|
||||
|
||||
type ClarificationInputOverlayProps = {
|
||||
message: Message;
|
||||
onResolved: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline clarification UI - simple numbered text options like Conductor.
|
||||
*/
|
||||
export function ClarificationInputOverlay({ message, onResolved }: ClarificationInputOverlayProps) {
|
||||
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
|
||||
const [customText, setCustomText] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmitOption = useCallback(async (optionId: string) => {
|
||||
if (!metadata?.pending_id || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const answer: ClarificationAnswer = {
|
||||
question_id: metadata.question.id,
|
||||
selected_options: [optionId],
|
||||
};
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/clarification/${metadata.pending_id}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: [answer], rejected: false }),
|
||||
});
|
||||
if (response.ok) {
|
||||
onResolved();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit clarification response:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [metadata, isSubmitting, onResolved]);
|
||||
|
||||
const handleSubmitCustom = useCallback(async () => {
|
||||
if (!metadata?.pending_id || isSubmitting || !customText.trim()) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const answer: ClarificationAnswer = {
|
||||
question_id: metadata.question.id,
|
||||
selected_options: [],
|
||||
custom_text: customText.trim(),
|
||||
};
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/clarification/${metadata.pending_id}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: [answer], rejected: false }),
|
||||
});
|
||||
if (response.ok) {
|
||||
onResolved();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit clarification response:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [metadata, isSubmitting, customText, onResolved]);
|
||||
|
||||
const handleSkip = useCallback(async () => {
|
||||
if (!metadata?.pending_id || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/clarification/${metadata.pending_id}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: [], rejected: true, reject_reason: 'User skipped' }),
|
||||
});
|
||||
if (response.ok) {
|
||||
onResolved();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to skip clarification:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [metadata, isSubmitting, onResolved]);
|
||||
|
||||
if (!metadata?.question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = metadata.question;
|
||||
|
||||
return (
|
||||
<div className="relative px-3 py-2">
|
||||
{/* Close/skip button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
className="absolute top-2 right-3 text-muted-foreground hover:text-foreground z-10"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="pr-6">
|
||||
{/* Question text */}
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<IconMessageQuestion className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-foreground">{question.prompt}</p>
|
||||
</div>
|
||||
|
||||
{/* Options with bullets - indented to align with question text */}
|
||||
<div className="space-y-0.5 mb-1.5 ml-6">
|
||||
{question.options.map((option) => {
|
||||
const button = (
|
||||
<button
|
||||
key={option.option_id}
|
||||
type="button"
|
||||
onClick={() => handleSubmitOption(option.option_id)}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'flex items-start gap-2 w-full text-left text-xs rounded px-1.5 py-0.5 -ml-1.5 transition-colors',
|
||||
'hover:bg-blue-500/15 hover:text-blue-600 dark:hover:text-blue-400',
|
||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground flex-shrink-0">•</span>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (option.description) {
|
||||
return (
|
||||
<Tooltip key={option.option_id}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
{option.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom input row - indented to align with options */}
|
||||
<div className="flex items-center gap-2 ml-6">
|
||||
<span className="text-muted-foreground flex-shrink-0">•</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type something..."
|
||||
value={customText}
|
||||
onChange={(e) => setCustomText(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customText.trim()) {
|
||||
e.preventDefault();
|
||||
handleSubmitCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ToolCallMessage } from '@/components/task/chat/messages/tool-call-messa
|
||||
import { ThinkingMessage } from '@/components/task/chat/messages/thinking-message';
|
||||
import { TodoMessage } from '@/components/task/chat/messages/todo-message';
|
||||
import { ScriptExecutionMessage } from '@/components/task/chat/messages/script-execution-message';
|
||||
import { ClarificationRequestMessage } from '@/components/task/chat/messages/clarification-request-message';
|
||||
|
||||
type AdapterContext = {
|
||||
isTaskDescription: boolean;
|
||||
@@ -47,6 +48,10 @@ const adapters: MessageAdapter[] = [
|
||||
matches: (comment) => comment.type === 'permission_request',
|
||||
render: (comment) => <PermissionRequestMessage comment={comment} />,
|
||||
},
|
||||
{
|
||||
matches: (comment) => comment.type === 'clarification_request',
|
||||
render: (comment) => <ClarificationRequestMessage comment={comment} />,
|
||||
},
|
||||
{
|
||||
matches: (comment) => comment.type === 'script_execution',
|
||||
render: (comment) => <ScriptExecutionMessage comment={comment} />,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { IconMessageQuestion, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message, ClarificationRequestMetadata } from '@/lib/types/http';
|
||||
|
||||
type ClarificationRequestMessageProps = {
|
||||
comment: Message;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a resolved clarification request in the chat history.
|
||||
* Pending clarifications are shown in the input area instead.
|
||||
*/
|
||||
export function ClarificationRequestMessage({ comment }: ClarificationRequestMessageProps) {
|
||||
const metadata = comment.metadata as ClarificationRequestMetadata | undefined;
|
||||
|
||||
if (!metadata?.question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = metadata.question;
|
||||
const status = metadata.status;
|
||||
const isAnswered = status === 'answered';
|
||||
const isSkipped = status === 'rejected';
|
||||
|
||||
const getStatusIndicator = () => {
|
||||
if (isAnswered) {
|
||||
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
|
||||
}
|
||||
if (isSkipped) {
|
||||
return <IconX className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Get the answer summary for display
|
||||
const getAnswerSummary = () => {
|
||||
const response = metadata.response;
|
||||
if (!response) return 'No selection';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Get selected option labels
|
||||
if (response.selected_options?.length) {
|
||||
for (const optionId of response.selected_options) {
|
||||
const option = question.options.find((o) => o.option_id === optionId);
|
||||
if (option) {
|
||||
parts.push(option.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom text
|
||||
if (response.custom_text) {
|
||||
parts.push(`"${response.custom_text}"`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : 'No selection';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-start gap-3 w-full">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<IconMessageQuestion className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header with question and answer */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{question.prompt}
|
||||
</span>
|
||||
{getStatusIndicator()}
|
||||
{isAnswered && (
|
||||
<span className="text-foreground/80">{getAnswerSummary()}</span>
|
||||
)}
|
||||
{isSkipped && (
|
||||
<span className="text-muted-foreground">Skipped</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,8 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
<SessionPanelContent>
|
||||
{isSingleDiffSelected && selectedDiffContent ? (
|
||||
// Render single file diff (from commit, snapshot, or uncommitted)
|
||||
<div className="space-y-4">
|
||||
// Key includes content length to force re-render when diff updates
|
||||
<div key={`selected-${selectedDiffPath}-${selectedDiffContent.length}`} className="space-y-4">
|
||||
<DiffView
|
||||
data={{
|
||||
hunks: parseDiffIntoHunks(selectedDiffContent, selectedDiffPath!),
|
||||
@@ -276,8 +277,10 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
{uncommittedFiles.map((file) => {
|
||||
if (!file.diff) return null;
|
||||
const fileLang = getFileLang(file.path);
|
||||
// Include diff length in key to force re-render when content changes
|
||||
const diffKey = `uncommitted-${file.path}-${file.diff.length}`;
|
||||
return (
|
||||
<div key={`uncommitted-${file.path}`} className="space-y-2">
|
||||
<div key={diffKey} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{file.path}
|
||||
@@ -312,8 +315,10 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
{committedFiles.map((file) => {
|
||||
if (!file.diff) return null;
|
||||
const fileLang = getFileLang(file.path);
|
||||
// Include diff length in key to force re-render when content changes
|
||||
const diffKey = `committed-${file.path}-${file.diff.length}`;
|
||||
return (
|
||||
<div key={`committed-${file.path}`} className="space-y-2">
|
||||
<div key={diffKey} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="rounded-full text-xs">
|
||||
{file.path}
|
||||
|
||||
@@ -46,13 +46,20 @@ export function TaskChatPanel({
|
||||
const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId);
|
||||
|
||||
// Process messages (filtering, todos, etc.)
|
||||
const { allMessages, permissionsByToolCallId, todoItems, agentMessageCount } = useProcessedMessages(
|
||||
const { allMessages, permissionsByToolCallId, todoItems, agentMessageCount, pendingClarification } = useProcessedMessages(
|
||||
messages,
|
||||
taskId,
|
||||
resolvedSessionId,
|
||||
taskDescription
|
||||
);
|
||||
|
||||
// Track clarification resolved state to trigger re-render after submit
|
||||
const [clarificationKey, setClarificationKey] = useState(0);
|
||||
const handleClarificationResolved = useCallback(() => {
|
||||
// Force re-render which will update messages from backend
|
||||
setClarificationKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Model management
|
||||
const { sessionModel, activeModel } = useSessionModel(
|
||||
resolvedSessionId,
|
||||
@@ -165,6 +172,7 @@ export function TaskChatPanel({
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{todoItems.length > 0 && <TodoSummary todos={todoItems} />}
|
||||
<ChatInputContainer
|
||||
key={clarificationKey}
|
||||
onSubmit={handleSubmit}
|
||||
sessionId={resolvedSessionId}
|
||||
taskId={taskId}
|
||||
@@ -183,6 +191,8 @@ export function TaskChatPanel({
|
||||
? 'Continue working on this task...'
|
||||
: 'Write to submit work to the agent...'
|
||||
}
|
||||
pendingClarification={pendingClarification}
|
||||
onClarificationResolved={handleClarificationResolved}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { CumulativeDiff } from '@/lib/state/slices/session-runtime/types';
|
||||
|
||||
// Local state cache for cumulative diffs (not stored in global store since it's on-demand)
|
||||
const cumulativeDiffCache: Record<string, CumulativeDiff | null> = {};
|
||||
const loadingState: Record<string, boolean> = {};
|
||||
// Version counter to trigger refetch when cache is invalidated
|
||||
const cacheVersion: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Invalidate the cumulative diff cache for a session.
|
||||
* Call this when git status changes (commits, snapshots, etc.)
|
||||
*/
|
||||
export function invalidateCumulativeDiffCache(sessionId: string) {
|
||||
delete cumulativeDiffCache[sessionId];
|
||||
cacheVersion[sessionId] = (cacheVersion[sessionId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch cumulative diff for a session.
|
||||
@@ -18,6 +29,8 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
sessionId ? loadingState[sessionId] ?? false : false
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Track the version we last fetched for
|
||||
const lastFetchedVersionRef = useRef<number | null>(null);
|
||||
|
||||
const fetchCumulativeDiff = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
@@ -38,6 +51,7 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
if (response?.cumulative_diff) {
|
||||
cumulativeDiffCache[sessionId] = response.cumulative_diff;
|
||||
setDiff(response.cumulative_diff);
|
||||
lastFetchedVersionRef.current = cacheVersion[sessionId] ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cumulative diff:', err);
|
||||
@@ -48,21 +62,44 @@ export function useCumulativeDiff(sessionId: string | null) {
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Fetch on mount if not cached
|
||||
// Fetch on mount if not cached, or refetch if cache was invalidated
|
||||
useEffect(() => {
|
||||
if (sessionId && !diff && !loading) {
|
||||
if (!sessionId) return;
|
||||
|
||||
const currentVersion = cacheVersion[sessionId] ?? 0;
|
||||
const cachedDiff = cumulativeDiffCache[sessionId];
|
||||
const needsFetch = !cachedDiff || lastFetchedVersionRef.current !== currentVersion;
|
||||
|
||||
if (needsFetch && !loading) {
|
||||
fetchCumulativeDiff();
|
||||
}
|
||||
}, [sessionId, diff, loading, fetchCumulativeDiff]);
|
||||
}, [sessionId, loading, fetchCumulativeDiff]);
|
||||
|
||||
// Poll for cache invalidation (check every 500ms if cache was invalidated)
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const currentVersion = cacheVersion[sessionId] ?? 0;
|
||||
if (lastFetchedVersionRef.current !== currentVersion && !loadingState[sessionId]) {
|
||||
// Cache was invalidated, clear local state to trigger refetch
|
||||
setDiff(null);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [sessionId]);
|
||||
|
||||
// Update local state when sessionId changes
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
setDiff(cumulativeDiffCache[sessionId] ?? null);
|
||||
setLoading(loadingState[sessionId] ?? false);
|
||||
lastFetchedVersionRef.current = cacheVersion[sessionId] ?? 0;
|
||||
} else {
|
||||
setDiff(null);
|
||||
setLoading(false);
|
||||
lastFetchedVersionRef.current = null;
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import type { Message, ClarificationRequestMetadata } from '@/lib/types/http';
|
||||
|
||||
export function useProcessedMessages(
|
||||
messages: Message[],
|
||||
@@ -35,11 +35,36 @@ export function useProcessedMessages(
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
// Find pending clarification request (the most recent one that is still pending)
|
||||
// This will be rendered in the input area, not in the message list
|
||||
const pendingClarification = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.type === 'clarification_request') {
|
||||
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
|
||||
// A clarification is pending if it has no status or status is 'pending'
|
||||
if (!metadata?.status || metadata.status === 'pending') {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
// Filter to only show chat-relevant types.
|
||||
// Permission requests are only hidden if they have a matching tool_call in the messages
|
||||
// (so they can be displayed inline). Standalone permissions are shown as separate messages.
|
||||
// Pending clarification requests are also hidden (shown in input area instead).
|
||||
const visibleMessages = useMemo(() => {
|
||||
return messages.filter((message) => {
|
||||
// Hide pending clarification request (it's shown in input area)
|
||||
if (message.type === 'clarification_request') {
|
||||
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
|
||||
const isPending = !metadata?.status || metadata.status === 'pending';
|
||||
// Only show clarification requests that are resolved (not pending)
|
||||
return !isPending;
|
||||
}
|
||||
|
||||
// Standard visible types
|
||||
if (
|
||||
!message.type ||
|
||||
@@ -112,5 +137,6 @@ export function useProcessedMessages(
|
||||
permissionsByToolCallId,
|
||||
todoItems,
|
||||
agentMessageCount,
|
||||
pendingClarification,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ export type MessageType =
|
||||
| 'thinking'
|
||||
| 'todo'
|
||||
| 'permission_request'
|
||||
| 'clarification_request'
|
||||
| 'script_execution';
|
||||
|
||||
export type Message = {
|
||||
@@ -625,3 +626,40 @@ export type ListAvailableAgentsResponse = {
|
||||
agents: AvailableAgent[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
// Clarification request types (for ask_user_question feature)
|
||||
export type ClarificationOption = {
|
||||
option_id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ClarificationQuestion = {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
options: ClarificationOption[];
|
||||
};
|
||||
|
||||
export type ClarificationRequestMetadata = {
|
||||
pending_id: string;
|
||||
session_id: string;
|
||||
task_id?: string;
|
||||
question: ClarificationQuestion;
|
||||
context?: string;
|
||||
status?: 'pending' | 'answered' | 'rejected' | 'expired';
|
||||
response?: ClarificationAnswer;
|
||||
};
|
||||
|
||||
export type ClarificationAnswer = {
|
||||
question_id: string;
|
||||
selected_options?: string[];
|
||||
custom_text?: string;
|
||||
};
|
||||
|
||||
export type ClarificationResponse = {
|
||||
pending_id: string;
|
||||
answers: ClarificationAnswer[];
|
||||
rejected?: boolean;
|
||||
reject_reason?: string;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
GitCommitsResetEvent,
|
||||
GitSnapshotCreatedEvent,
|
||||
} from '@/lib/types/git-events';
|
||||
import { invalidateCumulativeDiffCache } from '@/hooks/domains/session/use-cumulative-diff';
|
||||
|
||||
// Handler functions for each event type
|
||||
type GitEventHandlers = {
|
||||
@@ -32,6 +33,8 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
files: event.status.files,
|
||||
timestamp: event.timestamp,
|
||||
});
|
||||
// Invalidate cumulative diff cache when files change
|
||||
invalidateCumulativeDiffCache(event.session_id);
|
||||
},
|
||||
|
||||
commit_created: (store, event) => {
|
||||
@@ -49,11 +52,15 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
committed_at: event.commit.committed_at,
|
||||
created_at: event.commit.created_at ?? event.timestamp,
|
||||
});
|
||||
// Invalidate cumulative diff cache when new commit is created
|
||||
invalidateCumulativeDiffCache(event.session_id);
|
||||
},
|
||||
|
||||
commits_reset: (store, event) => {
|
||||
// Clear commits to trigger refetch in useSessionCommits hook
|
||||
store.getState().clearSessionCommits(event.session_id);
|
||||
// Invalidate cumulative diff cache when commits are reset
|
||||
invalidateCumulativeDiffCache(event.session_id);
|
||||
},
|
||||
|
||||
snapshot_created: (store, event) => {
|
||||
@@ -71,6 +78,8 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
triggered_by: event.snapshot.triggered_by,
|
||||
created_at: event.snapshot.created_at,
|
||||
});
|
||||
// Invalidate cumulative diff cache when snapshot is created
|
||||
invalidateCumulativeDiffCache(event.session_id);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user