From 22c0fd2a57f0e38396bd1ec3f4fcafdcfa053fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 2 Mar 2026 14:29:34 +0000 Subject: [PATCH] fix: improve claude plan mode reliability and cleanup (#346) --- .../adapter/transport/streamjson/adapter.go | 12 + .../transport/streamjson/adapter_test.go | 78 ++-- .../streamjson/exit_plan_mode_test.go | 337 ++++++++++++++++++ .../transport/streamjson/streamjson_init.go | 48 ++- .../streamjson/streamjson_messages.go | 40 +++ .../streamjson/streamjson_permissions.go | 122 +++++-- .../transport/streamjson/streamjson_result.go | 19 + .../orchestrator/event_handlers_streaming.go | 37 +- apps/backend/internal/task/models/models.go | 2 + apps/backend/pkg/claudecode/client.go | 16 + apps/backend/pkg/claudecode/client_test.go | 40 +++ apps/backend/pkg/claudecode/types.go | 16 +- apps/cli/src/dev.ts | 9 +- apps/web/components/kanban-board.tsx | 9 +- .../components/task-create-dialog-footer.tsx | 1 + .../components/task/chat/chat-input-area.tsx | 12 + .../components/task/chat/message-renderer.tsx | 5 + .../task/chat/messages/agent-plan-message.tsx | 91 +++++ apps/web/hooks/use-processed-messages.ts | 1 + .../session-runtime/session-runtime-slice.ts | 4 + .../lib/state/slices/session-runtime/types.ts | 1 + apps/web/lib/state/store.ts | 1 + apps/web/lib/types/http.ts | 3 +- apps/web/lib/ws/handlers/session-mode.ts | 11 +- 24 files changed, 817 insertions(+), 98 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go create mode 100644 apps/web/components/task/chat/messages/agent-plan-message.tsx diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go index aef527fe7d..48807358d3 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "sync" + "sync/atomic" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" @@ -101,6 +102,13 @@ type Adapter struct { lastMessageUUID string // Latest committed message UUID (safe to resume from) pendingAssistantUUID string // UUID from assistant message, committed on result + // Plan content from ExitPlanMode tool_call, published as agent_plan message on result + exitPlanContent string + + // ExitPlanMode completion guard: set to true when a delayed completion goroutine + // is pending, cleared on result message arrival or goroutine execution. + exitPlanPending atomic.Bool + // lastRawData holds the raw JSON of the current message being processed. // Set in handleMessage before dispatch; used by sendUpdate for OTel tracing. lastRawData json.RawMessage @@ -118,6 +126,10 @@ type Adapter struct { // defaultContextWindow is the fallback context window size for Claude models const defaultContextWindow = 200000 +// toolExitPlanMode is the Claude Code tool name for exiting plan mode. +// Used in hook matching, permission handling, and completion detection. +const toolExitPlanMode = "ExitPlanMode" + // resultComplete holds the result of a completed prompt type resultComplete struct { success bool diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go index a1d22c30ea..73fc27d1f5 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter_test.go @@ -2,6 +2,7 @@ package streamjson import ( "encoding/json" + "io" "testing" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" @@ -164,8 +165,32 @@ func TestBuildHooks_Autonomous(t *testing.T) { }, newTestLogger(t)) hooks := a.buildHooks() - if hooks != nil { - t.Errorf("buildHooks() = %v, want nil for autonomous mode", hooks) + if hooks == nil { + t.Fatal("buildHooks() returned nil, want hooks with ExitPlanMode") + } + + // Should have PreToolUse with only ExitPlanMode auto-approve + preToolUse, ok := hooks["PreToolUse"] + if !ok { + t.Fatal("buildHooks() missing PreToolUse key") + } + entries, ok := preToolUse.([]claudecode.HookEntry) + if !ok { + t.Fatalf("PreToolUse is %T, want []claudecode.HookEntry", preToolUse) + } + if len(entries) != 1 { + t.Fatalf("PreToolUse has %d entries, want 1", len(entries)) + } + if entries[0].Matcher != `^ExitPlanMode$` { + t.Errorf("entries[0].Matcher = %q, want %q", entries[0].Matcher, `^ExitPlanMode$`) + } + if entries[0].HookCallbackIDs[0] != "auto_approve" { + t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "auto_approve") + } + + // Should NOT have Stop (not registered until completion handling is implemented) + if _, ok := hooks["Stop"]; ok { + t.Error("buildHooks() has Stop key, but Stop hooks should not be registered") } }) } @@ -182,7 +207,7 @@ func TestBuildHooks_Supervised(t *testing.T) { t.Fatal("buildHooks() returned nil, want hooks") } - // Should have PreToolUse + // Should have PreToolUse with 2 entries: ExitPlanMode auto-approve + tool_approval preToolUse, ok := hooks["PreToolUse"] if !ok { t.Fatal("buildHooks() missing PreToolUse key") @@ -191,27 +216,24 @@ func TestBuildHooks_Supervised(t *testing.T) { if !ok { t.Fatalf("PreToolUse is %T, want []claudecode.HookEntry", preToolUse) } - if len(entries) != 1 { - t.Fatalf("PreToolUse has %d entries, want 1", len(entries)) + if len(entries) != 2 { + t.Fatalf("PreToolUse has %d entries, want 2", len(entries)) } - if entries[0].HookCallbackIDs[0] != "tool_approval" { - t.Errorf("PreToolUse[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "tool_approval") + // First: ExitPlanMode auto-approve + if entries[0].Matcher != `^ExitPlanMode$` { + t.Errorf("entries[0].Matcher = %q, want %q", entries[0].Matcher, `^ExitPlanMode$`) + } + if entries[0].HookCallbackIDs[0] != "auto_approve" { + t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "auto_approve") + } + // Second: tool_approval for write/dangerous tools + if entries[1].HookCallbackIDs[0] != "tool_approval" { + t.Errorf("entries[1].HookCallbackIDs[0] = %q, want %q", entries[1].HookCallbackIDs[0], "tool_approval") } - // Should have Stop - stop, ok := hooks["Stop"] - if !ok { - t.Fatal("buildHooks() missing Stop key") - } - stopEntries, ok := stop.([]claudecode.HookEntry) - if !ok { - t.Fatalf("Stop is %T, want []claudecode.HookEntry", stop) - } - if len(stopEntries) != 1 { - t.Fatalf("Stop has %d entries, want 1", len(stopEntries)) - } - if stopEntries[0].HookCallbackIDs[0] != "stop_git_check" { - t.Errorf("Stop[0].HookCallbackIDs[0] = %q, want %q", stopEntries[0].HookCallbackIDs[0], "stop_git_check") + // Should NOT have Stop + if _, ok := hooks["Stop"]; ok { + t.Error("buildHooks() has Stop key, but Stop hooks should not be registered") } } @@ -239,12 +261,12 @@ func TestBuildHooks_Plan(t *testing.T) { t.Fatalf("PreToolUse has %d entries, want 2", len(entries)) } - // First entry: ExitPlanMode requires approval + // First entry: ExitPlanMode auto-approved if entries[0].Matcher != `^ExitPlanMode$` { t.Errorf("entries[0].Matcher = %q, want %q", entries[0].Matcher, `^ExitPlanMode$`) } - if entries[0].HookCallbackIDs[0] != "tool_approval" { - t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "tool_approval") + if entries[0].HookCallbackIDs[0] != "auto_approve" { + t.Errorf("entries[0].HookCallbackIDs[0] = %q, want %q", entries[0].HookCallbackIDs[0], "auto_approve") } // Second entry: everything else auto-approved @@ -252,9 +274,9 @@ func TestBuildHooks_Plan(t *testing.T) { t.Errorf("entries[1].HookCallbackIDs[0] = %q, want %q", entries[1].HookCallbackIDs[0], "auto_approve") } - // Should have Stop - if _, ok := hooks["Stop"]; !ok { - t.Error("buildHooks() missing Stop key") + // Should NOT have Stop + if _, ok := hooks["Stop"]; ok { + t.Error("buildHooks() has Stop key, but Stop hooks should not be registered") } } @@ -541,7 +563,7 @@ func (b *syncBuf) Bytes() []byte { return b.data } // emptyReader always returns EOF. type emptyReader struct{} -func (e *emptyReader) Read(p []byte) (int, error) { return 0, nil } +func (e *emptyReader) Read([]byte) (int, error) { return 0, io.EOF } // --- TodoWrite normalizer tests --- diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go new file mode 100644 index 0000000000..c2ef4af2fc --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/exit_plan_mode_test.go @@ -0,0 +1,337 @@ +package streamjson + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" + "github.com/kandev/kandev/pkg/claudecode" +) + +// --- drainExitPlanContent tests --- + +func TestDrainExitPlanContent_ReturnsAndClears(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + + a.mu.Lock() + a.exitPlanContent = "my plan" + a.mu.Unlock() + + content := a.drainExitPlanContent() + if content != "my plan" { + t.Errorf("drainExitPlanContent() = %q, want %q", content, "my plan") + } + + // Second drain should return empty + content2 := a.drainExitPlanContent() + if content2 != "" { + t.Errorf("second drainExitPlanContent() = %q, want empty", content2) + } +} + +func TestDrainExitPlanContent_EmptyReturnsEmpty(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + + content := a.drainExitPlanContent() + if content != "" { + t.Errorf("drainExitPlanContent() = %q, want empty", content) + } +} + +// --- processToolUseBlock ExitPlanMode tests --- + +func TestProcessToolUseBlock_ExitPlanMode_CapturesPlanAndEmitsMode(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + + block := claudecode.ContentBlock{ + Type: "tool_use", + ID: "tool-1", + Name: "ExitPlanMode", + Input: map[string]any{"plan": "## My Plan\n- step 1\n- step 2"}, + } + + a.processToolUseBlock(block, "sess-1", "op-1", "") + + events := drainEvents(a) + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %d", len(events)) + } + + // First event: session_mode with empty mode (plan mode exited) + if events[0].Type != "session_mode" { + t.Errorf("events[0].Type = %q, want %q", events[0].Type, "session_mode") + } + if events[0].CurrentModeID != "" { + t.Errorf("events[0].CurrentModeID = %q, want empty", events[0].CurrentModeID) + } + + // Verify plan content was captured + a.mu.RLock() + planContent := a.exitPlanContent + a.mu.RUnlock() + if planContent != "## My Plan\n- step 1\n- step 2" { + t.Errorf("exitPlanContent = %q, want plan text", planContent) + } +} + +func TestProcessToolUseBlock_NonExitPlanMode_NoPlanCapture(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + + block := claudecode.ContentBlock{ + Type: "tool_use", + ID: "tool-1", + Name: "Bash", + Input: map[string]any{"command": "ls"}, + } + + a.processToolUseBlock(block, "sess-1", "op-1", "") + + events := drainEvents(a) + for _, ev := range events { + if ev.Type == "session_mode" { + t.Error("unexpected session_mode event for non-ExitPlanMode tool") + } + } + + a.mu.RLock() + planContent := a.exitPlanContent + a.mu.RUnlock() + if planContent != "" { + t.Errorf("exitPlanContent = %q, want empty", planContent) + } +} + +// --- handleResultMessage plan content tests --- + +func TestHandleResultMessage_ClearsPendingAndDrainsPlan(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + + a.mu.Lock() + a.exitPlanContent = "the plan" + a.mu.Unlock() + a.exitPlanPending.Store(true) + + msg := &claudecode.CLIMessage{ + Type: claudecode.MessageTypeResult, + Result: json.RawMessage(`"done"`), + } + + a.handleResultMessage(msg, "sess-1", "op-1") + + if a.exitPlanPending.Load() { + t.Error("exitPlanPending should be false after result message") + } + + a.mu.RLock() + remaining := a.exitPlanContent + a.mu.RUnlock() + if remaining != "" { + t.Errorf("exitPlanContent = %q, want empty after drain", remaining) + } + + events := drainEvents(a) + var completeEvent *AgentEvent + for i := range events { + if events[i].Type == "complete" { + completeEvent = &events[i] + break + } + } + if completeEvent == nil { + t.Fatal("no complete event found") + } + if completeEvent.Data == nil { + t.Fatal("complete data is nil") + } + if completeEvent.Data["plan_content"] != "the plan" { + t.Errorf("plan_content = %v, want %q", completeEvent.Data["plan_content"], "the plan") + } +} + +// --- scheduleExitPlanModeCompletion tests --- + +func TestScheduleExitPlanModeCompletion_EmitsCompleteAfterDelay(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + a.resultCh = make(chan resultComplete, 1) + + a.mu.Lock() + a.exitPlanContent = "delayed plan" + a.mu.Unlock() + + a.scheduleExitPlanModeCompletion() + + select { + case result := <-a.resultCh: + if !result.success { + t.Error("expected success=true") + } + case <-time.After(exitPlanModeCompletionDelay + time.Second): + t.Fatal("timed out waiting for completion signal") + } + + events := drainEvents(a) + var completeEvent *AgentEvent + for i := range events { + if events[i].Type == "complete" { + completeEvent = &events[i] + break + } + } + if completeEvent == nil { + t.Fatal("no complete event emitted") + } + if completeEvent.Data == nil { + t.Fatal("complete data is nil") + } + if completeEvent.Data["plan_content"] != "delayed plan" { + t.Errorf("plan_content = %v, want %q", completeEvent.Data["plan_content"], "delayed plan") + } + + remaining := a.drainExitPlanContent() + if remaining != "" { + t.Errorf("exitPlanContent not drained: %q", remaining) + } +} + +func TestScheduleExitPlanModeCompletion_CancelledByResult(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + + a.scheduleExitPlanModeCompletion() + + // Simulate a result message arriving before the delay + a.exitPlanPending.Store(false) + + time.Sleep(exitPlanModeCompletionDelay + 500*time.Millisecond) + + events := drainEvents(a) + for _, ev := range events { + if ev.Type == "complete" { + t.Error("unexpected complete event — should have been cancelled by result") + } + } +} + +func TestScheduleExitPlanModeCompletion_CancelledByContext(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + + a.scheduleExitPlanModeCompletion() + + // Cancel the adapter context immediately + a.cancel() + + time.Sleep(500 * time.Millisecond) + + if a.exitPlanPending.Load() { + t.Error("exitPlanPending should be false after context cancel") + } + + events := drainEvents(a) + for _, ev := range events { + if ev.Type == "complete" { + t.Error("unexpected complete event after context cancellation") + } + } +} + +// --- handleSystemMessage status/permission mode tests --- + +func TestHandleSystemMessage_StatusPermissionMode(t *testing.T) { + tests := []struct { + name string + mode string + wantModeID string + }{ + {"plan mode", "plan", "plan"}, + {"bypass mapped to empty", "bypassPermissions", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + + msg := &claudecode.CLIMessage{ + Type: claudecode.MessageTypeSystem, + Subtype: "status", + PermissionMode: tt.mode, + } + + a.handleSystemMessage(msg) + + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != "session_mode" { + t.Errorf("event type = %q, want %q", events[0].Type, "session_mode") + } + if events[0].CurrentModeID != tt.wantModeID { + t.Errorf("CurrentModeID = %q, want %q", events[0].CurrentModeID, tt.wantModeID) + } + }) + } +} + +// --- hook callback and permission handler ExitPlanMode tests --- + +func TestHandleHookCallback_AutoApprove_ExitPlanMode(t *testing.T) { + var buf syncBuf + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.client = claudecode.NewClient(&buf, &emptyReader{}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + a.resultCh = make(chan resultComplete, 1) + + req := &claudecode.ControlRequest{ + Subtype: claudecode.SubtypeHookCallback, + CallbackID: "auto_approve", + Input: map[string]any{ + "tool_name": "ExitPlanMode", + "hook_event_name": "PreToolUse", + }, + } + + a.handleHookCallback("req-1", req) + + if !a.exitPlanPending.Load() { + t.Error("exitPlanPending should be true after ExitPlanMode hook approval") + } + + a.cancel() +} + +func TestHandleToolPermission_ExitPlanMode_NoHandler(t *testing.T) { + var buf syncBuf + a := NewAdapter(&shared.Config{AgentID: "test"}, newTestLogger(t)) + a.client = claudecode.NewClient(&buf, &emptyReader{}, newTestLogger(t)) + a.sessionID = "sess-1" + a.operationID = "op-1" + a.resultCh = make(chan resultComplete, 1) + + req := &claudecode.ControlRequest{ + Subtype: claudecode.SubtypeCanUseTool, + ToolName: "ExitPlanMode", + Input: map[string]any{"plan": "test plan"}, + } + + a.handleToolPermission("req-1", req) + + if !a.exitPlanPending.Load() { + t.Error("exitPlanPending should be true after ExitPlanMode auto-allow") + } + + a.cancel() +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go index 7cc29d56da..f37f5246e1 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_init.go @@ -84,6 +84,10 @@ func (a *Adapter) Initialize(ctx context.Context) error { a.client.SetRequestHandler(a.handleControlRequest) a.client.SetCancelHandler(a.handleControlCancel) a.client.SetMessageHandler(a.handleMessage) + a.client.SetCloseHandler(func() { + a.logger.Info("stream closed (EOF), signaling completion") + a.signalResultCompletion(true, "") + }) // Start reading from stdout with the adapter's context // Wait for the read loop to be ready before sending initialize @@ -141,47 +145,55 @@ func (a *Adapter) Initialize(ctx context.Context) error { } // buildHooks constructs hook configuration based on the permission policy. -// Returns nil for autonomous mode (no hooks). +// Always registers at least an ExitPlanMode auto-approve hook (ExitPlanMode +// doesn't send a result message, so it needs special completion handling +// regardless of policy). func (a *Adapter) buildHooks() map[string]any { policy := a.cfg.PermissionPolicy - if policy == "" || policy == "autonomous" { - return nil - } - cfg := &claudecode.HookConfig{} switch policy { case "supervised": - // Match write/dangerous tools — everything except read-only tools. - // Non-matching tools (Glob, Grep, Read, Task, TodoWrite, etc.) are - // implicitly auto-approved by Claude Code when no hook matches. + // ExitPlanMode auto-approved; write/dangerous tools require approval. + // Non-matching read-only tools are implicitly auto-approved by Claude Code. cfg.PreToolUse = []claudecode.HookEntry{ { - Matcher: `^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite)$).*`, + Matcher: `^ExitPlanMode$`, + HookCallbackIDs: []string{"auto_approve"}, + }, + { + Matcher: `^(?!(Glob|Grep|NotebookRead|Read|Task|TodoWrite|ExitPlanMode)$).*`, HookCallbackIDs: []string{"tool_approval"}, }, } case "plan": - // Only ExitPlanMode requires approval — all other tools run freely during planning. - // The auto_approve callback explicitly approves so Claude doesn't fall through. + // All tools auto-approved during planning, including ExitPlanMode. cfg.PreToolUse = []claudecode.HookEntry{ { Matcher: `^ExitPlanMode$`, - HookCallbackIDs: []string{"tool_approval"}, + HookCallbackIDs: []string{"auto_approve"}, }, { Matcher: `^(?!ExitPlanMode$).*`, HookCallbackIDs: []string{"auto_approve"}, }, } + default: + // Autonomous / empty: only hook ExitPlanMode for completion handling. + // All other tools have no matching hook → implicitly auto-approved. + cfg.PreToolUse = []claudecode.HookEntry{ + { + Matcher: `^ExitPlanMode$`, + HookCallbackIDs: []string{"auto_approve"}, + }, + } } - // Always register a Stop hook to check for uncommitted changes - cfg.Stop = []claudecode.HookEntry{ - { - HookCallbackIDs: []string{"stop_git_check"}, - }, - } + // NOTE: Do NOT register Stop hooks here until proper completion handling + // is implemented. Stop hooks fire on every turn end — when approved, + // Claude Code terminates without sending a result message, leaving + // Prompt() blocked forever. A Stop hook would need its own delayed + // completion logic (like scheduleExitPlanModeCompletion) to work. return cfg.ToMap() } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go index 16f9cf20a9..25be4ad499 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_messages.go @@ -13,10 +13,29 @@ import ( // handleSystemMessage processes system messages. // Subtypes: // - "init" (or empty): Session initialization with slash commands and status +// - "status": Permission mode changes (e.g. plan mode entry) // - "task_started": Sub-agent task lifecycle event — logged and skipped // // Note: Turn completion is signaled by result messages, not system messages. func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { + // Handle permission mode changes from system status messages. + // "bypassPermissions" is the default/normal mode — map it to empty (no mode). + if msg.Subtype == "status" && msg.PermissionMode != "" { + modeID := msg.PermissionMode + if modeID == "bypassPermissions" { + modeID = "" + } + a.mu.RLock() + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + a.logger.Info("permission mode changed", + zap.String("raw_mode", msg.PermissionMode), + zap.String("mode_id", modeID)) + a.emitSessionMode(sessionID, operationID, modeID) + return + } + // Skip non-init system messages (e.g. task_started for sub-agent lifecycle) if msg.Subtype != "" && msg.Subtype != "init" { a.logger.Debug("skipping system message with subtype", @@ -144,6 +163,17 @@ func (a *Adapter) processToolUseBlock(block claudecode.ContentBlock, sessionID, zap.String("tool_type", toolType), zap.String("title", toolTitle)) + // Capture plan content from ExitPlanMode for dedicated agent_plan message + // Also emit mode-clear since Claude Code doesn't send a system status reset on exit + if block.Name == toolExitPlanMode { + if plan, ok := block.Input["plan"].(string); ok { + a.mu.Lock() + a.exitPlanContent = plan + a.mu.Unlock() + } + a.emitSessionMode(sessionID, operationID, "") + } + // Track this tool call as pending with its payload for result enrichment a.mu.Lock() a.pendingToolCalls[block.ID] = normalizedPayload @@ -414,3 +444,13 @@ func (a *Adapter) enrichSubagentResult(payload *streams.NormalizedPayload, data st.ToolUseCount = int(v) } } + +// emitSessionMode sends a session_mode event to indicate the agent's mode has changed. +func (a *Adapter) emitSessionMode(sessionID, operationID, modeID string) { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeSessionMode, + SessionID: sessionID, + OperationID: operationID, + CurrentModeID: modeID, + }) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go index f671766e16..6a6eb38c3f 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_permissions.go @@ -3,6 +3,7 @@ package streamjson import ( "context" "fmt" + "time" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" @@ -47,12 +48,17 @@ func (a *Adapter) handleControlRequest(requestID string, req *claudecode.Control } // handleToolPermission processes can_use_tool permission requests. +// This is the fallback path when hooks are not registered or the hook responds "ask". +// Note: plan content and session-mode-clear for ExitPlanMode are handled in +// processToolUseBlock (which fires first when the tool_use block is streamed). func (a *Adapter) handleToolPermission(requestID string, req *claudecode.ControlRequest) { a.mu.RLock() handler := a.permissionHandler sessionID := a.sessionID a.mu.RUnlock() + isExitPlanMode := req.ToolName == toolExitPlanMode + // Determine action type based on tool name actionType := types.ActionTypeOther switch req.ToolName { @@ -89,7 +95,6 @@ func (a *Adapter) handleToolPermission(requestID string, req *claudecode.Control // We pass PendingID so the handler uses Claude Code's requestID // instead of generating a new one - this ensures the frontend and backend // use the same ID for response lookup. - // Build action details, including blocked paths if present actionDetails := req.Input if req.BlockedPaths != "" { if actionDetails == nil { @@ -105,7 +110,7 @@ func (a *Adapter) handleToolPermission(requestID string, req *claudecode.Control Options: options, ActionType: actionType, ActionDetails: actionDetails, - PendingID: requestID, // Use Claude Code's requestID so response lookup works + PendingID: requestID, } // If no handler, auto-allow @@ -113,6 +118,9 @@ func (a *Adapter) handleToolPermission(requestID string, req *claudecode.Control a.logger.Debug("auto-allowing tool (no handler)", zap.String("tool", req.ToolName)) a.sendPermissionResponse(requestID, claudecode.BehaviorAllow) + if isExitPlanMode { + a.scheduleExitPlanModeCompletion() + } return } @@ -165,6 +173,12 @@ func (a *Adapter) handleToolPermission(requestID string, req *claudecode.Control } a.sendPermissionResponse(requestID, behavior) + + // ExitPlanMode: Claude Code ends the turn after approval without a result message. + // Schedule delayed completion to unblock Prompt(). + if isExitPlanMode && behavior == claudecode.BehaviorAllow { + a.scheduleExitPlanModeCompletion() + } } // sendControlResult sends a success control response with the given result payload and traces it. @@ -212,12 +226,12 @@ func (a *Adapter) handleControlCancel(requestID string) { // handleHookCallback processes hook callback requests, dispatching based on callback ID. // Hook responses use different payload formats than can_use_tool responses: // - PreToolUse hooks: hookSpecificOutput with permissionDecision -// - Stop hooks: decision + optional reason func (a *Adapter) handleHookCallback(requestID string, req *claudecode.ControlRequest) { + hookEventName, _ := req.Input["hook_event_name"].(string) a.logger.Info("received hook callback", zap.String("request_id", requestID), zap.String("callback_id", req.CallbackID), - zap.String("hook_name", req.HookName)) + zap.String("hook_event_name", hookEventName)) switch req.CallbackID { case "tool_approval": @@ -229,9 +243,12 @@ func (a *Adapter) handleHookCallback(requestID string, req *claudecode.ControlRe // Auto-approve via hookSpecificOutput a.sendPreToolUseHookResponse(requestID, "allow", "Auto-approved by SDK") - case "stop_git_check": - // Check for uncommitted changes before stopping - a.handleStopGitCheck(requestID, req) + // ExitPlanMode: Claude Code ends the turn after approval without a result message. + // Capture plan content from hook input and schedule delayed completion. + // Note: Claude Code sends hook data in the "input" field (not "hook_input"). + if toolName, ok := req.Input["tool_name"].(string); ok && toolName == toolExitPlanMode { + a.handleExitPlanModeHookApproval() + } default: a.logger.Warn("unknown hook callback ID, auto-approving", @@ -252,27 +269,76 @@ func (a *Adapter) sendPreToolUseHookResponse(requestID, permissionDecision, reas }, "hook_response.pre_tool_use") } -// handleStopGitCheck processes the stop_git_check hook callback. -// Checks if the stop hook is active and approves/blocks accordingly. -func (a *Adapter) handleStopGitCheck(requestID string, req *claudecode.ControlRequest) { - // If stop_hook_active is set, just approve (hook is being deactivated) - if active, ok := req.HookInput["stop_hook_active"].(bool); ok && active { - a.sendStopHookResponse(requestID, "approve", "") - return - } - - // For now, approve the stop — the workspace tracker integration - // can be added later to check for uncommitted changes - a.sendStopHookResponse(requestID, "approve", "") +// handleExitPlanModeHookApproval schedules completion for ExitPlanMode. +// Called when ExitPlanMode is auto-approved via hook callback. +// Note: plan content and session-mode-clear are handled in processToolUseBlock +// (which fires first when the tool_use block is streamed). +func (a *Adapter) handleExitPlanModeHookApproval() { + a.scheduleExitPlanModeCompletion() } -// sendStopHookResponse sends a Stop hook callback response with decision format. -func (a *Adapter) sendStopHookResponse(requestID, decision, reason string) { - result := map[string]any{ - "decision": decision, - } - if reason != "" { - result["reason"] = reason - } - a.sendControlResult(requestID, result, "hook_response.stop") +// exitPlanModeCompletionDelay is the time to wait after ExitPlanMode approval before +// emitting a synthetic completion. This allows trailing stream events to arrive first. +const exitPlanModeCompletionDelay = 2 * time.Second + +// scheduleExitPlanModeCompletion schedules a delayed completion for ExitPlanMode. +// Claude Code does not send a result message after ExitPlanMode approval — the turn +// simply ends. This goroutine waits briefly for trailing stream_events, then emits +// a complete event and signals Prompt() to unblock. +// Uses exitPlanPending atomic flag to prevent duplicate completion if a result +// message arrives before the delay expires. +func (a *Adapter) scheduleExitPlanModeCompletion() { + a.exitPlanPending.Store(true) + + a.mu.RLock() + sessionID := a.sessionID + operationID := a.operationID + a.mu.RUnlock() + + go func(expectedSessionID, expectedOperationID string) { + select { + case <-time.After(exitPlanModeCompletionDelay): + // Timer expired, proceed with completion + case <-a.ctx.Done(): + a.exitPlanPending.Store(false) + return + } + + // Guard against stale goroutine completing the wrong turn + a.mu.RLock() + currentSessionID := a.sessionID + currentOperationID := a.operationID + a.mu.RUnlock() + if currentSessionID != expectedSessionID || currentOperationID != expectedOperationID { + a.logger.Debug("ExitPlanMode completion skipped (stale operation)", + zap.String("expected_session_id", expectedSessionID), + zap.String("current_session_id", currentSessionID)) + return + } + + if !a.exitPlanPending.CompareAndSwap(true, false) { + a.logger.Debug("ExitPlanMode completion cancelled (result message arrived)") + return + } + + a.logger.Info("ExitPlanMode: emitting delayed completion", + zap.String("session_id", sessionID), + zap.String("operation_id", operationID)) + + // Drain plan content + planContent := a.drainExitPlanContent() + + completeData := map[string]any{"is_error": false} + if planContent != "" { + completeData["plan_content"] = planContent + } + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeComplete, + SessionID: sessionID, + OperationID: operationID, + Data: completeData, + }) + a.signalResultCompletion(true, "") + }(sessionID, operationID) } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go index 1bb13c118c..f397702a15 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/streamjson_result.go @@ -27,6 +27,15 @@ func (a *Adapter) updateContextWindowFromModelUsage(msg *claudecode.CLIMessage) return contextUsed, contextSize } +// drainExitPlanContent atomically reads and clears the captured ExitPlanMode plan content. +func (a *Adapter) drainExitPlanContent() string { + a.mu.Lock() + defer a.mu.Unlock() + content := a.exitPlanContent + a.exitPlanContent = "" + return content +} + // drainPendingToolCalls atomically removes and returns all pending tool call IDs. func (a *Adapter) drainPendingToolCalls() []string { a.mu.Lock() @@ -80,6 +89,10 @@ func (a *Adapter) signalResultCompletion(success bool, errMsg string) { // handleResultMessage processes result (completion) messages. func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { + // Cancel any pending ExitPlanMode delayed completion — this result message + // will handle completion instead. + a.exitPlanPending.Store(false) + a.logger.Info("received result message", zap.Bool("is_error", msg.IsError), zap.Int("num_turns", msg.NumTurns)) @@ -155,6 +168,9 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope // Build error message from errors array if available errorMsg := a.extractResultErrorMsg(msg) + // Drain captured plan content from ExitPlanMode (if any) + planContent := a.drainExitPlanContent() + // Send completion event AFTER any result text chunk completeData := map[string]any{ "cost_usd": msg.CostUSD, @@ -164,6 +180,9 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope "output_tokens": msg.TotalOutputTokens, "is_error": msg.IsError, } + if planContent != "" { + completeData["plan_content"] = planContent + } if len(msg.Errors) > 0 { completeData["errors"] = msg.Errors } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 35301accb4..8aee54ae85 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -417,11 +417,9 @@ func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID strin // handleCompleteStreamEvent handles the agentEventComplete stream event. func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - s.logger.Debug("orchestrator received complete event", + s.logger.Debug("handling complete stream event", zap.String("task_id", payload.TaskID), - zap.String("session_id", payload.SessionID), - zap.Int("text_length", len(payload.Data.Text)), - zap.Bool("has_text", payload.Data.Text != "")) + zap.String("session_id", payload.SessionID)) // Load session once up front — used by storeResumeToken, state check, and setSessionWaitingForInput. var session *models.TaskSession @@ -449,6 +447,7 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy } s.saveAgentTextIfPresent(ctx, payload) + s.publishAgentPlanIfPresent(ctx, payload) s.completeTurnForSession(ctx, payload.SessionID) // READY events own workflow transitions and queued prompt execution. @@ -463,6 +462,33 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy s.setSessionWaitingForInput(ctx, payload.TaskID, payload.SessionID, session) } +// publishAgentPlanIfPresent extracts plan_content from a complete event and creates +// a dedicated agent_plan message in the session. +func (s *Service) publishAgentPlanIfPresent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + if payload.SessionID == "" || payload.Data.Data == nil || s.messageCreator == nil { + return + } + dataMap, ok := payload.Data.Data.(map[string]interface{}) + if !ok { + return + } + planContent, ok := dataMap["plan_content"].(string) + if !ok || planContent == "" { + return + } + + sessionID := payload.SessionID + if err := s.messageCreator.CreateSessionMessage( + ctx, payload.TaskID, planContent, sessionID, + string(models.MessageTypeAgentPlan), s.getActiveTurnID(sessionID), nil, false, + ); err != nil { + s.logger.Error("failed to create agent plan message", + zap.String("task_id", payload.TaskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } +} + // handleAvailableCommandsEvent broadcasts available_commands events to the WebSocket for the frontend. func (s *Service) handleAvailableCommandsEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { sessionID := payload.SessionID @@ -480,9 +506,10 @@ func (s *Service) handleAvailableCommandsEvent(ctx context.Context, payload *lif } // handleSessionModeEvent broadcasts session_mode events to the WebSocket for the frontend. +// An empty CurrentModeID means the agent has exited its special mode (e.g. plan mode ended). func (s *Service) handleSessionModeEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { sessionID := payload.SessionID - if sessionID == "" || s.eventBus == nil || payload.Data.CurrentModeID == "" { + if sessionID == "" || s.eventBus == nil { return } eventPayload := lifecycle.SessionModeEventPayload{ diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 7bd4d87122..9ff81c5eb3 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -112,6 +112,8 @@ const ( MessageTypeScriptExecution MessageType = "script_execution" // MessageTypeThinking is for agent thinking/reasoning content MessageTypeThinking MessageType = "thinking" + // MessageTypeAgentPlan is for agent native plan content (e.g. ExitPlanMode) + MessageTypeAgentPlan MessageType = "agent_plan" ) // Message represents a message in a task session diff --git a/apps/backend/pkg/claudecode/client.go b/apps/backend/pkg/claudecode/client.go index 5a5c0bc96a..ec3adf8d59 100644 --- a/apps/backend/pkg/claudecode/client.go +++ b/apps/backend/pkg/claudecode/client.go @@ -40,6 +40,7 @@ type Client struct { requestHandler RequestHandler cancelHandler CancelHandler messageHandler MessageHandler + closeHandler func() // Called when readLoop exits (EOF or error) // Pending control requests (requests we sent, waiting for responses) pendingRequests map[string]*pendingRequest @@ -82,6 +83,13 @@ func (c *Client) SetMessageHandler(handler MessageHandler) { c.messageHandler = handler } +// SetCloseHandler sets a handler called when the read loop exits (EOF or error). +func (c *Client) SetCloseHandler(handler func()) { + c.mu.Lock() + defer c.mu.Unlock() + c.closeHandler = handler +} + // Start begins reading from stdout in a goroutine. // Returns a channel that is closed when the read loop is ready. func (c *Client) Start(ctx context.Context) <-chan struct{} { @@ -226,6 +234,14 @@ func (c *Client) readLoop(ctx context.Context, ready chan<- struct{}) { if err := scanner.Err(); err != nil { c.logger.Error("read loop error", zap.Error(err)) } + + // Notify close handler (e.g., to unblock Prompt on unexpected EOF) + c.mu.RLock() + handler := c.closeHandler + c.mu.RUnlock() + if handler != nil { + handler() + } } func (c *Client) handleLine(line []byte) { diff --git a/apps/backend/pkg/claudecode/client_test.go b/apps/backend/pkg/claudecode/client_test.go index 574f241ed0..39cbb76362 100644 --- a/apps/backend/pkg/claudecode/client_test.go +++ b/apps/backend/pkg/claudecode/client_test.go @@ -411,6 +411,46 @@ func TestClient_HandleControlCancelRequest_NoHandler(t *testing.T) { // Test passes if no panic occurs } +func TestClient_CloseHandler_CalledOnEOF(t *testing.T) { + // Reader that immediately returns EOF + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(""), newTestLogger()) + + var called bool + var mu sync.Mutex + client.SetCloseHandler(func() { + mu.Lock() + called = true + mu.Unlock() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(100 * time.Millisecond) // Give time for readLoop to exit + + mu.Lock() + defer mu.Unlock() + if !called { + t.Error("close handler was not called on EOF") + } +} + +func TestClient_CloseHandler_NotSetNoPanic(t *testing.T) { + // Verify no panic when close handler is not set + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(""), newTestLogger()) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(100 * time.Millisecond) + + // Test passes if no panic occurs +} + func TestClient_HandleControlCancelRequest_NotForwardedAsMessage(t *testing.T) { // control_cancel_request should be handled by the cancel handler, // not forwarded to the message handler diff --git a/apps/backend/pkg/claudecode/types.go b/apps/backend/pkg/claudecode/types.go index dc69bc2022..1987e63c69 100644 --- a/apps/backend/pkg/claudecode/types.go +++ b/apps/backend/pkg/claudecode/types.go @@ -64,9 +64,10 @@ type CLIMessage struct { Response *IncomingControlResponse `json:"response,omitempty"` // For system messages - SessionID string `json:"session_id,omitempty"` - SessionStatus string `json:"session_status,omitempty"` - SlashCommands []string `json:"slash_commands,omitempty"` // System message uses string array + SessionID string `json:"session_id,omitempty"` + SessionStatus string `json:"session_status,omitempty"` + SlashCommands []string `json:"slash_commands,omitempty"` // System message uses string array + PermissionMode string `json:"permissionMode,omitempty"` // From system status messages (e.g. "plan", "bypassPermissions") // For subagent context (when running inside a Task tool call) ParentToolUseID string `json:"parent_tool_use_id,omitempty"` @@ -257,10 +258,11 @@ type ControlRequest struct { ToolUseID string `json:"tool_use_id,omitempty"` BlockedPaths string `json:"blocked_paths,omitempty"` - // For hook_callback requests - CallbackID string `json:"callback_id,omitempty"` - HookName string `json:"hook_name,omitempty"` - HookInput map[string]any `json:"hook_input,omitempty"` + // For hook_callback requests. + // Note: hook data (tool_name, tool_input, hook_event_name) is in the Input field, + // not a separate field — Claude Code sends it under "input" for both can_use_tool + // and hook_callback subtypes. + CallbackID string `json:"callback_id,omitempty"` // Permission suggestions from Claude PermissionSuggestions []PermissionUpdate `json:"permission_suggestions,omitempty"` diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index b60ce5092b..f0d1b1f367 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -23,10 +23,11 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr const ports = await pickPorts(backendPort, webPort); const dbPath = path.join(repoRoot, "apps", "backend", "kandev.db"); - const backendEnv = buildBackendEnv({ - ports, - extra: { KANDEV_MOCK_AGENT: "true", KANDEV_DATABASE_PATH: dbPath }, - }); + const extra: Record = { KANDEV_DATABASE_PATH: dbPath }; + if (process.env.KANDEV_MOCK_AGENT) { + extra.KANDEV_MOCK_AGENT = process.env.KANDEV_MOCK_AGENT; + } + const backendEnv = buildBackendEnv({ ports, extra }); const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true }); const logLevel = process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info"; diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 67082a6ee2..24b0c169e9 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -63,13 +63,14 @@ function useWorkflowSelection({ (workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId, ); - const desiredWorkflowId = + const hasSettingsWorkflow = settings.workflowId && workspaceWorkflows.some( (workflow: WorkflowsState["items"][number]) => workflow.id === settings.workflowId, - ) - ? settings.workflowId - : null; + ); + const fallbackWorkflowId = workspaceWorkflows.length === 1 ? workspaceWorkflows[0].id : null; + const desiredWorkflowId = + (hasSettingsWorkflow ? settings.workflowId : fallbackWorkflowId) ?? null; setActiveWorkflow(desiredWorkflowId); if (!desiredWorkflowId) { store.getState().hydrate({ diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 193fd8b9d7..4568e5f46e 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -226,6 +226,7 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { const defaultDisabled = isSessionMode ? !agentProfileId : !hasTitle || !hasRepositorySelection || !branch || missingCtx; + return { showStartTask, splitDisabled, defaultDisabled }; } diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 08189040b1..e9a196012c 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -1,10 +1,12 @@ "use client"; import { useCallback, useState } from "react"; +import { IconBrain } from "@tabler/icons-react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { useMessageHandler } from "@/hooks/use-message-handler"; +import { useAppStore } from "@/components/state-provider"; import { type ContextFile } from "@/lib/state/context-files-store"; import { ChatInputContainer, @@ -302,6 +304,10 @@ export function ChatInputArea({ chatInputRef, ); + const agentMode = useAppStore((state) => + resolvedSessionId ? state.sessionMode.bySessionId[resolvedSessionId] : undefined, + ); + const hasClarification = !!panelState.pendingClarification; const placeholder = resolveInputPlaceholder( isAgentBusy, @@ -311,6 +317,12 @@ export function ChatInputArea({ ); return (
+ {agentMode && ( +
+ + {agentMode} mode +
+ )} comment.type === "clarification_request", render: (comment) => , }, + { + matches: (comment) => comment.type === "agent_plan", + render: (comment) => , + }, { matches: (comment) => comment.type === "script_execution", render: (comment) => , diff --git a/apps/web/components/task/chat/messages/agent-plan-message.tsx b/apps/web/components/task/chat/messages/agent-plan-message.tsx new file mode 100644 index 0000000000..d0363d4951 --- /dev/null +++ b/apps/web/components/task/chat/messages/agent-plan-message.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState, memo } from "react"; +import ReactMarkdown from "react-markdown"; +import { IconMinus, IconPlus, IconMaximize } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components"; +import type { Message } from "@/lib/types/http"; + +function parsePlanContent(text: string): { title: string; body: string } { + const lines = text.split("\n"); + const firstContentIdx = lines.findIndex((l) => l.trim().length > 0); + if (firstContentIdx === -1) return { title: "Agent Plan", body: "" }; + const firstLine = lines[firstContentIdx]; + const isHeading = /^#{1,6}\s+/.test(firstLine); + const title = isHeading ? firstLine.replace(/^#{1,6}\s+/, "").trim() : firstLine.trim(); + const body = isHeading ? lines.slice(firstContentIdx + 1).join("\n") : text; + return { title: title || "Agent Plan", body }; +} + +function PlanMarkdownBody({ text, className }: { text: string; className?: string }) { + return ( +
*]:my-2 [&>p]:my-2 [&>ul]:my-2 [&>ol]:my-2 ${className ?? ""}`} + > + + {text} + +
+ ); +} + +export const AgentPlanMessage = memo(function AgentPlanMessage({ comment }: { comment: Message }) { + const [collapsed, setCollapsed] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const text = comment.content; + + if (!text) return null; + + const { title, body } = parsePlanContent(text); + + return ( + <> +
+
+ {title} +
+ + +
+
+ {!collapsed && ( +
+
+ +
+
+ )} +
+ + + + + {title} + + + + + + ); +}); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index 9b5fd10ad6..70cab455ff 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -25,6 +25,7 @@ const VISIBLE_MESSAGE_TYPES: Set = new Set([ "thinking", "todo", "script_execution", + "agent_plan", ]); function isVisibleMessageType(type: MessageType | undefined): boolean { diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index e155bb2de2..9c8a972b5a 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -241,5 +241,9 @@ export const createSessionRuntimeSlice: StateCreator< set((draft) => { draft.sessionMode.bySessionId[sessionId] = modeId; }), + clearSessionMode: (sessionId) => + set((draft) => { + delete draft.sessionMode.bySessionId[sessionId]; + }), ...buildUserShellActions(set), }); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 45d11d897f..b81585bbf1 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -234,6 +234,7 @@ export type SessionRuntimeSliceActions = { clearAvailableCommands: (sessionId: string) => void; // Session mode actions setSessionMode: (sessionId: string, modeId: string) => void; + clearSessionMode: (sessionId: string) => void; // User shells actions setUserShells: (sessionId: string, shells: UserShellInfo[]) => void; setUserShellsLoading: (sessionId: string, loading: boolean) => void; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 391cdb4cb0..b086c01c41 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -352,6 +352,7 @@ export type AppState = { clearAvailableCommands: (sessionId: string) => void; // Session mode actions setSessionMode: (sessionId: string, modeId: string) => void; + clearSessionMode: (sessionId: string) => void; // User shells actions setUserShells: ( sessionId: string, diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 92c7a1d123..7fb0264c57 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -558,7 +558,8 @@ export type MessageType = | "todo" | "permission_request" | "clarification_request" - | "script_execution"; + | "script_execution" + | "agent_plan"; export type Message = { id: string; diff --git a/apps/web/lib/ws/handlers/session-mode.ts b/apps/web/lib/ws/handlers/session-mode.ts index 1d4a5e33d2..fd5df74bc7 100644 --- a/apps/web/lib/ws/handlers/session-mode.ts +++ b/apps/web/lib/ws/handlers/session-mode.ts @@ -6,13 +6,18 @@ export function registerSessionModeHandlers(store: StoreApi): WsHandle return { "session.mode_changed": (message) => { const payload = message.payload; - if (!payload?.session_id || !payload?.current_mode_id) { + if (!payload?.session_id) { return; } const sessionId = payload.session_id as string; - const modeId = payload.current_mode_id as string; + // Empty current_mode_id means the agent exited its special mode + const modeId = (payload.current_mode_id as string) || ""; - store.getState().setSessionMode(sessionId, modeId); + if (modeId) { + store.getState().setSessionMode(sessionId, modeId); + } else { + store.getState().clearSessionMode(sessionId); + } }, }; }