fix: improve claude plan mode reliability and cleanup (#346)
This commit is contained in:
@@ -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
|
||||
|
||||
+50
-28
@@ -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 ---
|
||||
|
||||
|
||||
+337
@@ -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()
|
||||
}
|
||||
+30
-18
@@ -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()
|
||||
}
|
||||
|
||||
+40
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+94
-28
@@ -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)
|
||||
}
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -558,7 +558,8 @@ export type MessageType =
|
||||
| "todo"
|
||||
| "permission_request"
|
||||
| "clarification_request"
|
||||
| "script_execution";
|
||||
| "script_execution"
|
||||
| "agent_plan";
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user