feat: improved passthrough and workflow (#237)

This commit is contained in:
Carlos Florêncio
2026-02-17 12:32:02 +00:00
committed by GitHub
parent 82ea7578dc
commit 2b05ed0735
38 changed files with 829 additions and 382 deletions
+5
View File
@@ -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)
@@ -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 {
+19 -34
View File
@@ -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) {
@@ -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
@@ -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
@@ -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) {
@@ -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 {
@@ -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")
}
})
}
@@ -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
@@ -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)
@@ -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)
}
})
}
@@ -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
}
@@ -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) {
+2
View File
@@ -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,
}
@@ -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
}
@@ -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, &notNull, &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)")
}
@@ -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
}
@@ -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
}
@@ -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,
}))
)
);
+28 -21
View File
@@ -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: <IconFileTextSpark className="size-3.5" />,
keywords: ['plan', 'mode', 'toggle'],
action: () => panels.addPlan(),
});
if (!isPassthrough) {
items.push({
id: 'session-plan-mode',
label: 'Toggle Plan Mode',
group: 'Session',
icon: <IconFileTextSpark className="size-3.5" />,
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: <IconFileText className="size-3.5" />,
keywords: ['plan', 'document'],
action: () => panels.addPlan(),
},
{
id: 'panel-changes',
label: 'Add Changes Panel',
group: 'Panels',
icon: <IconFileDiff className="size-3.5" />,
keywords: ['changes', 'diff', 'git'],
action: () => panels.addChanges(),
},
);
});
}
items.push({
id: 'panel-changes',
label: 'Add Changes Panel',
group: 'Panels',
icon: <IconFileDiff className="size-3.5" />,
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);
@@ -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,
}))
)
);
@@ -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 }:
)}
</div>
</div>
{/* On Exit */}
{hasOnEnterAction(step, 'enable_plan_mode') && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">On Exit</Label>
<HelpTip text="Runs when leaving this step (before entering the next step)." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-exit-disable-plan`}
checked={hasOnExitAction(step, 'disable_plan_mode')}
onCheckedChange={() => {
if (readOnly) return;
toggleOnExitAction('disable_plan_mode');
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-exit-disable-plan`} className="text-sm">Disable plan mode</Label>
<HelpTip text="Turn off plan mode when leaving this step." />
</div>
</div>
)}
</div>
{/* Prompt section */}
+13 -1
View File
@@ -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 ? <div className={className ?? 'flex items-center gap-1.5 text-muted-foreground'}>{fallback}</div> : null;
}
@@ -75,6 +77,16 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil
</Tooltip>
</TooltipProvider>
)}
{hasOnExit && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconDoorExit className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>On exit actions</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
+37 -15
View File
@@ -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<HTMLTextAreaElement>(null);
@@ -345,7 +347,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
<div>
<Textarea
ref={textareaRef}
placeholder={isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...'}
placeholder={placeholder ?? (isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...')}
value={description}
onChange={handleDescriptionChange}
onKeyDown={onKeyDown}
@@ -431,6 +433,13 @@ export function TaskCreateDialog({
const agentProfilesLoading = open && !settingsData.agentsLoaded;
const executorsLoading = open && !settingsData.executorsLoaded;
// Detect passthrough profile to disable prompt textarea
const isPassthroughProfile = useMemo(() => {
if (!agentProfileId) return false;
const profile = agentProfiles.find((p: AgentProfileOption) => p.id === agentProfileId);
return profile?.cli_passthrough === true;
}, [agentProfileId, agentProfiles]);
// Effective workflow/steps: use fetched steps when a different workflow is selected
const effectiveWorkflowId = selectedWorkflowId ?? workflowId;
const effectiveDefaultStepId = (selectedWorkflowId && selectedWorkflowId !== workflowId && fetchedSteps)
@@ -680,6 +689,7 @@ export function TaskCreateDialog({
const parts = profile.label.split(' • ');
const agentLabel = parts[0] ?? profile.label;
const profileLabel = parts[1] ?? '';
const isPassthrough = profile.cli_passthrough === true;
return {
value: profile.id,
label: profile.label,
@@ -689,11 +699,14 @@ export function TaskCreateDialog({
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
<span>{agentLabel}</span>
</span>
{profileLabel ? (
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
{profileLabel}
</ScrollOnOverflow>
) : null}
<span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && <IconTerminal2 className="size-3.5 text-muted-foreground" title="Passthrough terminal" />}
{profileLabel ? (
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
{profileLabel}
</ScrollOnOverflow>
) : null}
</span>
</span>
),
};
@@ -836,7 +849,8 @@ export function TaskCreateDialog({
const handleSessionSubmit = useCallback(async () => {
const description = descriptionInputRef.current?.getValue() ?? '';
const trimmedDescription = description.trim();
if (!trimmedDescription || !agentProfileId) return;
if (!agentProfileId) return;
if (!trimmedDescription && !isPassthroughProfile) return;
// Legacy mode: delegate to parent
if (onCreateSession) {
@@ -871,7 +885,7 @@ export function TaskCreateDialog({
} finally {
setIsCreatingSession(false);
}
}, [agentProfileId, environmentId, executorId, onCreateSession, onOpenChange, resetForm, router, taskId, toast]);
}, [agentProfileId, environmentId, executorId, isPassthroughProfile, onCreateSession, onOpenChange, resetForm, router, taskId, toast]);
const performTaskUpdate = useCallback(async () => {
if (!editingTask) return null;
@@ -958,7 +972,7 @@ export function TaskCreateDialog({
setIsCreatingTask(true);
try {
if (trimmedDescription) {
if (trimmedDescription || isPassthroughProfile) {
// "Start" mode: create task + start agent in background, stay on kanban
// Backend resolves the start step automatically
const taskResponse = await createTask({
@@ -975,7 +989,11 @@ export function TaskCreateDialog({
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
onSuccess?.(taskResponse, 'create', { taskSessionId: newSessionId });
resetForm();
onOpenChange(false);
if (isPassthroughProfile && newSessionId) {
router.push(linkToSession(newSessionId));
} else {
onOpenChange(false);
}
} else {
// "Plan" mode: create task + session only (no agent), navigate to session
// Backend resolves the start step automatically
@@ -1019,7 +1037,7 @@ export function TaskCreateDialog({
}
}, [
taskName, workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, agentProfileId, executorId,
getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
isPassthroughProfile, getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
setActiveDocument, setPlanMode, router, toast,
]);
@@ -1137,8 +1155,12 @@ export function TaskCreateDialog({
onDescriptionChange={setHasDescription}
onKeyDown={handleKeyDown}
descriptionValueRef={descriptionInputRef}
disabled={isTaskStarted}
disabled={isTaskStarted || isPassthroughProfile}
placeholder={isPassthroughProfile ? 'Sending a prompt is not supported in passthrough mode' : undefined}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt will be ignored — passthrough sessions don&apos;t support sending a prompt on start.</p>
)}
{!isSessionMode && !isTaskStarted && (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
<div>
@@ -1268,7 +1290,7 @@ export function TaskCreateDialog({
'Update'
)}
</Button>
) : (isCreateMode && hasDescription) || (isEditMode && agentProfileId) ? (
) : (isCreateMode && (hasDescription || isPassthroughProfile)) || (isEditMode && agentProfileId) ? (
<div className="inline-flex rounded-md border border-border overflow-hidden sm:h-7 h-10">
<Button
type="submit"
@@ -1309,7 +1331,7 @@ export function TaskCreateDialog({
isCreatingSession ||
isCreatingTask ||
(isSessionMode
? !hasDescription || !agentProfileId
? (!hasDescription && !isPassthroughProfile) || !agentProfileId
: !hasTitle || (!repositoryId && !selectedLocalRepo) || !branch)
}
>
@@ -54,6 +54,8 @@ import { ChangesPanel } from './changes-panel';
import { FilesPanel } from './files-panel';
import { TaskPlanPanel } from './task-plan-panel';
import { FileEditorPanel } from './file-editor-panel';
import { PassthroughTerminal } from './passthrough-terminal';
import { PanelRoot, PanelBody } from './panel-primitives';
import { TerminalPanel } from './terminal-panel';
import { BrowserPanel } from './browser-panel';
import { CommitDetailPanel } from './commit-detail-panel';
@@ -116,6 +118,25 @@ function ChatPanel(props: IDockviewPanelProps) {
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
const { openFile } = useFileEditors();
const isPassthrough = useAppStore((state) => {
if (!sessionId) return false;
return state.taskSessions.items[sessionId]?.is_passthrough === true;
});
useEffect(() => {
props.api.setTitle('Agent');
}, [props.api]);
if (isPassthrough) {
return (
<PanelRoot>
<PanelBody padding={false} scroll={false}>
<PassthroughTerminal sessionId={sessionId} mode="agent" />
</PanelBody>
</PanelRoot>
);
}
return (
<TaskChatPanel
sessionId={sessionId}
@@ -237,6 +258,10 @@ function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const isPassthrough = useAppStore((state) => {
if (!activeSessionId) return false;
return state.taskSessions.items[activeSessionId]?.is_passthrough === true;
});
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
@@ -284,10 +309,12 @@ function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
<IconDeviceDesktop className="h-3.5 w-3.5 mr-1.5" />
Browser
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addPlanPanel(group.id)} className="cursor-pointer text-xs">
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
Plan
</DropdownMenuItem>
{!isPassthrough && (
<DropdownMenuItem onClick={() => addPlanPanel(group.id)} className="cursor-pointer text-xs">
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
Plan
</DropdownMenuItem>
)}
{!hasChanges && (
<DropdownMenuItem onClick={() => addChangesPanel(group.id)} className="cursor-pointer text-xs">
<IconGitBranch className="h-3.5 w-3.5 mr-1.5" />
@@ -696,7 +723,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
id: 'chat',
component: 'chat',
tabComponent: 'permanentTab',
title: 'Chat',
title: 'Agent',
position: sidebarPanel
? { direction: 'right', referencePanel: 'sidebar' }
: undefined,
@@ -101,7 +101,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
{activeTaskId ? (
isPassthroughMode ? (
<div className="flex-1 min-h-0">
<PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} terminalId="default" />
<PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} mode="agent" />
</div>
) : (
<TaskChatPanel
@@ -13,11 +13,12 @@ import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl
import { getBackendConfig } from '@/lib/config';
import { getTerminalTheme } from '@/lib/terminal-theme';
type PassthroughTerminalProps = {
type BaseProps = {
sessionId?: string | null;
terminalId?: string; // Terminal tab identifier (e.g., "shell-1", "shell-2")
label?: string; // Label for plain shell terminals (sent to backend for persistence)
};
type AgentTerminalProps = BaseProps & { mode: 'agent'; label?: string };
type ShellTerminalProps = BaseProps & { mode: 'shell'; terminalId: string; label?: string };
type PassthroughTerminalProps = AgentTerminalProps | ShellTerminalProps;
// Debug flag - set to true to see detailed logs
const DEBUG = false;
@@ -33,15 +34,17 @@ const MIN_HEIGHT = 100;
* PassthroughTerminal provides direct terminal interaction with an agent CLI.
*
* Design: Dedicated Binary WebSocket + AttachAddon
* - Uses a dedicated WebSocket connection to /xterm.js/:sessionId
* - Uses a dedicated WebSocket connection to /terminal/:sessionId
* - Raw binary frames bypass JSON encoding/decoding latency
* - AttachAddon (official xterm.js addon) handles the bridging
* - Unicode11Addon enables proper unicode character support
* - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}]
*/
export function PassthroughTerminal({ sessionId: propSessionId, terminalId, label }: PassthroughTerminalProps) {
export function PassthroughTerminal(props: PassthroughTerminalProps) {
const { sessionId: propSessionId, mode, label } = props;
const terminalId = mode === 'shell' ? props.terminalId : undefined;
// Debug: log props on every render
log('Render - props:', { propSessionId, terminalId, label });
log('Render - props:', { propSessionId, mode, terminalId, label });
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
@@ -313,7 +316,7 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
// Connect WebSocket when ready
useEffect(() => {
log('WebSocket effect:', { taskId, sessionId, terminalId, canConnect, hasTerminal: !!xtermRef.current });
log('WebSocket effect:', { taskId, sessionId, mode, terminalId, canConnect, hasTerminal: !!xtermRef.current });
if (!taskId || !sessionId || !canConnect) {
log('WebSocket effect: early return - missing requirements', { taskId, sessionId, canConnect });
@@ -343,18 +346,17 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
wsRef.current = null;
}
// Build WebSocket URL - terminalId is required for user shells
// If terminalId is not provided, this is an error for user shell terminals
if (!terminalId) {
log('ERROR: terminalId is required but was not provided!', { sessionId, terminalId });
return;
// Build WebSocket URL based on mode
let wsUrl: string;
if (mode === 'agent') {
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=agent`;
} else {
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`;
}
let wsUrl = `${wsBaseUrl}/xterm.js/${sessionId}?terminalId=${encodeURIComponent(terminalId)}`;
if (label) {
// For plain shell terminals, send the label for persistence
wsUrl += `&label=${encodeURIComponent(label)}`;
}
log('Connecting to', wsUrl, { terminalId, label });
log('Connecting to', wsUrl, { mode, terminalId, label });
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
@@ -427,7 +429,7 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
wsRef.current = null;
}
};
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, terminalId, label]);
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, mode, terminalId, label]);
return (
<div
@@ -596,7 +596,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
{activeTaskId ? (
isPassthroughMode ? (
<div className="flex-1 min-h-0 h-full" style={{ minHeight: '150px' }}>
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} terminalId="default" />
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} mode="agent" />
</div>
) : (
<TaskChatPanel
@@ -137,6 +137,9 @@ export function TaskPageContent({
const storeSessionState = useAppStore((state) =>
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.state ?? null : null
);
const isSessionPassthrough = useAppStore((state) =>
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.is_passthrough === true : false
);
const agentctlStatus = useSessionAgentctl(effectiveSessionId);
const previewOpen = useAppStore((state) =>
effectiveSessionId ? state.previewPanel.openBySessionId[effectiveSessionId] ?? false : false
@@ -247,6 +250,7 @@ export function TaskPageContent({
baseBranch={task?.repositories?.[0]?.base_branch}
isAgentRunning={isAgentWorking}
hasWorktree={Boolean(worktreeBranch)}
isPassthrough={isSessionPassthrough}
/>
{DEBUG_UI && showDebugOverlay && (
<DebugOverlay
@@ -216,6 +216,7 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
<PassthroughTerminal
key={terminal.id}
sessionId={sessionId ?? undefined}
mode="shell"
terminalId={terminal.id}
label={terminal.type === 'shell' ? terminal.label : undefined}
/>
@@ -50,6 +50,7 @@ export const TerminalPanel = memo(function TerminalPanel(
<PanelBody padding={false} scroll={false}>
<PassthroughTerminal
sessionId={sessionId ?? undefined}
mode="shell"
terminalId={terminalId}
/>
</PanelBody>
@@ -53,6 +53,7 @@ export function useSettingsData(enabled = true) {
label: `${profile.agent_display_name} • ${profile.name}`,
agent_id: agent.id,
agent_name: agent.name,
cli_passthrough: profile.cli_passthrough,
}))
)
);
+2 -2
View File
@@ -267,7 +267,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
id: 'chat',
component: 'chat',
tabComponent: 'permanentTab',
title: 'Chat',
title: 'Agent',
});
// Sidebar panel — split off to the left
@@ -340,7 +340,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
id: 'chat',
component: 'chat',
tabComponent: 'permanentTab',
title: 'Chat',
title: 'Agent',
position: { referenceGroup: centerGroupId },
});
},
@@ -4,6 +4,7 @@ export type KanbanStepEvents = {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
};
export type KanbanState = {
@@ -36,6 +36,7 @@ export type AgentProfileOption = {
label: string;
agent_id: string;
agent_name: string;
cli_passthrough?: boolean;
};
export type AgentProfilesState = {
+1
View File
@@ -176,6 +176,7 @@ export type TaskSessionStateChangedPayload = {
agent_profile_id?: string;
agent_profile_snapshot?: Record<string, unknown>;
metadata?: Record<string, unknown>;
is_passthrough?: boolean;
// Workflow-related fields (sent during workflow transitions)
review_status?: string;
workflow_step_id?: string;
+10
View File
@@ -19,6 +19,9 @@ export type OnTurnStartActionType = 'move_to_next' | 'move_to_previous' | 'move_
// On Turn Complete action types
export type OnTurnCompleteActionType = 'move_to_next' | 'move_to_previous' | 'move_to_step' | 'disable_plan_mode';
// On Exit action types
export type OnExitActionType = 'disable_plan_mode';
export type OnEnterAction = {
type: OnEnterActionType;
config?: Record<string, unknown>;
@@ -34,10 +37,16 @@ export type OnTurnCompleteAction = {
config?: Record<string, unknown>;
};
export type OnExitAction = {
type: OnExitActionType;
config?: Record<string, unknown>;
};
export type StepEvents = {
on_enter?: OnEnterAction[];
on_turn_start?: OnTurnStartAction[];
on_turn_complete?: OnTurnCompleteAction[];
on_exit?: OnExitAction[];
};
// Workflow Review Status
@@ -278,6 +287,7 @@ export type TaskSession = {
updated_at: string;
// Workflow fields
is_primary?: boolean;
is_passthrough?: boolean;
workflow_step_id?: string;
review_status?: WorkflowReviewStatus;
};
@@ -41,12 +41,15 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
const agentProfileId = payload.agent_profile_id;
const agentProfileSnapshot = payload.agent_profile_snapshot as Record<string, unknown> | undefined;
const isPassthrough = payload.is_passthrough as boolean | undefined;
// Build the update object with all possible fields
const sessionUpdate: Record<string, unknown> = {};
if (newState) sessionUpdate.state = newState;
if (reviewStatus !== undefined) sessionUpdate.review_status = reviewStatus;
if (workflowStepId !== undefined) sessionUpdate.workflow_step_id = workflowStepId;
if (agentProfileSnapshot) sessionUpdate.agent_profile_snapshot = agentProfileSnapshot;
if (isPassthrough !== undefined) sessionUpdate.is_passthrough = isPassthrough;
if (existingSession) {
// Update existing session - preserve all existing fields