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: