diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 71d49966db..8eddb6e9de 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -201,6 +201,11 @@ func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, session return a.mgr.IsAgentRunningForSession(ctx, sessionID) } +// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. +func (a *lifecycleAdapter) IsPassthroughSession(ctx context.Context, sessionID string) bool { + return a.mgr.IsPassthroughSession(ctx, sessionID) +} + // ResolveAgentProfile resolves an agent profile ID to profile information func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { info, err := a.mgr.ResolveAgentProfile(ctx, profileID) diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index a6e2a06e37..2866c14f74 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -2487,6 +2487,15 @@ func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, d return nil } +// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. +func (m *Manager) IsPassthroughSession(ctx context.Context, sessionID string) bool { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return false + } + return execution.PassthroughProcessID != "" +} + // ResizePassthroughPTY resizes the PTY for a passthrough process. // Returns an error if the session is not in passthrough mode or if resizing fails. func (m *Manager) ResizePassthroughPTY(ctx context.Context, sessionID string, cols, rows uint16) error { diff --git a/apps/backend/internal/events/bus/memory_test.go b/apps/backend/internal/events/bus/memory_test.go index b51087cff4..152544af8a 100644 --- a/apps/backend/internal/events/bus/memory_test.go +++ b/apps/backend/internal/events/bus/memory_test.go @@ -5,6 +5,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/kandev/kandev/internal/common/logger" @@ -58,16 +59,12 @@ func TestMemoryEventBus_PublishSubscribe(t *testing.T) { t.Fatalf("Publish failed: %v", err) } - select { - case e := <-received: - if e.ID != event.ID { - t.Errorf("Expected event ID %s, got %s", event.ID, e.ID) - } - if e.Type != event.Type { - t.Errorf("Expected event type %s, got %s", event.Type, e.Type) - } - case <-time.After(time.Second): - t.Fatal("Timeout waiting for event") + e := <-received + if e.ID != event.ID { + t.Errorf("Expected event ID %s, got %s", event.ID, e.ID) + } + if e.Type != event.Type { + t.Errorf("Expected event type %s, got %s", event.Type, e.Type) } } @@ -98,8 +95,6 @@ func TestMemoryEventBus_MultipleSubscribers(t *testing.T) { t.Fatalf("Publish failed: %v", err) } - time.Sleep(100 * time.Millisecond) // Allow goroutines to complete - if atomic.LoadInt32(&count) != 3 { t.Errorf("Expected 3 handlers to be called, got %d", count) } @@ -126,7 +121,6 @@ func TestMemoryEventBus_Unsubscribe(t *testing.T) { if err := bus.Publish(ctx, "test.unsub", event); err != nil { t.Fatalf("Publish failed: %v", err) } - time.Sleep(50 * time.Millisecond) // Unsubscribe if err := sub.Unsubscribe(); err != nil { @@ -140,7 +134,6 @@ func TestMemoryEventBus_Unsubscribe(t *testing.T) { if err := bus.Publish(ctx, "test.unsub", event); err != nil { t.Fatalf("Publish failed: %v", err) } - time.Sleep(50 * time.Millisecond) if atomic.LoadInt32(&count) != 1 { t.Errorf("Expected 1 handler call, got %d", count) @@ -179,8 +172,6 @@ func TestMemoryEventBus_SingleTokenWildcard(t *testing.T) { t.Fatalf("Publish failed: %v", err) } - time.Sleep(100 * time.Millisecond) - if atomic.LoadInt32(&count) != 2 { t.Errorf("Expected 2 events received, got %d", count) } @@ -256,8 +247,6 @@ func TestMemoryEventBus_WildcardNoMatch(t *testing.T) { t.Fatalf("Publish failed: %v", err) } - time.Sleep(100 * time.Millisecond) - if atomic.LoadInt32(&count) != 0 { t.Errorf("Expected 0 events (no match), got %d", count) } @@ -294,8 +283,6 @@ func TestMemoryEventBus_ExactMatch(t *testing.T) { t.Fatalf("Publish failed: %v", err) } - time.Sleep(100 * time.Millisecond) - if atomic.LoadInt32(&count) != 1 { t.Errorf("Expected 1 event, got %d", count) } @@ -337,8 +324,6 @@ func TestMemoryEventBus_QueueSubscribe(t *testing.T) { } } - time.Sleep(100 * time.Millisecond) - // Each event should be handled by exactly one subscriber (round-robin) if atomic.LoadInt32(&count) != 6 { t.Errorf("Expected 6 handler calls, got %d", count) @@ -388,8 +373,6 @@ func TestMemoryEventBus_ConcurrentAccess(t *testing.T) { if publishErrorCount > 0 { t.Errorf("publish errors: %d", publishErrorCount) } - time.Sleep(200 * time.Millisecond) // Allow handlers to complete - expectedCount := int32(numGoroutines * eventsPerGoroutine) if atomic.LoadInt32(&receivedCount) != expectedCount { t.Errorf("Expected %d events, got %d", expectedCount, receivedCount) @@ -476,19 +459,21 @@ func TestMemoryEventBus_Request(t *testing.T) { } func TestMemoryEventBus_RequestTimeout(t *testing.T) { - log := newTestLogger(t) - bus := NewMemoryEventBus(log) - defer bus.Close() + synctest.Test(t, func(t *testing.T) { + log := newTestLogger(t) + bus := NewMemoryEventBus(log) + defer bus.Close() - ctx := context.Background() + ctx := context.Background() - // Make a request with no responder - request := NewEvent("service.nonexistent", "requester", map[string]interface{}{}) + // Make a request with no responder + request := NewEvent("service.nonexistent", "requester", map[string]interface{}{}) - _, err := bus.Request(ctx, "service.nonexistent", request, 100*time.Millisecond) - if err == nil { - t.Error("Expected timeout error") - } + _, err := bus.Request(ctx, "service.nonexistent", request, 100*time.Millisecond) + if err == nil { + t.Error("Expected timeout error") + } + }) } func TestNewEvent(t *testing.T) { diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index 23cd9970b3..011eff57be 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -54,7 +54,7 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) { // Add dedicated terminal WebSocket route if terminal handler is configured if g.TerminalHandler != nil { - router.GET("/xterm.js/:sessionId", g.TerminalHandler.HandleTerminalWS) + router.GET("/terminal/:sessionId", g.TerminalHandler.HandleTerminalWS) } // Add LSP routes if LSP handler is configured diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index 4161e3760c..4af7f918bc 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -139,11 +139,11 @@ func (w *wsWriter) Close() error { return nil } -// HandleTerminalWS handles WebSocket connections at /xterm.js/:sessionId +// HandleTerminalWS handles WebSocket connections at /terminal/:sessionId // This creates a binary WebSocket bridge between xterm.js and the PTY. -// Query parameter terminalId determines the type of terminal: -// - "default" or empty: Agent passthrough terminal (CLI passthrough mode) -// - "shell-1", "shell-2", etc.: Independent user shell terminals +// Query parameter "mode" determines the type of terminal: +// - "agent": Agent passthrough terminal (CLI passthrough mode) +// - "shell": Independent user shell terminal (requires terminalId query param) func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { sessionID := c.Param("sessionId") if sessionID == "" { @@ -151,25 +151,6 @@ func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { return } - terminalID := c.Query("terminalId") - // terminalId is required - "default" is for agent passthrough, anything else is for user shells - // Empty terminalId is rejected to prevent accidental fallback behavior - if terminalID == "" { - h.logger.Warn("terminal WebSocket connection rejected: terminalId query parameter is required (use 'default' for agent passthrough or a unique ID for user shells)", - zap.String("session_id", sessionID), - zap.String("raw_query", c.Request.URL.RawQuery), - zap.String("remote_addr", c.Request.RemoteAddr)) - c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId query parameter is required"}) - return - } - - h.logger.Info("terminal WebSocket connection request", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Bool("is_agent_passthrough", terminalID == "default"), - zap.String("raw_query", c.Request.URL.RawQuery), - zap.String("remote_addr", c.Request.RemoteAddr)) - // Get the interactive runner first interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() if interactiveRunner == nil { @@ -178,14 +159,41 @@ func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { return } - // Route based on terminal ID - if terminalID != "default" { - // User shell terminal - create independent shell process - h.handleUserShellWS(c, sessionID, terminalID, interactiveRunner) - return - } + // Route based on explicit mode parameter + mode := c.Query("mode") + switch mode { + case "agent": + h.logger.Info("terminal WebSocket connection request", + zap.String("session_id", sessionID), + zap.String("mode", "agent"), + zap.String("remote_addr", c.Request.RemoteAddr)) + h.handleAgentPassthroughWS(c, sessionID, interactiveRunner) - // Agent passthrough terminal - existing logic + case "shell": + terminalID := c.Query("terminalId") + if terminalID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId required for shell mode"}) + return + } + h.logger.Info("terminal WebSocket connection request", + zap.String("session_id", sessionID), + zap.String("mode", "shell"), + zap.String("terminal_id", terminalID), + zap.String("remote_addr", c.Request.RemoteAddr)) + h.handleUserShellWS(c, sessionID, terminalID, interactiveRunner) + + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "mode query param required: agent or shell"}) + } +} + +// handleAgentPassthroughWS handles WebSocket connections for agent passthrough terminals. +// It ensures a passthrough execution exists, upgrades to WebSocket, and bridges I/O. +func (h *TerminalHandler) handleAgentPassthroughWS( + c *gin.Context, + sessionID string, + interactiveRunner *process.InteractiveRunner, +) { // Ensure passthrough execution exists and is running. // This handles: // 1. Normal case: execution exists with running process diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index d95f3d41d7..ac4524fabf 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -426,6 +426,10 @@ func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, p }, nil } +func (s *SimulatedAgentManagerClient) IsPassthroughSession(ctx context.Context, sessionID string) bool { + return false +} + // ============================================ // ORCHESTRATOR TEST SERVER // ============================================ @@ -1116,6 +1120,16 @@ func TestOrchestratorStopTask(t *testing.T) { _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) require.NoError(t, err) + // Wait for the session to become active before stopping + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + session, sErr := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) + if sErr == nil && session.State == models.TaskSessionStateRunning { + break + } + time.Sleep(50 * time.Millisecond) + } + // Stop task stopResp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{ "task_id": taskID, @@ -1126,7 +1140,9 @@ func TestOrchestratorStopTask(t *testing.T) { var stopPayload map[string]interface{} require.NoError(t, stopResp.ParsePayload(&stopPayload)) - assert.True(t, stopPayload["success"].(bool)) + success, ok := stopPayload["success"].(bool) + assert.True(t, ok, "expected 'success' key in stop response, got: %v", stopPayload) + assert.True(t, success) } func TestOrchestratorPromptTask(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 5fbd8aa09f..a11b18680c 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -96,6 +96,11 @@ func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEven return } + // Process on_turn_start workflow events (step transitions). + // For ACP sessions this happens in the message handler before PromptTask; + // for passthrough it happens here when the PTY detects user input. + s.processOnTurnStart(ctx, data.TaskID, data.SessionID) + // Move session to running and task to in progress s.setSessionRunning(ctx, data.TaskID, data.SessionID) } @@ -152,85 +157,96 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD zap.Bool("is_queued", queueStatus.IsQueued), zap.Any("message", queueStatus.Message)) - if queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID); exists { - // Skip if the queued message has empty content (might have been cleared accidentally) - if queuedMsg.Content == "" && len(queuedMsg.Attachments) == 0 { - s.logger.Warn("skipping empty queued message", - zap.String("session_id", data.SessionID), - zap.String("queue_id", queuedMsg.ID)) + // Skip queued messages for passthrough sessions — PromptTask uses ACP which + // doesn't work for passthrough, and writing to PTY stdin is unreliable. + // Check before TakeQueued to avoid destructively removing the message. + if s.agentManager.IsPassthroughSession(ctx, data.SessionID) { + s.logger.Info("skipping queued message for passthrough session", + zap.String("session_id", data.SessionID)) + return + } - // Still publish status change to clear frontend state - s.publishQueueStatusEvent(ctx, data.SessionID) - return + queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID) + if !exists { + s.logger.Debug("no queued message to execute", + zap.String("session_id", data.SessionID)) + return + } + + // Skip if the queued message has empty content (might have been cleared accidentally) + if queuedMsg.Content == "" && len(queuedMsg.Attachments) == 0 { + s.logger.Warn("skipping empty queued message", + zap.String("session_id", data.SessionID), + zap.String("queue_id", queuedMsg.ID)) + + // Still publish status change to clear frontend state + s.publishQueueStatusEvent(ctx, data.SessionID) + return + } + + s.logger.Info("auto-executing queued message", + zap.String("session_id", data.SessionID), + zap.String("task_id", queuedMsg.TaskID), + zap.String("queue_id", queuedMsg.ID)) + + // Publish queue status changed event to notify frontend + s.publishQueueStatusEvent(ctx, data.SessionID) + + // Execute the queued message asynchronously + go func() { + promptCtx := context.Background() // Use a fresh context for async execution + + // Create user message for the queued message (so it appears in chat history) + if s.messageCreator != nil { + turnID := s.getActiveTurnID(queuedMsg.SessionID) + if turnID == "" { + // Start a new turn if needed + s.startTurnForSession(promptCtx, queuedMsg.SessionID) + turnID = s.getActiveTurnID(queuedMsg.SessionID) } - s.logger.Info("auto-executing queued message", + // Note: Attachments will be sent to the agent via PromptTask but not stored in the message + // This matches the behavior of direct prompts + meta := NewUserMessageMeta().WithPlanMode(queuedMsg.PlanMode) + err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, meta.ToMap()) + if err != nil { + s.logger.Error("failed to create user message for queued message", + zap.String("session_id", queuedMsg.SessionID), + zap.Error(err)) + // Continue anyway - the prompt should still be sent + } + } + + // Convert queue attachments to v1 attachments + attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments)) + for i, att := range queuedMsg.Attachments { + attachments[i] = v1.MessageAttachment{ + Type: att.Type, + Data: att.Data, + MimeType: att.MimeType, + } + } + + _, err := s.PromptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, + queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments) + if err != nil { + s.logger.Error("failed to execute queued message", zap.String("session_id", data.SessionID), zap.String("task_id", queuedMsg.TaskID), - zap.String("queue_id", queuedMsg.ID)) + zap.String("queue_id", queuedMsg.ID), + zap.Error(err)) - // Publish queue status changed event to notify frontend - s.publishQueueStatusEvent(ctx, data.SessionID) - - // Execute the queued message asynchronously - go func() { - promptCtx := context.Background() // Use a fresh context for async execution - - // Create user message for the queued message (so it appears in chat history) - if s.messageCreator != nil { - turnID := s.getActiveTurnID(queuedMsg.SessionID) - if turnID == "" { - // Start a new turn if needed - s.startTurnForSession(promptCtx, queuedMsg.SessionID) - turnID = s.getActiveTurnID(queuedMsg.SessionID) - } - - // Note: Attachments will be sent to the agent via PromptTask but not stored in the message - // This matches the behavior of direct prompts - meta := NewUserMessageMeta().WithPlanMode(queuedMsg.PlanMode) - err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, meta.ToMap()) - if err != nil { - s.logger.Error("failed to create user message for queued message", - zap.String("session_id", queuedMsg.SessionID), - zap.Error(err)) - // Continue anyway - the prompt should still be sent - } - } - - // Convert queue attachments to v1 attachments - attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments)) - for i, att := range queuedMsg.Attachments { - attachments[i] = v1.MessageAttachment{ - Type: att.Type, - Data: att.Data, - MimeType: att.MimeType, - } - } - - _, err := s.PromptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID, - queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments) - if err != nil { - s.logger.Error("failed to execute queued message", - zap.String("session_id", data.SessionID), - zap.String("task_id", queuedMsg.TaskID), - zap.String("queue_id", queuedMsg.ID), - zap.Error(err)) - - // TODO: Implement dead letter queue for failed queued messages - // Currently, failed messages are lost. Consider: - // 1. Retry mechanism with exponential backoff - // 2. Persist failed messages to database for manual intervention - // 3. Notification to user about failed queue execution - s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)", - zap.String("session_id", data.SessionID), - zap.String("queue_id", queuedMsg.ID), - zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))])) - } - }() - } else { - s.logger.Debug("no queued message to execute", - zap.String("session_id", data.SessionID)) + // TODO: Implement dead letter queue for failed queued messages + // Currently, failed messages are lost. Consider: + // 1. Retry mechanism with exponential backoff + // 2. Persist failed messages to database for manual intervention + // 3. Notification to user about failed queue execution + s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)", + zap.String("session_id", data.SessionID), + zap.String("queue_id", queuedMsg.ID), + zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))])) } + }() } // handleAgentCompleted handles agent completion events @@ -486,6 +502,9 @@ func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID stri // If triggerOnEnter is true, on_enter actions (like auto_start_agent) are processed. // If false, only the step change is applied (used for on_turn_start where the user is about to send a message). func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID string, fromStep *wfmodels.WorkflowStep, toStepID string, triggerOnEnter bool) { + // Process on_exit actions for the step we're leaving (before the step change) + s.processOnExit(ctx, taskID, sessionID, fromStep) + // Get the target step targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID) if err != nil { @@ -571,6 +590,8 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s // processOnEnter processes the on_enter events for a step after transitioning to it. func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep, taskDescription string) { + isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) + // Check if this step enables plan mode hasPlanMode := false for _, action := range step.Events.OnEnter { @@ -580,9 +601,12 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, } } - // Auto-disable plan mode when entering a step that doesn't explicitly enable it - if !hasPlanMode { - s.clearSessionPlanMode(ctx, sessionID) + // Skip plan mode management for passthrough sessions — the CLI manages its own state. + // For ACP sessions, auto-disable plan mode when entering a step that doesn't explicitly enable it. + if !isPassthrough { + if !hasPlanMode { + s.clearSessionPlanMode(ctx, sessionID) + } } if len(step.Events.OnEnter) == 0 { @@ -609,18 +633,23 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, for _, action := range step.Events.OnEnter { switch action.Type { case wfmodels.OnEnterEnablePlanMode: - s.setSessionPlanMode(ctx, sessionID, true) + // Skip plan mode for passthrough — CLI manages its own state + if !isPassthrough { + s.setSessionPlanMode(ctx, sessionID, true) + } case wfmodels.OnEnterAutoStartAgent: hasAutoStart = true } } - if hasAutoStart { + // Skip auto-start for passthrough sessions — stdin is unreliable and the + // process may not be expecting input. Let the user interact directly. + if hasAutoStart && !isPassthrough { // Build prompt from step configuration effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) - // Auto-start the agent + // Auto-start the agent via ACP prompt go func() { bgCtx := context.Background() _, err := s.PromptTask(bgCtx, taskID, sessionID, effectivePrompt, "", planMode, nil) @@ -653,6 +682,28 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, } } +// processOnExit processes the on_exit events for a step when leaving it. +// This is called before transitioning to the next step. Only side-effect actions +// are supported (no transitions — those are decided by on_turn_complete). +func (s *Service) processOnExit(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep) { + if len(step.Events.OnExit) == 0 { + return + } + + // Skip plan mode management for passthrough sessions — the CLI manages its own state. + isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) + + for _, action := range step.Events.OnExit { + if action.Type == wfmodels.OnExitDisablePlanMode && !isPassthrough { + s.clearSessionPlanMode(ctx, sessionID) + s.logger.Debug("on_exit: disabled plan mode", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("step_name", step.Name)) + } + } +} + // clearSessionPlanMode clears plan mode from session metadata. func (s *Service) clearSessionPlanMode(ctx context.Context, sessionID string) { s.setSessionPlanMode(ctx, sessionID, false) @@ -1293,6 +1344,7 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID "new_state": string(nextState), "agent_profile_id": session.AgentProfileID, "agent_profile_snapshot": session.AgentProfileSnapshot, + "is_passthrough": session.IsPassthrough, } // Include review_status and workflow_step_id if present to ensure frontend state consistency if session.ReviewStatus != nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index a2ecf191d5..7dd63faa68 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -83,6 +84,34 @@ func (m *mockTaskRepo) UpdateTaskState(_ context.Context, taskID string, state v return nil } +// mockAgentManager is a minimal mock of executor.AgentManagerClient for testing. +type mockAgentManager struct { + isPassthrough bool +} + +func (m *mockAgentManager) LaunchAgent(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + return nil, nil +} +func (m *mockAgentManager) StartAgentProcess(_ context.Context, _ string) error { return nil } +func (m *mockAgentManager) StopAgent(_ context.Context, _ string, _ bool) error { return nil } +func (m *mockAgentManager) PromptAgent(_ context.Context, _ string, _ string, _ []v1.MessageAttachment) (*executor.PromptResult, error) { + return nil, nil +} +func (m *mockAgentManager) CancelAgent(_ context.Context, _ string) error { return nil } +func (m *mockAgentManager) RespondToPermissionBySessionID(_ context.Context, _, _, _ string, _ bool) error { + return nil +} +func (m *mockAgentManager) IsAgentRunningForSession(_ context.Context, _ string) bool { return false } +func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*executor.AgentProfileInfo, error) { + return nil, nil +} +func (m *mockAgentManager) SetExecutionDescription(_ context.Context, _, _ string) error { + return nil +} +func (m *mockAgentManager) IsPassthroughSession(_ context.Context, _ string) bool { + return m.isPassthrough +} + // --- Helpers --- func testLogger() *logger.Logger { @@ -164,11 +193,16 @@ func seedSession(t *testing.T, repo repository.Repository, taskID, sessionID, wo // createTestService creates a Service with minimal dependencies for event handler testing. func createTestService(repo repository.Repository, stepGetter *mockStepGetter, taskRepo *mockTaskRepo) *Service { + return createTestServiceWithAgent(repo, stepGetter, taskRepo, &mockAgentManager{}) +} + +func createTestServiceWithAgent(repo repository.Repository, stepGetter *mockStepGetter, taskRepo *mockTaskRepo, agentMgr executor.AgentManagerClient) *Service { return &Service{ logger: testLogger(), repo: repo, workflowStepGetter: stepGetter, taskRepo: taskRepo, + agentManager: agentMgr, } } @@ -565,3 +599,141 @@ func TestSetSessionPlanMode(t *testing.T) { }) } +func TestProcessOnExit(t *testing.T) { + ctx := context.Background() + + t.Run("no actions is a no-op", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + step := &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", + Events: wfmodels.StepEvents{}, + } + + // Should not panic or modify anything + svc.processOnExit(ctx, "t1", "s1", step) + }) + + t.Run("disable_plan_mode clears plan mode", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Set plan_mode in session metadata + session, _ := repo.GetTaskSession(ctx, "s1") + session.Metadata = map[string]interface{}{"plan_mode": true} + _ = repo.UpdateTaskSession(ctx, session) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + step := &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", + Events: wfmodels.StepEvents{ + OnExit: []wfmodels.OnExitAction{ + {Type: wfmodels.OnExitDisablePlanMode}, + }, + }, + } + + svc.processOnExit(ctx, "t1", "s1", step) + + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.Metadata != nil { + if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode { + t.Error("expected plan_mode to be cleared from session metadata") + } + } + }) + + t.Run("disable_plan_mode skipped for passthrough session", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Set plan_mode in session metadata + session, _ := repo.GetTaskSession(ctx, "s1") + session.Metadata = map[string]interface{}{"plan_mode": true} + _ = repo.UpdateTaskSession(ctx, session) + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true}) + + step := &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", + Events: wfmodels.StepEvents{ + OnExit: []wfmodels.OnExitAction{ + {Type: wfmodels.OnExitDisablePlanMode}, + }, + }, + } + + svc.processOnExit(ctx, "t1", "s1", step) + + // plan_mode should still be set + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.Metadata == nil { + t.Fatal("expected metadata to still be set") + } + if pm, ok := updated.Metadata["plan_mode"].(bool); !ok || !pm { + t.Error("expected plan_mode to remain true for passthrough session") + } + }) +} + +func TestProcessOnEnterPassthrough(t *testing.T) { + ctx := context.Background() + + t.Run("plan mode not set for passthrough session", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true}) + + step := &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Plan Step", + Events: wfmodels.StepEvents{ + OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterEnablePlanMode}, + }, + }, + } + + svc.processOnEnter(ctx, "t1", "s1", step, "test task") + + session, _ := repo.GetTaskSession(ctx, "s1") + if session.Metadata != nil { + if _, hasPlanMode := session.Metadata["plan_mode"]; hasPlanMode { + t.Error("expected plan_mode NOT to be set for passthrough session") + } + } + }) + + t.Run("plan mode not cleared for passthrough session", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Set plan_mode in session metadata + session, _ := repo.GetTaskSession(ctx, "s1") + session.Metadata = map[string]interface{}{"plan_mode": true} + _ = repo.UpdateTaskSession(ctx, session) + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true}) + + step := &wfmodels.WorkflowStep{ + ID: "step1", Name: "Regular Step", + Events: wfmodels.StepEvents{}, // no enable_plan_mode + } + + svc.processOnEnter(ctx, "t1", "s1", step, "test task") + + // plan_mode should still be set since passthrough sessions skip plan mode management + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.Metadata == nil { + t.Fatal("expected metadata to still be set") + } + if pm, ok := updated.Metadata["plan_mode"].(bool); !ok || !pm { + t.Error("expected plan_mode to remain true for passthrough session") + } + }) +} + diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 3b4b69c2b0..3a369b7277 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -66,6 +66,9 @@ type AgentManagerClient interface { // SetExecutionDescription updates the task description in an existing execution's metadata. // Used when starting an agent on a session whose workspace was already launched. SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error + + // IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. + IsPassthroughSession(ctx context.Context, sessionID string) bool } // AgentProfileInfo contains resolved profile information @@ -356,6 +359,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi // Resolve agent profile to get model and other settings for snapshot var agentProfileSnapshot map[string]interface{} + var isPassthrough bool if profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID); err == nil && profileInfo != nil { agentProfileSnapshot = map[string]interface{}{ "id": profileInfo.ProfileID, @@ -367,6 +371,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, "cli_passthrough": profileInfo.CLIPassthrough, } + isPassthrough = profileInfo.CLIPassthrough } else { agentProfileSnapshot = map[string]interface{}{ "id": agentProfileID, @@ -388,6 +393,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi UpdatedAt: now, AgentProfileSnapshot: agentProfileSnapshot, IsPrimary: true, + IsPassthrough: isPassthrough, } if workflowStepID != "" { session.WorkflowStepID = &workflowStepID diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 8445363861..fc3a3dc567 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -64,6 +64,10 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session return false } +func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool { + return false +} + func (m *mockAgentManager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { if m.resolveAgentProfileFunc != nil { return m.resolveAgentProfileFunc(ctx, profileID) diff --git a/apps/backend/internal/orchestrator/queue/queue_test.go b/apps/backend/internal/orchestrator/queue/queue_test.go index ba6621f18e..c666ecc316 100644 --- a/apps/backend/internal/orchestrator/queue/queue_test.go +++ b/apps/backend/internal/orchestrator/queue/queue_test.go @@ -2,6 +2,7 @@ package queue import ( "testing" + "testing/synctest" "time" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -195,22 +196,25 @@ func TestUnlimitedQueue(t *testing.T) { } func TestFIFOWithSamePriority(t *testing.T) { - q := NewTaskQueue(10) + synctest.Test(t, func(t *testing.T) { + q := NewTaskQueue(10) - // All tasks have same priority - should be FIFO - _ = q.Enqueue(createTestTask("first", 5)) - time.Sleep(1 * time.Millisecond) // Ensure different timestamps - _ = q.Enqueue(createTestTask("second", 5)) - time.Sleep(1 * time.Millisecond) - _ = q.Enqueue(createTestTask("third", 5)) + // All tasks have same priority - should be FIFO + // Using 1s sleeps with synctest fake clock ensures distinct timestamps instantly + _ = q.Enqueue(createTestTask("first", 5)) + time.Sleep(1 * time.Second) + _ = q.Enqueue(createTestTask("second", 5)) + time.Sleep(1 * time.Second) + _ = q.Enqueue(createTestTask("third", 5)) - first := q.Dequeue() - if first.TaskID != "first" { - t.Errorf("expected 'first' with FIFO ordering, got %s", first.TaskID) - } + first := q.Dequeue() + if first.TaskID != "first" { + t.Errorf("expected 'first' with FIFO ordering, got %s", first.TaskID) + } - second := q.Dequeue() - if second.TaskID != "second" { - t.Errorf("expected 'second' with FIFO ordering, got %s", second.TaskID) - } + second := q.Dequeue() + if second.TaskID != "second" { + t.Errorf("expected 'second' with FIFO ordering, got %s", second.TaskID) + } + }) } diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index e6744368d6..031ca9ffcb 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -67,6 +67,10 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session return false } +func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool { + return false +} + func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) error { return nil } diff --git a/apps/backend/internal/orchestrator/watcher/watcher_test.go b/apps/backend/internal/orchestrator/watcher/watcher_test.go index 4898e7be50..458253fa94 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher_test.go +++ b/apps/backend/internal/orchestrator/watcher/watcher_test.go @@ -4,6 +4,7 @@ import ( "context" "sync" "testing" + "testing/synctest" "time" "github.com/kandev/kandev/internal/agent/lifecycle" @@ -202,157 +203,163 @@ func TestStopNotRunning(t *testing.T) { } func TestTaskEventHandling(t *testing.T) { - eventBus := newMockEventBus() + synctest.Test(t, func(t *testing.T) { + eventBus := newMockEventBus() - var receivedData TaskEventData - var received bool - var mu sync.Mutex + var receivedData TaskEventData + var received bool + var mu sync.Mutex - handlers := EventHandlers{ - OnTaskStateChanged: func(ctx context.Context, data TaskEventData) { - mu.Lock() - receivedData = data - received = true - mu.Unlock() - }, - } - log := createTestLogger() + handlers := EventHandlers{ + OnTaskStateChanged: func(ctx context.Context, data TaskEventData) { + mu.Lock() + receivedData = data + received = true + mu.Unlock() + }, + } + log := createTestLogger() - w := NewWatcher(eventBus, handlers, log) - _ = w.Start(context.Background()) - defer func() { - _ = w.Stop() - }() + w := NewWatcher(eventBus, handlers, log) + _ = w.Start(context.Background()) + defer func() { + _ = w.Stop() + }() - // Simulate publishing a task state changed event - oldState := v1.TaskStateTODO - newState := v1.TaskStateInProgress - event := bus.NewEvent(events.TaskStateChanged, "test", map[string]interface{}{ - "task_id": "task-123", - "old_state": string(oldState), - "new_state": string(newState), + // Simulate publishing a task state changed event + oldState := v1.TaskStateTODO + newState := v1.TaskStateInProgress + event := bus.NewEvent(events.TaskStateChanged, "test", map[string]interface{}{ + "task_id": "task-123", + "old_state": string(oldState), + "new_state": string(newState), + }) + + _ = eventBus.Publish(context.Background(), events.TaskStateChanged, event) + + // Wait for goroutines in mock event bus to settle + synctest.Wait() + + mu.Lock() + defer mu.Unlock() + if !received { + t.Error("OnTaskStateChanged handler was not called") + } + if receivedData.TaskID != "task-123" { + t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID) + } }) - - _ = eventBus.Publish(context.Background(), events.TaskStateChanged, event) - - // Wait for handler to be called - time.Sleep(50 * time.Millisecond) - - mu.Lock() - defer mu.Unlock() - if !received { - t.Error("OnTaskStateChanged handler was not called") - } - if receivedData.TaskID != "task-123" { - t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID) - } } func TestAgentEventHandling(t *testing.T) { - eventBus := newMockEventBus() + synctest.Test(t, func(t *testing.T) { + eventBus := newMockEventBus() - var receivedData AgentEventData - var received bool - var mu sync.Mutex + var receivedData AgentEventData + var received bool + var mu sync.Mutex - handlers := EventHandlers{ - OnAgentCompleted: func(ctx context.Context, data AgentEventData) { - mu.Lock() - receivedData = data - received = true - mu.Unlock() - }, - } - log := createTestLogger() + handlers := EventHandlers{ + OnAgentCompleted: func(ctx context.Context, data AgentEventData) { + mu.Lock() + receivedData = data + received = true + mu.Unlock() + }, + } + log := createTestLogger() - w := NewWatcher(eventBus, handlers, log) - _ = w.Start(context.Background()) - defer func() { - _ = w.Stop() - }() + w := NewWatcher(eventBus, handlers, log) + _ = w.Start(context.Background()) + defer func() { + _ = w.Stop() + }() - // Simulate publishing an agent completed event - exitCode := 0 - event := bus.NewEvent(events.AgentCompleted, "test", map[string]interface{}{ - "task_id": "task-123", - "agent_execution_id": "agent-456", - "agent_type": "test-agent", - "exit_code": exitCode, + // Simulate publishing an agent completed event + exitCode := 0 + event := bus.NewEvent(events.AgentCompleted, "test", map[string]interface{}{ + "task_id": "task-123", + "agent_execution_id": "agent-456", + "agent_type": "test-agent", + "exit_code": exitCode, + }) + + _ = eventBus.Publish(context.Background(), events.AgentCompleted, event) + + // Wait for goroutines in mock event bus to settle + synctest.Wait() + + mu.Lock() + defer mu.Unlock() + if !received { + t.Error("OnAgentCompleted handler was not called") + } + if receivedData.TaskID != "task-123" { + t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID) + } + if receivedData.AgentExecutionID != "agent-456" { + t.Errorf("expected agent_execution_id = 'agent-456', got %s", receivedData.AgentExecutionID) + } }) - - _ = eventBus.Publish(context.Background(), events.AgentCompleted, event) - - // Wait for handler to be called - time.Sleep(50 * time.Millisecond) - - mu.Lock() - defer mu.Unlock() - if !received { - t.Error("OnAgentCompleted handler was not called") - } - if receivedData.TaskID != "task-123" { - t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID) - } - if receivedData.AgentExecutionID != "agent-456" { - t.Errorf("expected agent_execution_id = 'agent-456', got %s", receivedData.AgentExecutionID) - } } func TestAgentStreamEventHandling(t *testing.T) { - eventBus := newMockEventBus() + synctest.Test(t, func(t *testing.T) { + eventBus := newMockEventBus() - var receivedPayload *lifecycle.AgentStreamEventPayload - var received bool - var mu sync.Mutex + var receivedPayload *lifecycle.AgentStreamEventPayload + var received bool + var mu sync.Mutex - handlers := EventHandlers{ - OnAgentStreamEvent: func(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - mu.Lock() - receivedPayload = payload - received = true - mu.Unlock() - }, - } - log := createTestLogger() + handlers := EventHandlers{ + OnAgentStreamEvent: func(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + mu.Lock() + receivedPayload = payload + received = true + mu.Unlock() + }, + } + log := createTestLogger() - w := NewWatcher(eventBus, handlers, log) - _ = w.Start(context.Background()) - defer func() { - _ = w.Stop() - }() + w := NewWatcher(eventBus, handlers, log) + _ = w.Start(context.Background()) + defer func() { + _ = w.Stop() + }() - // Simulate publishing an agent stream event - event := bus.NewEvent(events.BuildAgentStreamSubject("session-789"), "test", map[string]interface{}{ - "type": "agent/event", - "task_id": "task-789", - "session_id": "session-789", - "agent_id": "agent-123", - "timestamp": time.Now().Format(time.RFC3339), - "data": map[string]interface{}{ - "type": "tool_call", - "tool_call_id": "tc-1", - "tool_name": "view", - "tool_title": "View file", - "tool_status": "running", - }, + // Simulate publishing an agent stream event + event := bus.NewEvent(events.BuildAgentStreamSubject("session-789"), "test", map[string]interface{}{ + "type": "agent/event", + "task_id": "task-789", + "session_id": "session-789", + "agent_id": "agent-123", + "timestamp": time.Now().Format(time.RFC3339), + "data": map[string]interface{}{ + "type": "tool_call", + "tool_call_id": "tc-1", + "tool_name": "view", + "tool_title": "View file", + "tool_status": "running", + }, + }) + + _ = eventBus.Publish(context.Background(), events.BuildAgentStreamWildcardSubject(), event) + + // Wait for goroutines in mock event bus to settle + synctest.Wait() + + mu.Lock() + defer mu.Unlock() + if !received { + t.Error("OnAgentStreamEvent handler was not called") + } + if receivedPayload == nil { + t.Error("received payload should not be nil") + } + if receivedPayload.TaskID != "task-789" { + t.Errorf("expected task_id = 'task-789', got %s", receivedPayload.TaskID) + } }) - - _ = eventBus.Publish(context.Background(), events.BuildAgentStreamWildcardSubject(), event) - - // Wait for handler to be called - time.Sleep(50 * time.Millisecond) - - mu.Lock() - defer mu.Unlock() - if !received { - t.Error("OnAgentStreamEvent handler was not called") - } - if receivedPayload == nil { - t.Error("received payload should not be nil") - } - if receivedPayload.TaskID != "task-789" { - t.Errorf("expected task_id = 'task-789', got %s", receivedPayload.TaskID) - } } func TestNoHandlersRegistered(t *testing.T) { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 0bf5d0135a..8c6c4a3d4b 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -141,6 +141,7 @@ type TaskSessionDTO struct { UpdatedAt time.Time `json:"updated_at"` // Workflow fields IsPrimary bool `json:"is_primary"` + IsPassthrough bool `json:"is_passthrough"` WorkflowStepID *string `json:"workflow_step_id,omitempty"` ReviewStatus *string `json:"review_status,omitempty"` } @@ -430,6 +431,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { UpdatedAt: session.UpdatedAt, // Workflow fields IsPrimary: session.IsPrimary, + IsPassthrough: session.IsPassthrough, WorkflowStepID: session.WorkflowStepID, ReviewStatus: session.ReviewStatus, } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index c274620c41..fab9ac85b7 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -226,6 +226,7 @@ type TaskSession struct { // Workflow-related fields IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task + IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode WorkflowStepID *string `json:"workflow_step_id,omitempty"` // Current workflow step ID ReviewStatus *string `json:"review_status,omitempty"` // pending, approved } @@ -274,6 +275,7 @@ func (s *TaskSession) ToAPI() map[string]interface{} { if s.RepositorySnapshot != nil { result["repository_snapshot"] = s.RepositorySnapshot } + result["is_passthrough"] = s.IsPassthrough return result } diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 3cea65dc5a..aa148f4888 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -402,18 +402,22 @@ func (r *Repository) initSchema() error { return err } + if err := r.ensureIsPassthroughColumn(); err != nil { + return err + } + return nil } -// ensureArchivedAtColumn adds the archived_at column to tasks if it doesn't exist. -func (r *Repository) ensureArchivedAtColumn() error { - rows, err := r.db.Query("PRAGMA table_info(tasks)") +// ensureColumnExists checks if a column exists on a table and runs the provided +// SQL statements to add it if missing. +func (r *Repository) ensureColumnExists(table, column string, migrationSQL ...string) error { + rows, err := r.db.Query("PRAGMA table_info(" + table + ")") if err != nil { return err } defer func() { _ = rows.Close() }() - var hasColumn bool for rows.Next() { var cid int var name, colType string @@ -423,21 +427,29 @@ func (r *Repository) ensureArchivedAtColumn() error { if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { return err } - if name == "archived_at" { - hasColumn = true - break + if name == column { + return nil } } if err := rows.Err(); err != nil { return err } - if hasColumn { - return nil - } - if _, err := r.db.Exec("ALTER TABLE tasks ADD COLUMN archived_at DATETIME"); err != nil { - return err + for _, stmt := range migrationSQL { + if _, err := r.db.Exec(stmt); err != nil { + return err + } } - _, err = r.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)") - return err + return nil +} + +func (r *Repository) ensureIsPassthroughColumn() error { + return r.ensureColumnExists("task_sessions", "is_passthrough", + "ALTER TABLE task_sessions ADD COLUMN is_passthrough INTEGER DEFAULT 0") +} + +func (r *Repository) ensureArchivedAtColumn() error { + return r.ensureColumnExists("tasks", "archived_at", + "ALTER TABLE tasks ADD COLUMN archived_at DATETIME", + "CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)") } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 15698469b1..357091fe52 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -205,14 +205,15 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_primary, workflow_step_id, review_status, is_passthrough + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, - commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus) + commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, + commonsqlite.BoolToInt(session.IsPassthrough)) return err } @@ -228,6 +229,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int + var isPassthrough int var workflowStepID sql.NullString var reviewStatus sql.NullString @@ -236,7 +238,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE id = ? `, id).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, @@ -244,7 +246,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, ) if err == sql.ErrNoRows { @@ -256,6 +258,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 if workflowStepID.Valid { session.WorkflowStepID = &workflowStepID.String } @@ -311,6 +314,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int + var isPassthrough int var workflowStepID sql.NullString var reviewStatus sql.NullString @@ -319,7 +323,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, @@ -327,7 +331,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, ) if err == sql.ErrNoRows { @@ -339,6 +343,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 if workflowStepID.Valid { session.WorkflowStepID = &workflowStepID.String } @@ -394,6 +399,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int + var isPassthrough int var workflowStepID sql.NullString var reviewStatus sql.NullString @@ -402,7 +408,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC LIMIT 1 @@ -412,7 +418,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, ) if err == sql.ErrNoRows { @@ -424,6 +430,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 if workflowStepID.Valid { session.WorkflowStepID = &workflowStepID.String } @@ -499,13 +506,14 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task repository_id = ?, base_branch = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?, - is_primary = ?, workflow_step_id = ?, review_status = ? + is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ? WHERE id = ? `, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, + commonsqlite.BoolToInt(session.IsPassthrough), session.ID) if err != nil { return err @@ -565,7 +573,7 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC `, taskID) if err != nil { @@ -594,7 +602,7 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC `) if err != nil { @@ -623,7 +631,7 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC `, taskID) if err != nil { @@ -700,6 +708,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int + var isPassthrough int var workflowStepID sql.NullString var reviewStatus sql.NullString @@ -709,7 +718,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, ) if err != nil { return nil, err @@ -717,6 +726,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 if workflowStepID.Valid { session.WorkflowStepID = &workflowStepID.String } @@ -870,6 +880,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin var repositorySnapshotJSON string var completedAt sql.NullTime var isPrimary int + var isPassthrough int var workflowStepID sql.NullString var reviewStatus sql.NullString @@ -878,7 +889,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status + is_primary, workflow_step_id, review_status, is_passthrough FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1 `, taskID).Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, @@ -886,7 +897,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin &session.RepositoryID, &session.BaseBranch, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, + &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, ) if err == sql.ErrNoRows { @@ -898,6 +909,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 + session.IsPassthrough = isPassthrough == 1 if workflowStepID.Valid { session.WorkflowStepID = &workflowStepID.String } diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index 187a826a58..695c770023 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -50,11 +50,25 @@ type OnTurnCompleteAction struct { Config map[string]interface{} `json:"config,omitempty"` } +// OnExitActionType represents the type of action to execute when leaving a step. +type OnExitActionType string + +const ( + OnExitDisablePlanMode OnExitActionType = "disable_plan_mode" +) + +// OnExitAction represents an action to execute when leaving a step. +type OnExitAction struct { + Type OnExitActionType `json:"type"` + Config map[string]interface{} `json:"config,omitempty"` +} + // StepEvents contains event-driven actions for a workflow step. type StepEvents struct { OnEnter []OnEnterAction `json:"on_enter,omitempty"` OnTurnStart []OnTurnStartAction `json:"on_turn_start,omitempty"` OnTurnComplete []OnTurnCompleteAction `json:"on_turn_complete,omitempty"` + OnExit []OnExitAction `json:"on_exit,omitempty"` } // ReviewStatus represents the review state of a session @@ -183,5 +197,6 @@ func RemapStepEvents(events StepEvents, idMap map[string]string) StepEvents { } result.OnTurnComplete = append(result.OnTurnComplete, a) } + result.OnExit = append(result.OnExit, events.OnExit...) return result } diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index b76731f5c3..7606c7a448 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -164,6 +164,7 @@ function AgentSetupForm({ label: `${profile.agent_display_name} • ${profile.name}`, agent_id: agent.id, agent_name: agent.name, + cli_passthrough: profile.cli_passthrough, })) ) ); diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx index 775b476349..b2a8f938b1 100644 --- a/apps/web/components/session-commands.tsx +++ b/apps/web/components/session-commands.tsx @@ -28,9 +28,10 @@ type SessionCommandsProps = { baseBranch?: string; isAgentRunning?: boolean; hasWorktree?: boolean; + isPassthrough?: boolean; }; -export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree }: SessionCommandsProps) { +export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree, isPassthrough }: SessionCommandsProps) { const git = useGitOperations(sessionId); const panels = usePanelActions(); const { openCommitDialog, openPRDialog } = useVcsDialogs(); @@ -77,14 +78,16 @@ export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWork }); } - items.push({ - id: 'session-plan-mode', - label: 'Toggle Plan Mode', - group: 'Session', - icon: , - keywords: ['plan', 'mode', 'toggle'], - action: () => panels.addPlan(), - }); + if (!isPassthrough) { + items.push({ + id: 'session-plan-mode', + label: 'Toggle Plan Mode', + group: 'Session', + icon: , + keywords: ['plan', 'mode', 'toggle'], + action: () => panels.addPlan(), + }); + } // Git — only when worktree is set up if (hasWorktree) { @@ -164,26 +167,30 @@ export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWork keywords: ['terminal', 'shell', 'console'], action: () => panels.addTerminal(), }, - { + ); + + if (!isPassthrough) { + items.push({ id: 'panel-plan', label: 'Add Plan Panel', group: 'Panels', icon: , keywords: ['plan', 'document'], action: () => panels.addPlan(), - }, - { - id: 'panel-changes', - label: 'Add Changes Panel', - group: 'Panels', - icon: , - keywords: ['changes', 'diff', 'git'], - action: () => panels.addChanges(), - }, - ); + }); + } + + items.push({ + id: 'panel-changes', + label: 'Add Changes Panel', + group: 'Panels', + icon: , + keywords: ['changes', 'diff', 'git'], + action: () => panels.addChanges(), + }); return items.map((cmd) => ({ ...cmd, priority: PAGE_PRIORITY })); - }, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, openCommitDialog, openPRDialog, runGitWithFeedback]); + }, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, isPassthrough, openCommitDialog, openPRDialog, runGitWithFeedback]); useRegisterCommands(commands); diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 9cb896e344..bc7ef66c93 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -46,6 +46,7 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passth label: `${agentProfile.agent_display_name} • ${agentProfile.name}`, agent_id: agentItem.id, agent_name: agentItem.name, + cli_passthrough: agentProfile.cli_passthrough, })) ) ); diff --git a/apps/web/components/settings/workflow-pipeline-editor.tsx b/apps/web/components/settings/workflow-pipeline-editor.tsx index 4aa2273e95..c2d5dace4e 100644 --- a/apps/web/components/settings/workflow-pipeline-editor.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor.tsx @@ -44,7 +44,7 @@ import { TooltipProvider, TooltipTrigger, } from '@kandev/ui/tooltip'; -import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction } from '@/lib/types/http'; +import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction, OnExitAction } from '@/lib/types/http'; import { useDebouncedCallback } from '@/hooks/use-debounce'; import { cn } from '@/lib/utils'; @@ -93,6 +93,10 @@ function hasDisablePlanMode(step: WorkflowStep): boolean { return step.events?.on_turn_complete?.some((a) => a.type === 'disable_plan_mode') ?? false; } +function hasOnExitAction(step: WorkflowStep, type: string): boolean { + return step.events?.on_exit?.some((a) => a.type === type) ?? false; +} + function getTransitionLabel(step: WorkflowStep): string { const t = getTransitionType(step); if (t === 'move_to_next') return 'auto'; @@ -368,6 +372,16 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }: onUpdate({ events: { ...currentEvents, on_turn_complete: newOnTurnComplete } }); }; + const toggleOnExitAction = (type: string) => { + const currentEvents = step.events ?? {}; + const onExit = currentEvents.on_exit ?? []; + const exists = onExit.some((a) => a.type === type); + const newOnExit = exists + ? onExit.filter((a) => a.type !== type) + : [...onExit, { type } as OnExitAction]; + onUpdate({ events: { ...currentEvents, on_exit: newOnExit } }); + }; + // Reset local state only when the step identity changes (not on local edits round-tripping) const resetKey = step.id; @@ -657,6 +671,29 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }: )} + + {/* On Exit */} + {hasOnEnterAction(step, 'enable_plan_mode') && ( +
+
+ + +
+
+ { + if (readOnly) return; + toggleOnExitAction('disable_plan_mode'); + }} + disabled={readOnly} + /> + + +
+
+ )} {/* Prompt section */} diff --git a/apps/web/components/step-capability-icons.tsx b/apps/web/components/step-capability-icons.tsx index 6492028f2c..619f6d34ca 100644 --- a/apps/web/components/step-capability-icons.tsx +++ b/apps/web/components/step-capability-icons.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import { IconArrowRight, IconClipboard, + IconDoorExit, IconMessageForward, IconRobot, } from '@tabler/icons-react'; @@ -28,8 +29,9 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil const hasTransition = events?.on_turn_complete?.some((a) => ['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type) ) ?? false; + const hasOnExit = (events?.on_exit?.length ?? 0) > 0; - if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition) { + if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition && !hasOnExit) { return fallback ?
{fallback}
: null; } @@ -75,6 +77,16 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil )} + {hasOnExit && ( + + + + + + On exit actions + + + )} ); } diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 0ff41f9078..7dcd23fcdf 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, FormEvent, memo, useMemo, useCallback } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { IconLoader2, IconGitBranch, IconFileInvoice, IconSend, IconChevronDown } from '@tabler/icons-react'; +import { IconLoader2, IconGitBranch, IconFileInvoice, IconSend, IconChevronDown, IconTerminal2 } from '@tabler/icons-react'; import { Dialog, DialogContent, @@ -294,6 +294,7 @@ type TaskFormInputsProps = { onKeyDown: (e: React.KeyboardEvent) => void; descriptionValueRef: React.RefObject<{ getValue: () => string } | null>; disabled?: boolean; + placeholder?: string; }; const TaskFormInputs = memo(function TaskFormInputs({ @@ -304,6 +305,7 @@ const TaskFormInputs = memo(function TaskFormInputs({ onKeyDown, descriptionValueRef, disabled, + placeholder, }: TaskFormInputsProps) { const [description, setDescription] = useState(initialDescription); const textareaRef = useRef(null); @@ -345,7 +347,7 @@ const TaskFormInputs = memo(function TaskFormInputs({