fix: improve claude plan mode reliability and cleanup (#346)

This commit is contained in:
Carlos Florêncio
2026-03-02 14:29:34 +00:00
committed by GitHub
parent 76c1dcce89
commit 22c0fd2a57
24 changed files with 817 additions and 98 deletions
@@ -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
@@ -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 ---
@@ -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()
}
@@ -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()
}
@@ -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,
})
}
@@ -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)
}
@@ -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
}
@@ -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{
@@ -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
+16
View File
@@ -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) {
@@ -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
+9 -7
View File
@@ -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"`
+5 -4
View File
@@ -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<string, string> = { 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";
+5 -4
View File
@@ -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({
@@ -226,6 +226,7 @@ function computeFooterState(props: TaskCreateDialogFooterProps) {
const defaultDisabled = isSessionMode
? !agentProfileId
: !hasTitle || !hasRepositorySelection || !branch || missingCtx;
return { showStartTask, splitDisabled, defaultDisabled };
}
@@ -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 (
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
{agentMode && (
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-muted-foreground">
<IconBrain className="h-3 w-3" />
<span className="capitalize">{agentMode} mode</span>
</div>
)}
<ChatInputContainer
ref={chatInputRef}
key={clarificationKey}
@@ -20,6 +20,7 @@ import { TodoMessage } from "@/components/task/chat/messages/todo-message";
import { ScriptExecutionMessage } from "@/components/task/chat/messages/script-execution-message";
import { ClarificationRequestMessage } from "@/components/task/chat/messages/clarification-request-message";
import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subagent-message";
import { AgentPlanMessage } from "@/components/task/chat/messages/agent-plan-message";
type AdapterContext = {
isTaskDescription: boolean;
@@ -184,6 +185,10 @@ const adapters: MessageAdapter[] = [
matches: (comment) => comment.type === "clarification_request",
render: (comment) => <ClarificationRequestMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "agent_plan",
render: (comment) => <AgentPlanMessage comment={comment} />,
},
{
matches: (comment) => comment.type === "script_execution",
render: (comment) => <ScriptExecutionMessage comment={comment} />,
@@ -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 (
<div
className={`markdown-body max-w-none text-sm [&>*]:my-2 [&>p]:my-2 [&>ul]:my-2 [&>ol]:my-2 ${className ?? ""}`}
>
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
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 (
<>
<div className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/40">
<span className="text-sm font-medium">{title}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
aria-label={collapsed ? "Expand plan" : "Collapse plan"}
className="h-7 w-7 cursor-pointer"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <IconPlus className="h-4 w-4" /> : <IconMinus className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open plan details"
className="h-7 w-7 cursor-pointer"
onClick={() => setDialogOpen(true)}
>
<IconMaximize className="h-4 w-4" />
</Button>
</div>
</div>
{!collapsed && (
<div className="max-h-[300px] overflow-y-auto">
<div className="px-5 py-4 border-l-2 border-border/30 ml-3">
<PlanMarkdownBody
text={body}
className="text-foreground/80 [&_strong]:text-foreground"
/>
</div>
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[70vw] w-[95vw] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<PlanMarkdownBody text={body} />
</DialogContent>
</Dialog>
</>
);
});
+1
View File
@@ -25,6 +25,7 @@ const VISIBLE_MESSAGE_TYPES: Set<string> = new Set([
"thinking",
"todo",
"script_execution",
"agent_plan",
]);
function isVisibleMessageType(type: MessageType | undefined): boolean {
@@ -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),
});
@@ -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;
+1
View File
@@ -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,
+2 -1
View File
@@ -558,7 +558,8 @@ export type MessageType =
| "todo"
| "permission_request"
| "clarification_request"
| "script_execution";
| "script_execution"
| "agent_plan";
export type Message = {
id: string;
+8 -3
View File
@@ -6,13 +6,18 @@ export function registerSessionModeHandlers(store: StoreApi<AppState>): 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);
}
},
};
}