feat: mock agent (#208)

This commit is contained in:
Carlos Florêncio
2026-02-07 17:48:04 +00:00
committed by GitHub
parent cc17ae7813
commit 4d41155158
20 changed files with 2076 additions and 78 deletions
+1
View File
@@ -70,6 +70,7 @@ apps/backend/bin/
# Go binaries (built locally) # Go binaries (built locally)
apps/backend/kandev apps/backend/kandev
apps/backend/agentctl apps/backend/agentctl
apps/backend/mock-agent
# Next.js # Next.js
.next/ .next/
+9 -2
View File
@@ -14,13 +14,19 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME)
.PHONY: all build build-all build-agentctl clean run dev test lint fmt vet help .PHONY: all build build-all build-agentctl build-mock-agent clean run dev test lint fmt vet help
## Default target ## Default target
all: build all: build
## Build the mock-agent binary for testing
build-mock-agent:
@echo "Building mock-agent..."
@mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mock-agent ./cmd/mock-agent
## Build the unified kandev binary ## Build the unified kandev binary
build: build-agentctl build: build-agentctl build-mock-agent
@echo "Building $(BINARY_NAME)..." @echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev
@@ -108,6 +114,7 @@ help:
@echo " build Build the unified kandev binary (default)" @echo " build Build the unified kandev binary (default)"
@echo " build-all Build all binaries (unified + individual services + agentctl)" @echo " build-all Build all binaries (unified + individual services + agentctl)"
@echo " build-agentctl Build the agentctl sidecar binary (includes embedded MCP server)" @echo " build-agentctl Build the agentctl sidecar binary (includes embedded MCP server)"
@echo " build-mock-agent Build the mock-agent binary for testing"
@echo " clean Remove build artifacts" @echo " clean Remove build artifacts"
@echo " run Build and run the application" @echo " run Build and run the application"
@echo " dev Run in development mode with a local database" @echo " dev Run in development mode with a local database"
+4 -2
View File
@@ -22,8 +22,10 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
agentRegistry := registry.NewRegistry(log) agentRegistry, _, err := registry.Provide(log)
agentRegistry.LoadDefaults() if err != nil {
return nil, nil, err
}
agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log) agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log)
userSvc := userservice.NewService(repos.User, eventBus, log) userSvc := userservice.NewService(repos.User, eventBus, log)
+263
View File
@@ -0,0 +1,263 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"math/rand"
"strings"
"time"
)
// delayRange returns min/max delay in milliseconds based on model name.
func delayRange(model string) (int, int) {
switch model {
case "mock-fast":
return 10, 50
case "mock-slow":
return 500, 3000
default:
return 100, 500
}
}
// randomDelay sleeps for a random duration within the model's delay range.
func randomDelay(model string) {
lo, hi := delayRange(model)
ms := lo + rand.Intn(hi-lo+1)
time.Sleep(time.Duration(ms) * time.Millisecond)
}
// fixedDelay sleeps for a fixed duration (for e2e scenarios).
func fixedDelay(ms int) {
time.Sleep(time.Duration(ms) * time.Millisecond)
}
// handleUserPrompt routes a user prompt to the appropriate sequence generator.
func handleUserPrompt(enc *json.Encoder, scanner *bufio.Scanner, prompt, model string) {
prompt = strings.TrimSpace(prompt)
// Emit system message at the start of every turn
emitSystemMessage(enc)
// Some commands emit their own result; track whether we need the default one.
customResult := false
switch {
case strings.EqualFold(prompt, "all"):
emitAllTypes(enc, scanner, model)
case strings.EqualFold(prompt, "/error"):
emitError(enc, model)
customResult = true
case strings.EqualFold(prompt, "/slow") || strings.HasPrefix(strings.ToLower(prompt), "/slow "):
emitSlowResponse(enc, scanner, prompt, model)
case strings.EqualFold(prompt, "/thinking"):
emitThinkingSequence(enc, model)
case strings.HasPrefix(prompt, "/tool:"):
toolName := strings.TrimPrefix(prompt, "/tool:")
emitSpecificTool(enc, scanner, strings.TrimSpace(toolName), model)
case strings.HasPrefix(prompt, "/subagent"):
emitSubagentSequence(enc, scanner, model)
case strings.HasPrefix(prompt, "/e2e:"):
scenarioName := strings.TrimPrefix(prompt, "/e2e:")
emitPredefinedScenario(enc, scanner, strings.TrimSpace(scenarioName))
// e2e:error emits its own result
if strings.TrimSpace(scenarioName) == "error" {
customResult = true
}
case strings.HasPrefix(prompt, "/todo"):
emitTodoSequence(enc, model)
default:
emitRandomResponse(enc, scanner, prompt, model)
}
if !customResult {
emitResult(enc, false, "")
}
}
// emitSystemMessage writes the system message to stdout.
func emitSystemMessage(enc *json.Encoder) {
_ = enc.Encode(SystemMsg{
Type: TypeSystem,
SessionID: sessionID,
SessionStatus: "active",
})
}
// emitResult writes the final result message to stdout.
func emitResult(enc *json.Encoder, isError bool, errText string) {
var resultJSON json.RawMessage
if isError {
resultJSON, _ = json.Marshal(errText)
} else {
resultJSON, _ = json.Marshal(ResultData{
Text: "Mock agent completed successfully.",
SessionID: sessionID,
})
}
_ = enc.Encode(ResultMsg{
Type: TypeResult,
Result: resultJSON,
CostUSD: 0.0042,
DurationMS: 1500,
DurationAPIMS: 1200,
IsError: isError,
NumTurns: 1,
TotalInputTokens: 1500,
TotalOutputTokens: 500,
ModelUsage: map[string]ModelUsageStats{
"mock-default": {ContextWindow: 200000},
},
})
}
// emitError emits an error result.
func emitError(enc *json.Encoder, model string) {
randomDelay(model)
emitTextBlock(enc, "Simulating an error condition...", "")
randomDelay(model)
emitResult(enc, true, "Mock error: something went wrong during processing")
// Return without emitting the normal result (handler skips the final emitResult
// because we already emitted one). We handle this by making the caller check.
}
// emitSlowResponse generates a response with configurable total duration.
// Accepts "/slow" (defaults to 5s) or "/slow <duration>" (e.g. "/slow 60s", "/slow 2m").
func emitSlowResponse(enc *json.Encoder, _ *bufio.Scanner, prompt, model string) {
totalDuration := 5 * time.Second
parts := strings.Fields(prompt)
if len(parts) >= 2 {
if d, err := time.ParseDuration(parts[1]); err == nil && d > 0 {
totalDuration = d
}
}
// Divide total duration into steps
steps := 5
stepDelay := totalDuration / time.Duration(steps)
emitThinking(enc, model)
time.Sleep(stepDelay)
emitTextBlock(enc, fmt.Sprintf("Running slow response (%s total)...", totalDuration), "")
time.Sleep(stepDelay)
emitReadFile(enc, model)
time.Sleep(stepDelay)
emitCodeSearch(enc, model)
time.Sleep(stepDelay)
emitTextBlock(enc, fmt.Sprintf("Slow response complete after %s.", totalDuration), "")
time.Sleep(stepDelay)
}
// emitRandomResponse generates a random mix of 2-5 events.
func emitRandomResponse(enc *json.Encoder, _ *bufio.Scanner, prompt, model string) {
generators := []func(){
func() { emitThinking(enc, model) },
func() { emitTextBlock(enc, "I'll help you with that. Let me look into it.", "") },
func() { emitReadFile(enc, model) },
func() { emitCodeSearch(enc, model) },
func() { emitWebFetch(enc, model) },
}
// Always start with thinking
emitThinking(enc, model)
randomDelay(model)
// Pick 1-4 more random events
count := 1 + rand.Intn(4)
for i := 0; i < count; i++ {
idx := rand.Intn(len(generators))
generators[idx]()
randomDelay(model)
}
// End with a text summary
emitTextBlock(enc, "I've completed the analysis of your request: \""+prompt+"\". Everything looks good!", "")
}
// emitAllTypes emits one of every message type.
func emitAllTypes(enc *json.Encoder, scanner *bufio.Scanner, model string) {
emitThinking(enc, model)
randomDelay(model)
emitTextBlock(enc, "Starting comprehensive demonstration of all message types...", "")
randomDelay(model)
emitReadFile(enc, model)
randomDelay(model)
emitEditFile(enc, scanner, model)
randomDelay(model)
emitShellExec(enc, scanner, model)
randomDelay(model)
emitCodeSearch(enc, model)
randomDelay(model)
emitSubagent(enc, scanner, model)
randomDelay(model)
emitTodo(enc, model)
randomDelay(model)
emitWebFetch(enc, model)
randomDelay(model)
emitTextBlock(enc, "All message types demonstrated successfully!", "")
}
// emitThinkingSequence emits extended thinking/reasoning blocks.
func emitThinkingSequence(enc *json.Encoder, model string) {
thoughts := []string{
"Let me analyze this problem step by step...",
"First, I need to consider the architecture and how the components interact.",
"The key insight is that we need to handle both synchronous and asynchronous flows.",
"I should also consider edge cases: what happens when the input is empty? What about concurrent access?",
"After careful analysis, I believe the best approach is to use a channel-based pattern with proper synchronization.",
}
for _, thought := range thoughts {
randomDelay(model)
emitThinkingBlock(enc, thought, "")
}
randomDelay(model)
emitTextBlock(enc, "After careful reasoning, here is my analysis:\n\n1. The architecture is sound\n2. Error handling covers edge cases\n3. The implementation follows Go best practices", "")
}
// emitSpecificTool emits a single specific tool call.
func emitSpecificTool(enc *json.Encoder, scanner *bufio.Scanner, toolName, model string) {
switch strings.ToLower(toolName) {
case "read":
emitReadFile(enc, model)
case "edit":
emitEditFile(enc, scanner, model)
case "exec", "bash":
emitShellExec(enc, scanner, model)
case "search", "grep":
emitCodeSearch(enc, model)
case "webfetch", "web":
emitWebFetch(enc, model)
default:
emitTextBlock(enc, "Unknown tool: "+toolName+". Available: read, edit, exec, search, webfetch", "")
}
}
// emitTodoSequence emits a todo management sequence.
func emitTodoSequence(enc *json.Encoder, model string) {
emitThinking(enc, model)
randomDelay(model)
emitTextBlock(enc, "I'll create a task list for this work.", "")
randomDelay(model)
emitTodo(enc, model)
randomDelay(model)
emitTextBlock(enc, "Task list has been updated.", "")
}
// emitSubagentSequence emits a subagent Task sequence.
func emitSubagentSequence(enc *json.Encoder, scanner *bufio.Scanner, model string) {
emitThinking(enc, model)
randomDelay(model)
emitTextBlock(enc, "I'll delegate this to a subagent for parallel processing.", "")
randomDelay(model)
emitSubagent(enc, scanner, model)
randomDelay(model)
emitTextBlock(enc, "Subagent task completed successfully.", "")
}
+107
View File
@@ -0,0 +1,107 @@
// Package main implements a mock agent binary that speaks the claude-code
// stream-json protocol over stdin/stdout. It generates simulated responses
// for rapid feature testing and e2e web app tests.
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
)
// sessionID is a unique identifier for this mock-agent process instance.
// Each session spawns its own process, so using PID ensures uniqueness
// across parallel sessions.
var sessionID = fmt.Sprintf("mock-session-%d", os.Getpid())
func main() {
model := parseModelFlag()
scanner := bufio.NewScanner(os.Stdin)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 10*1024*1024)
enc := json.NewEncoder(os.Stdout)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var msg IncomingMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
switch msg.Type {
case TypeControlRequest:
handleControlRequest(enc, msg)
case TypeUser:
if msg.Message != nil {
handleUserPrompt(enc, scanner, msg.Message.Content, model)
}
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "mock-agent: scanner error: %v\n", err)
os.Exit(1)
}
}
// parseModelFlag extracts --model value from command line args.
func parseModelFlag() string {
return parseModelFromArgs(os.Args)
}
// parseModelFromArgs extracts --model value from the given args slice.
func parseModelFromArgs(args []string) string {
for i, arg := range args[1:] {
if arg == "--model" && i+1 < len(args)-1 {
return args[i+2]
}
if strings.HasPrefix(arg, "--model=") {
return strings.TrimPrefix(arg, "--model=")
}
}
return "mock-default"
}
// handleControlRequest responds to control requests from the backend.
func handleControlRequest(enc *json.Encoder, msg IncomingMessage) {
// We only handle the initialize request from the backend
if msg.RequestID != "" {
resp := ControlResponseMsg{
Type: TypeControlResponse,
Response: ControlResponseBody{
Subtype: "success",
RequestID: msg.RequestID,
Response: &InitializeResponse{
Commands: []Command{
{Name: "error", Description: "Simulate an error result"},
{Name: "slow", Description: "Random response with slow delays", ArgumentHint: "<duration e.g. 5s, 30s, 2m>"},
{Name: "thinking", Description: "Extended thinking/reasoning blocks"},
{Name: "tool:read", Description: "Single file read"},
{Name: "tool:edit", Description: "Single file edit (with permission)"},
{Name: "tool:exec", Description: "Single shell command (with permission)"},
{Name: "tool:search", Description: "Single code search"},
{Name: "subagent", Description: "Subagent Task with nested child messages"},
{Name: "todo", Description: "Todo management sequence"},
{Name: "e2e:simple-message", Description: "E2E: text only, fixed timing"},
{Name: "e2e:read-and-edit", Description: "E2E: read + edit + text"},
{Name: "e2e:permission-flow", Description: "E2E: tool requiring permission"},
{Name: "e2e:error", Description: "E2E: error result"},
{Name: "e2e:subagent", Description: "E2E: subagent with child messages"},
{Name: "e2e:all-tools", Description: "E2E: one of each tool type"},
{Name: "e2e:multi-turn", Description: "E2E: minimal multi-turn response"},
},
Agents: []string{"Bash", "Read", "Edit", "Grep", "Glob", "Task"},
},
},
}
_ = enc.Encode(resp)
}
}
@@ -0,0 +1,229 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestParseModelFromArgs(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "no flag returns default",
args: []string{"mock-agent"},
want: "mock-default",
},
{
name: "separate flag and value",
args: []string{"mock-agent", "--model", "mock-slow"},
want: "mock-slow",
},
{
name: "equals syntax",
args: []string{"mock-agent", "--model=mock-fast"},
want: "mock-fast",
},
{
name: "flag with other args before",
args: []string{"mock-agent", "--verbose", "--model", "mock-slow"},
want: "mock-slow",
},
{
name: "flag with other args after",
args: []string{"mock-agent", "--model", "mock-fast", "--verbose"},
want: "mock-fast",
},
{
name: "dangling flag without value",
args: []string{"mock-agent", "--model"},
want: "mock-default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseModelFromArgs(tt.args)
if got != tt.want {
t.Errorf("parseModelFromArgs(%v) = %q, want %q", tt.args, got, tt.want)
}
})
}
}
func TestDelayRange(t *testing.T) {
tests := []struct {
model string
wantMinLo int
wantMinHi int
wantMaxLo int
wantMaxHi int
}{
{"mock-fast", 10, 10, 50, 50},
{"mock-slow", 500, 500, 3000, 3000},
{"mock-default", 100, 100, 500, 500},
{"unknown-model", 100, 100, 500, 500},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
lo, hi := delayRange(tt.model)
if lo != tt.wantMinLo || hi != tt.wantMaxHi {
t.Errorf("delayRange(%q) = (%d, %d), want (%d, %d)", tt.model, lo, hi, tt.wantMinLo, tt.wantMaxHi)
}
})
}
}
func TestReadFileSnippet(t *testing.T) {
// Create a temp file with known content
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
content := "line1\nline2\nline3\nline4\nline5\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
t.Run("reads up to maxLines", func(t *testing.T) {
result := readFileSnippet(path, 3)
expected := "line1\nline2\nline3\n"
if result != expected {
t.Errorf("readFileSnippet(%q, 3) = %q, want %q", path, result, expected)
}
})
t.Run("reads all lines when maxLines exceeds file", func(t *testing.T) {
result := readFileSnippet(path, 100)
expected := "line1\nline2\nline3\nline4\nline5\n"
if result != expected {
t.Errorf("readFileSnippet(%q, 100) = %q, want %q", path, result, expected)
}
})
t.Run("returns fallback for missing file", func(t *testing.T) {
result := readFileSnippet("/nonexistent/file.txt", 10)
if result != "// (file not readable)\n" {
t.Errorf("readFileSnippet(missing) = %q, want fallback", result)
}
})
t.Run("handles empty file", func(t *testing.T) {
emptyPath := filepath.Join(dir, "empty.txt")
if err := os.WriteFile(emptyPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
result := readFileSnippet(emptyPath, 10)
if result != "\n" {
t.Errorf("readFileSnippet(empty) = %q, want %q", result, "\n")
}
})
}
func TestPickEditableFragment(t *testing.T) {
dir := t.TempDir()
t.Run("returns fallback for missing file", func(t *testing.T) {
old, new_ := pickEditableFragment("/nonexistent/file.go")
if old != "hello" || new_ != "hello_mock" {
t.Errorf("pickEditableFragment(missing) = (%q, %q), want (\"hello\", \"hello_mock\")", old, new_)
}
})
t.Run("returns fallback for file with only short lines", func(t *testing.T) {
path := filepath.Join(dir, "short.txt")
if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0644); err != nil {
t.Fatal(err)
}
old, new_ := pickEditableFragment(path)
if old != "original" || new_ != "modified" {
t.Errorf("pickEditableFragment(short) = (%q, %q), want (\"original\", \"modified\")", old, new_)
}
})
t.Run("produces different old and new strings", func(t *testing.T) {
path := filepath.Join(dir, "code.go")
content := "package main\n\nfunc main() {\n\tfmt.Println(\"hello world\")\n}\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
old, new_ := pickEditableFragment(path)
if old == new_ {
t.Errorf("pickEditableFragment should produce different old and new, got %q", old)
}
if old == "" {
t.Error("old string should not be empty")
}
})
}
func TestDiscoverFiles(t *testing.T) {
// Reset global state
workspaceFiles = nil
// Save and restore working directory
origWd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origWd) }()
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// Create test files
for _, f := range []struct{ name, content string }{
{"main.go", "package main"},
{"util.ts", "export {}"},
{"image.png", "fake png"}, // should be skipped (non-text extension)
} {
if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.content), 0644); err != nil {
t.Fatal(err)
}
}
// Create a skipped directory
if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "node_modules", "lib.js"), []byte("//"), 0644); err != nil {
t.Fatal(err)
}
// Reset cache before test
workspaceFiles = nil
files := discoverFiles()
// Should find .go and .ts but not .png or node_modules
foundGo, foundTs, foundPng, foundNodeModules := false, false, false, false
for _, f := range files {
switch filepath.Base(f.absPath) {
case "main.go":
foundGo = true
case "util.ts":
foundTs = true
case "image.png":
foundPng = true
case "lib.js":
foundNodeModules = true
}
}
if !foundGo {
t.Error("expected to find main.go")
}
if !foundTs {
t.Error("expected to find util.ts")
}
if foundPng {
t.Error("should not find image.png (not a text extension)")
}
if foundNodeModules {
t.Error("should not find files in node_modules")
}
// Reset global state for other tests
workspaceFiles = nil
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
)
// requestPermission sends a control_request for tool permission and waits for the response.
// Returns true if permission was granted, false if denied.
func requestPermission(enc *json.Encoder, scanner *bufio.Scanner, toolName, toolUseID string, input map[string]any) bool {
requestID := fmt.Sprintf("mock-perm-%s-%s", toolName, toolUseID)
// Emit control_request to stdout
_ = enc.Encode(ControlRequestMsg{
Type: TypeControlRequest,
RequestID: requestID,
Request: ControlRequestBody{
Subtype: "can_use_tool",
ToolName: toolName,
Input: input,
ToolUseID: toolUseID,
},
})
// Read stdin for control_response
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var msg IncomingMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
if msg.Type == TypeControlResponse && msg.Response != nil {
if msg.Response.Result != nil {
return msg.Response.Result.Behavior == "allow"
}
// Error response means denied
return false
}
}
return false
}
+341
View File
@@ -0,0 +1,341 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"strings"
)
// Predefined e2e test scenarios with fixed timing for deterministic test assertions.
// emitPredefinedScenario dispatches to a named e2e scenario.
func emitPredefinedScenario(enc *json.Encoder, scanner *bufio.Scanner, name string) {
switch name {
case "simple-message":
scenarioSimpleMessage(enc)
case "read-and-edit":
scenarioReadAndEdit(enc, scanner)
case "permission-flow":
scenarioPermissionFlow(enc, scanner)
case "error":
scenarioError(enc)
case "subagent":
scenarioSubagent(enc, scanner)
case "all-tools":
scenarioAllTools(enc, scanner)
case "multi-turn":
scenarioMultiTurn(enc)
default:
emitTextBlock(enc, "Unknown e2e scenario: "+name+". Available: simple-message, read-and-edit, permission-flow, error, subagent, all-tools, multi-turn", "")
}
}
// scenarioSimpleMessage: text only with fixed 100ms delays.
func scenarioSimpleMessage(enc *json.Encoder) {
fixedDelay(100)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockThinking, Thinking: "Processing the request..."},
},
Model: "mock-default",
Usage: defaultUsage(),
},
})
fixedDelay(100)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockText, Text: "This is a simple mock response for e2e testing."},
},
Model: "mock-default",
StopReason: "end_turn",
Usage: defaultUsage(),
},
})
}
// scenarioReadAndEdit: read -> edit -> text with fixed delays, using real files.
func scenarioReadAndEdit(enc *json.Encoder, scanner *bufio.Scanner) {
f := randomFile()
snippet := readFileSnippet(f.absPath, 20)
oldStr, newStr := pickEditableFragment(f.absPath)
fixedDelay(50)
// Read file
readID := nextToolID()
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockToolUse, ID: readID, Name: ToolRead, Input: map[string]any{"file_path": f.absPath}},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
fixedDelay(50)
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{Type: BlockToolResult, ToolUseID: readID, Content: snippet},
},
},
})
fixedDelay(50)
// Edit file (with permission)
editID := nextToolID()
editInput := map[string]any{
"file_path": f.absPath,
"old_string": oldStr,
"new_string": newStr,
}
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockToolUse, ID: editID, Name: ToolEdit, Input: editInput},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
allowed := requestPermission(enc, scanner, ToolEdit, editID, editInput)
fixedDelay(50)
if allowed {
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{Type: BlockToolResult, ToolUseID: editID, Content: "File edited successfully: " + f.absPath},
},
},
})
} else {
emitTextBlock(enc, "Edit was denied.", "")
}
fixedDelay(50)
emitTextBlock(enc, "Read and edit scenario complete.", "")
}
// scenarioPermissionFlow: tool requiring permission with fixed delays.
func scenarioPermissionFlow(enc *json.Encoder, scanner *bufio.Scanner) {
fixedDelay(50)
bashID := nextToolID()
bashInput := map[string]any{
"command": "echo 'testing permissions'",
"description": "Test permission flow",
}
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockToolUse, ID: bashID, Name: ToolBash, Input: bashInput},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
allowed := requestPermission(enc, scanner, ToolBash, bashID, bashInput)
fixedDelay(50)
if allowed {
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{Type: BlockToolResult, ToolUseID: bashID, Content: "testing permissions"},
},
},
})
emitTextBlock(enc, "Permission was granted and command executed.", "")
} else {
emitTextBlock(enc, "Permission was denied.", "")
}
}
// scenarioError: error result with fixed delays.
func scenarioError(enc *json.Encoder) {
fixedDelay(100)
emitTextBlock(enc, "About to encounter an error...", "")
fixedDelay(100)
emitResult(enc, true, "E2E test error: simulated failure")
}
// scenarioSubagent: subagent with child messages and fixed delays.
func scenarioSubagent(enc *json.Encoder, scanner *bufio.Scanner) {
taskToolID := nextToolID()
fixedDelay(50)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockToolUse, ID: taskToolID, Name: ToolTask, Input: map[string]any{
"description": "E2E subagent test",
"prompt": "Run e2e subagent scenario",
}},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
fixedDelay(50)
_ = enc.Encode(SystemMsg{Type: TypeSystem, SessionID: sessionID, SessionStatus: "active"})
fixedDelay(50)
emitTextBlock(enc, "Subagent working on the task...", taskToolID)
fixedDelay(50)
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{Type: BlockToolResult, ToolUseID: taskToolID, Content: "E2E subagent completed"},
},
},
})
fixedDelay(50)
emitTextBlock(enc, "Subagent scenario complete.", "")
}
// scenarioAllTools: one of each tool type with fixed delays, using real files.
func scenarioAllTools(enc *json.Encoder, scanner *bufio.Scanner) {
used := map[string]bool{}
readFile := randomFile()
used[readFile.absPath] = true
grepFile := randomFileExcluding(used)
used[grepFile.absPath] = true
editFile := randomFileExcluding(used)
fixedDelay(50)
emitThinkingBlock(enc, "Running all tools...", "")
// Read
fixedDelay(50)
readID := nextToolID()
snippet := readFileSnippet(readFile.absPath, 20)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{{Type: BlockToolUse, ID: readID, Name: ToolRead, Input: map[string]any{"file_path": readFile.absPath}}},
Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(),
},
})
fixedDelay(50)
_ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: readID, Content: snippet}}}})
// Grep
fixedDelay(50)
grepID := nextToolID()
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{{Type: BlockToolUse, ID: grepID, Name: ToolGrep, Input: map[string]any{"pattern": "func ", "path": grepFile.absPath}}},
Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(),
},
})
fixedDelay(50)
paths := randomFilePaths(3)
var grepResults []string
for i, p := range paths {
grepResults = append(grepResults, fmt.Sprintf("%s:%d: func found here", p, (i+1)*10))
}
_ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: grepID, Content: strings.Join(grepResults, "\n")}}}})
// Edit (with permission)
fixedDelay(50)
editID := nextToolID()
oldStr, newStr := pickEditableFragment(editFile.absPath)
editInput := map[string]any{"file_path": editFile.absPath, "old_string": oldStr, "new_string": newStr}
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{{Type: BlockToolUse, ID: editID, Name: ToolEdit, Input: editInput}},
Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(),
},
})
allowed := requestPermission(enc, scanner, ToolEdit, editID, editInput)
fixedDelay(50)
if allowed {
_ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: editID, Content: "File edited successfully: " + editFile.absPath}}}})
} else {
emitTextBlock(enc, "Edit denied.", "")
}
// Bash (with permission)
fixedDelay(50)
bashID := nextToolID()
bashInput := map[string]any{"command": "echo done", "description": "Print done"}
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{{Type: BlockToolUse, ID: bashID, Name: ToolBash, Input: bashInput}},
Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(),
},
})
allowed = requestPermission(enc, scanner, ToolBash, bashID, bashInput)
fixedDelay(50)
if allowed {
_ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: bashID, Content: "done"}}}})
} else {
emitTextBlock(enc, "Bash denied.", "")
}
// WebFetch
fixedDelay(50)
webID := nextToolID()
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{{Type: BlockToolUse, ID: webID, Name: ToolWebFetch, Input: map[string]any{"url": "https://example.com", "prompt": "Summarize"}}},
Model: "mock-default", StopReason: "tool_use", Usage: defaultUsage(),
},
})
fixedDelay(50)
_ = enc.Encode(UserMsg{Type: TypeUser, Message: UserMsgBody{Role: "user", Content: []ContentBlock{{Type: BlockToolResult, ToolUseID: webID, Content: "Example page content"}}}})
fixedDelay(50)
emitTextBlock(enc, "All tools scenario complete.", "")
}
// scenarioMultiTurn: minimal response for multi-turn test.
func scenarioMultiTurn(enc *json.Encoder) {
fixedDelay(50)
emitTextBlock(enc, "Multi-turn response ready. Send another message to continue.", "")
}
+452
View File
@@ -0,0 +1,452 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"strings"
)
var toolCallCounter int
func nextToolID() string {
toolCallCounter++
return fmt.Sprintf("mock_tool_%04d", toolCallCounter)
}
func defaultUsage() *Usage {
return &Usage{
InputTokens: 1200,
OutputTokens: 350,
}
}
// --- Atomic emitters ---
// emitThinking emits an assistant message with a thinking block.
func emitThinking(enc *json.Encoder, model string) {
randomDelay(model)
emitThinkingBlock(enc, "Analyzing the request and considering the best approach...", "")
}
// emitThinkingBlock emits an assistant message with a thinking content block.
func emitThinkingBlock(enc *json.Encoder, thought, parentToolUseID string) {
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
ParentToolUseID: parentToolUseID,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockThinking, Thinking: thought},
},
Model: "mock-default",
Usage: defaultUsage(),
},
})
}
// emitTextBlock emits an assistant message with a text content block.
func emitTextBlock(enc *json.Encoder, text, parentToolUseID string) {
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
ParentToolUseID: parentToolUseID,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{Type: BlockText, Text: text},
},
Model: "mock-default",
StopReason: "end_turn",
Usage: defaultUsage(),
},
})
}
// emitReadFile emits a Read tool_use followed by a tool_result using a real workspace file.
func emitReadFile(enc *json.Encoder, model string) {
toolID := nextToolID()
f := randomFile()
snippet := readFileSnippet(f.absPath, 30)
randomDelay(model)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: toolID,
Name: ToolRead,
Input: map[string]any{
"file_path": f.absPath,
},
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
randomDelay(model)
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: toolID,
Content: snippet,
},
},
},
})
}
// emitEditFile emits an Edit tool_use with a real file and real content fragment.
func emitEditFile(enc *json.Encoder, scanner *bufio.Scanner, model string) {
toolID := nextToolID()
f := randomFile()
oldStr, newStr := pickEditableFragment(f.absPath)
randomDelay(model)
input := map[string]any{
"file_path": f.absPath,
"old_string": oldStr,
"new_string": newStr,
}
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: toolID,
Name: ToolEdit,
Input: input,
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
allowed := requestPermission(enc, scanner, ToolEdit, toolID, input)
if allowed {
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: toolID,
Content: "File edited successfully: " + f.absPath,
},
},
},
})
} else {
emitTextBlock(enc, "Permission denied for Edit, skipping.", "")
}
}
// emitShellExec emits a Bash tool_use with permission request, then tool_result.
func emitShellExec(enc *json.Encoder, scanner *bufio.Scanner, model string) {
toolID := nextToolID()
randomDelay(model)
input := map[string]any{
"command": "go test ./...",
"description": "Run all tests",
}
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: toolID,
Name: ToolBash,
Input: input,
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
allowed := requestPermission(enc, scanner, ToolBash, toolID, input)
if allowed {
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: toolID,
Content: "ok \tgithub.com/example/project\t0.042s\nPASS",
},
},
},
})
} else {
emitTextBlock(enc, "Permission denied for Bash, skipping.", "")
}
}
// emitCodeSearch emits a Grep tool_use using real workspace file paths.
func emitCodeSearch(enc *json.Encoder, model string) {
toolID := nextToolID()
randomDelay(model)
searchPatterns := []string{"func ", "import ", "TODO", "return ", "error", "type "}
pattern := searchPatterns[toolCallCounter%len(searchPatterns)]
f := randomFile()
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: toolID,
Name: ToolGrep,
Input: map[string]any{
"pattern": pattern,
"path": f.absPath,
},
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
randomDelay(model)
// Build realistic search results using real file paths
paths := randomFilePaths(3)
var results []string
for i, p := range paths {
results = append(results, fmt.Sprintf("%s:%d:%s found here", p, (i+1)*10, strings.TrimSpace(pattern)))
}
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: toolID,
Content: strings.Join(results, "\n"),
},
},
},
})
}
// emitSubagent emits a Task tool_use with nested child messages using real files.
func emitSubagent(enc *json.Encoder, scanner *bufio.Scanner, model string) {
taskToolID := nextToolID()
randomDelay(model)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: taskToolID,
Name: ToolTask,
Input: map[string]any{
"description": "Explore codebase",
"prompt": "Find all files and summarize the project structure",
},
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
randomDelay(model)
_ = enc.Encode(SystemMsg{
Type: TypeSystem,
SessionID: sessionID,
SessionStatus: "active",
})
randomDelay(model)
emitThinkingBlock(enc, "Exploring the project structure...", taskToolID)
randomDelay(model)
paths := randomFilePaths(5)
emitTextBlock(enc, fmt.Sprintf("Found %d files. The project structure looks well-organized.", len(paths)), taskToolID)
randomDelay(model)
// Child Glob tool
childToolID := nextToolID()
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
ParentToolUseID: taskToolID,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: childToolID,
Name: ToolGlob,
Input: map[string]any{
"pattern": "**/*",
},
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
randomDelay(model)
_ = enc.Encode(UserMsg{
Type: TypeUser,
ParentToolUseID: taskToolID,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: childToolID,
Content: strings.Join(paths, "\n"),
},
},
},
})
randomDelay(model)
emitTextBlock(enc, "Project structure analysis complete.", taskToolID)
randomDelay(model)
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: taskToolID,
Content: fmt.Sprintf("Subagent completed: Found %d files across the project.", len(paths)),
},
},
},
})
}
// emitTodo emits a TodoWrite tool_use followed by a tool_result.
func emitTodo(enc *json.Encoder, model string) {
toolID := nextToolID()
randomDelay(model)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: toolID,
Name: ToolTodoWrite,
Input: map[string]any{
"todos": []map[string]any{
{"id": "1", "content": "Review code changes", "status": "in_progress"},
{"id": "2", "content": "Run tests", "status": "pending"},
{"id": "3", "content": "Update documentation", "status": "pending"},
},
},
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
randomDelay(model)
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: toolID,
Content: "Todo list updated: 3 items (1 in progress, 2 pending)",
},
},
},
})
}
// emitWebFetch emits a WebFetch tool_use followed by a tool_result.
func emitWebFetch(enc *json.Encoder, model string) {
toolID := nextToolID()
randomDelay(model)
_ = enc.Encode(AssistantMsg{
Type: TypeAssistant,
Message: AssistantBody{
Role: "assistant",
Content: []ContentBlock{
{
Type: BlockToolUse,
ID: toolID,
Name: ToolWebFetch,
Input: map[string]any{
"url": "https://example.com/api/docs",
"prompt": "Extract the API endpoints and their descriptions",
},
},
},
Model: "mock-default",
StopReason: "tool_use",
Usage: defaultUsage(),
},
})
randomDelay(model)
_ = enc.Encode(UserMsg{
Type: TypeUser,
Message: UserMsgBody{
Role: "user",
Content: []ContentBlock{
{
Type: BlockToolResult,
ToolUseID: toolID,
Content: "API Documentation:\n- GET /api/v1/users - List all users\n- POST /api/v1/users - Create a new user\n- GET /api/v1/users/:id - Get user by ID\n- PUT /api/v1/users/:id - Update user\n- DELETE /api/v1/users/:id - Delete user",
},
},
},
})
}
+192
View File
@@ -0,0 +1,192 @@
package main
import "encoding/json"
// Message types
const (
TypeSystem = "system"
TypeAssistant = "assistant"
TypeUser = "user"
TypeResult = "result"
TypeControlRequest = "control_request"
TypeControlResponse = "control_response"
)
// Content block types
const (
BlockText = "text"
BlockThinking = "thinking"
BlockToolUse = "tool_use"
BlockToolResult = "tool_result"
)
// Tool names matching Claude Code conventions
const (
ToolBash = "Bash"
ToolEdit = "Edit"
ToolRead = "Read"
ToolGlob = "Glob"
ToolGrep = "Grep"
ToolTask = "Task"
ToolTodoWrite = "TodoWrite"
ToolWebFetch = "WebFetch"
)
// IncomingMessage is a minimal struct for parsing stdin messages.
type IncomingMessage struct {
Type string `json:"type"`
RequestID string `json:"request_id,omitempty"`
Message *IncomingBody `json:"message,omitempty"`
Response *IncomingControl `json:"response,omitempty"`
}
// IncomingBody is the message body for user messages.
type IncomingBody struct {
Role string `json:"role"`
Content string `json:"content"`
}
// IncomingControl is the response body for control_response messages.
type IncomingControl struct {
Subtype string `json:"subtype"`
RequestID string `json:"request_id,omitempty"`
Result *PermissionReply `json:"result,omitempty"`
}
// PermissionReply is the result from a permission response.
type PermissionReply struct {
Behavior string `json:"behavior"`
}
// --- Outgoing message types (written to stdout) ---
// SystemMsg is the system message emitted at the start of each turn.
type SystemMsg struct {
Type string `json:"type"`
SessionID string `json:"session_id"`
SessionStatus string `json:"session_status"`
}
// AssistantMsg is an assistant message with content blocks.
type AssistantMsg struct {
Type string `json:"type"`
ParentToolUseID string `json:"parent_tool_use_id,omitempty"`
Message AssistantBody `json:"message"`
}
// AssistantBody is the body of an assistant message.
type AssistantBody struct {
Role string `json:"role"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason,omitempty"`
Usage *Usage `json:"usage,omitempty"`
}
// ContentBlock represents a content block in an assistant or user message.
type ContentBlock struct {
Type string `json:"type"`
// text block
Text string `json:"text,omitempty"`
// thinking block
Thinking string `json:"thinking,omitempty"`
// tool_use block
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]any `json:"input,omitempty"`
// tool_result block
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
// Usage contains token usage information.
type Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"`
}
// UserMsg is a user message (used for tool results).
type UserMsg struct {
Type string `json:"type"`
ParentToolUseID string `json:"parent_tool_use_id,omitempty"`
Message UserMsgBody `json:"message"`
}
// UserMsgBody is the body of a user message with tool results.
type UserMsgBody struct {
Role string `json:"role"`
Content []ContentBlock `json:"content"`
}
// ResultMsg is the final result message for a turn.
type ResultMsg struct {
Type string `json:"type"`
Result json.RawMessage `json:"result"`
CostUSD float64 `json:"cost_usd"`
DurationMS int64 `json:"duration_ms"`
DurationAPIMS int64 `json:"duration_api_ms"`
IsError bool `json:"is_error"`
NumTurns int `json:"num_turns"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
ModelUsage map[string]ModelUsageStats `json:"model_usage,omitempty"`
}
// ModelUsageStats per-model usage statistics.
type ModelUsageStats struct {
ContextWindow int64 `json:"context_window"`
}
// ResultData is the result object for successful completions.
type ResultData struct {
Text string `json:"text"`
SessionID string `json:"session_id"`
}
// ControlRequestMsg is a control request emitted to stdout (permission requests).
type ControlRequestMsg struct {
Type string `json:"type"`
RequestID string `json:"request_id"`
Request ControlRequestBody `json:"request"`
}
// ControlRequestBody is the body of a control request.
type ControlRequestBody struct {
Subtype string `json:"subtype"`
ToolName string `json:"tool_name,omitempty"`
Input map[string]any `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
}
// ControlResponseMsg is a control response emitted to stdout (for initialize).
type ControlResponseMsg struct {
Type string `json:"type"`
Response ControlResponseBody `json:"response"`
}
// ControlResponseBody is the body of a control response.
type ControlResponseBody struct {
Subtype string `json:"subtype"`
RequestID string `json:"request_id"`
Response *InitializeResponse `json:"response,omitempty"`
}
// InitializeResponse is the response to an initialize control request.
type InitializeResponse struct {
Commands []Command `json:"commands"`
Agents []string `json:"agents"`
}
// Command is an available slash command.
type Command struct {
Name string `json:"name"`
Description string `json:"description"`
ArgumentHint string `json:"argumentHint,omitempty"`
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"bufio"
"math/rand"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// workspaceFiles holds discovered text files from the working directory.
// Populated once on first use via discoverFiles().
var workspaceFiles []fileInfo
type fileInfo struct {
absPath string // absolute path
relPath string // relative to working directory
}
// textExtensions are file extensions considered "text files" for mock operations.
var textExtensions = map[string]bool{
".go": true, ".ts": true, ".tsx": true, ".js": true, ".jsx": true,
".py": true, ".rs": true, ".java": true, ".c": true, ".h": true,
".css": true, ".html": true, ".json": true, ".yaml": true, ".yml": true,
".toml": true, ".md": true, ".txt": true, ".sh": true, ".sql": true,
".graphql": true, ".proto": true, ".xml": true, ".svg": true,
".env": true, ".gitignore": true, ".dockerignore": true,
}
// skipDirs are directories to skip during file discovery.
var skipDirs = map[string]bool{
".git": true, "node_modules": true, "vendor": true, ".next": true,
"dist": true, "build": true, "bin": true, "__pycache__": true,
".cache": true, ".turbo": true, "coverage": true,
}
const maxFiles = 200
// discoverFiles walks the working directory and collects text files.
func discoverFiles() []fileInfo {
if workspaceFiles != nil {
return workspaceFiles
}
wd, err := os.Getwd()
if err != nil {
return nil
}
var files []fileInfo
_ = filepath.Walk(wd, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip errors
}
if info.IsDir() {
if skipDirs[info.Name()] {
return filepath.SkipDir
}
return nil
}
if len(files) >= maxFiles {
return filepath.SkipAll
}
ext := strings.ToLower(filepath.Ext(info.Name()))
if !textExtensions[ext] && !textExtensions[info.Name()] {
return nil
}
// Skip very large files
if info.Size() > 100*1024 {
return nil
}
rel, _ := filepath.Rel(wd, path)
files = append(files, fileInfo{absPath: path, relPath: rel})
return nil
})
workspaceFiles = files
return workspaceFiles
}
// randomFile returns a random file from the workspace, or a fallback.
func randomFile() fileInfo {
files := discoverFiles()
if len(files) == 0 {
return fileInfo{absPath: "/workspace/example.txt", relPath: "example.txt"}
}
return files[rand.Intn(len(files))]
}
// randomFileExcluding returns a random file that isn't in the exclude set.
func randomFileExcluding(exclude map[string]bool) fileInfo {
files := discoverFiles()
if len(files) == 0 {
return fileInfo{absPath: "/workspace/example.txt", relPath: "example.txt"}
}
// Try up to 20 times to find a non-excluded file
for range 20 {
f := files[rand.Intn(len(files))]
if !exclude[f.absPath] {
return f
}
}
return files[rand.Intn(len(files))]
}
// readFileSnippet reads up to maxLines lines from a file.
func readFileSnippet(path string, maxLines int) string {
f, err := os.Open(path)
if err != nil {
return "// (file not readable)\n"
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
var lines []string
for scanner.Scan() && len(lines) < maxLines {
lines = append(lines, scanner.Text())
}
return strings.Join(lines, "\n") + "\n"
}
// pickEditableFragment finds a line in the file suitable for a mock edit.
// Returns (oldString, newString) where newString has a word replaced.
func pickEditableFragment(path string) (old, new_ string) {
f, err := os.Open(path)
if err != nil {
return "hello", "hello_mock"
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
var candidates []string
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Pick lines that are non-empty, not too short, and look like code
if len(trimmed) >= 10 && len(trimmed) <= 120 && utf8.ValidString(trimmed) {
candidates = append(candidates, line)
}
}
if len(candidates) == 0 {
return "original", "modified"
}
line := candidates[rand.Intn(len(candidates))]
// Find a word to replace
words := strings.Fields(line)
if len(words) == 0 {
return line, line + " // mock-edited"
}
// Pick a non-trivial word (length > 2)
var editableWords []int
for i, w := range words {
if len(w) > 2 {
editableWords = append(editableWords, i)
}
}
if len(editableWords) == 0 {
return line, line + " // mock-edited"
}
idx := editableWords[rand.Intn(len(editableWords))]
newWords := make([]string, len(words))
copy(newWords, words)
newWords[idx] = words[idx] + "_mock"
return line, strings.Join(newWords, " ")
}
// randomFilePaths returns n random file relative paths for search results.
func randomFilePaths(n int) []string {
files := discoverFiles()
if len(files) == 0 {
return []string{"example.txt"}
}
if n > len(files) {
n = len(files)
}
// Shuffle and take first n
perm := rand.Perm(len(files))
paths := make([]string, n)
for i := 0; i < n; i++ {
paths[i] = files[perm[i]].relPath
}
return paths
}
@@ -1075,6 +1075,114 @@
} }
] ]
} }
},
{
"id": "mock-agent",
"name": "Mock Agent",
"display_name": "Mock",
"description": "Mock agent for testing. Generates simulated responses with all message types.",
"enabled": false,
"image": "",
"tag": "",
"cmd": ["mock-agent"],
"working_dir": "{workspace}",
"required_env": [],
"env": {},
"mounts": [],
"resource_limits": {
"memory_mb": 512,
"cpu_cores": 0.5,
"timeout_seconds": 3600
},
"capabilities": [
"code_generation",
"code_review",
"refactoring",
"testing",
"shell_execution"
],
"protocol": "claude-code",
"protocol_config": {},
"model_flag": "--model {model}",
"workspace_flag": "",
"session_config": {
"native_session_resume": false,
"resume_flag": "",
"can_recover": false
},
"permission_config": {
"permission_flag": "",
"tools_requiring_permission": [
"Edit",
"Write",
"Bash",
"NotebookEdit"
]
},
"permission_settings": {
"auto_approve": {
"supported": true,
"default": true,
"label": "Auto-approve",
"description": "Automatically approve tool calls",
"apply_method": "stdio",
"cli_flag": ""
},
"dangerously_skip_permissions": {
"supported": true,
"default": false,
"label": "Skip Permissions (YOLO)",
"description": "Bypass all permission checks",
"apply_method": "",
"cli_flag": ""
}
},
"discovery": {
"supports_mcp": false,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": [],
"windows": [],
"macos": []
},
"discovery_capabilities": {
"supports_session_resume": false,
"supports_shell": false,
"supports_workspace_only": false
}
},
"model_config": {
"default_model": "mock-default",
"available_models": [
{
"id": "mock-default",
"name": "Mock Default",
"provider": "mock",
"context_window": 200000,
"is_default": true
},
{
"id": "mock-slow",
"name": "Mock Slow",
"description": "Longer delays",
"provider": "mock",
"context_window": 200000,
"is_default": false
},
{
"id": "mock-fast",
"name": "Mock Fast",
"description": "Minimal delays",
"provider": "mock",
"context_window": 200000,
"is_default": false
}
]
}
} }
] ]
} }
@@ -1,10 +1,28 @@
package registry package registry
import "github.com/kandev/kandev/internal/common/logger" import (
"os"
"path/filepath"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Provide creates and loads the agent registry. // Provide creates and loads the agent registry.
func Provide(log *logger.Logger) (*Registry, func() error, error) { func Provide(log *logger.Logger) (*Registry, func() error, error) {
reg := NewRegistry(log) reg := NewRegistry(log)
reg.LoadDefaults() reg.LoadDefaults()
if os.Getenv("KANDEV_MOCK_AGENT") == "true" {
if cfg, ok := reg.Get("mock-agent"); ok {
cfg.Enabled = true
// Resolve binary path: same directory as the running executable
if exePath, err := os.Executable(); err == nil {
cfg.Cmd = []string{filepath.Join(filepath.Dir(exePath), "mock-agent")}
}
log.Info("mock agent enabled", zap.String("cmd", cfg.Cmd[0]))
}
}
return reg, func() error { return nil }, nil return reg, func() error { return nil }, nil
} }
@@ -118,7 +118,7 @@ func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
} }
func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) { func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) {
results, err := c.discovery.Detect(ctx) results, err := c.detectAgents(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -137,7 +137,7 @@ func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryRespo
} }
func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailableAgentsResponse, error) { func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailableAgentsResponse, error) {
results, err := c.discovery.Detect(ctx) results, err := c.detectAgents(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -158,6 +158,7 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl
Available: false, Available: false,
} }
} }
displayName := def.DisplayName displayName := def.DisplayName
if displayName == "" { if displayName == "" {
displayName = def.Name displayName = def.Name
@@ -245,7 +246,7 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e
} }
func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error {
results, err := c.discovery.Detect(ctx) results, err := c.detectAgents(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -417,7 +418,7 @@ func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*
if err == nil && existing != nil { if err == nil && existing != nil {
return nil, fmt.Errorf("agent already configured: %s", req.Name) return nil, fmt.Errorf("agent already configured: %s", req.Name)
} }
discoveryResults, err := c.discovery.Detect(ctx) discoveryResults, err := c.detectAgents(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -689,6 +690,25 @@ func (c *Controller) defaultModelByAgent() map[string]string {
return mapped return mapped
} }
// detectAgents runs discovery and forces mock-agent available when enabled.
func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) {
results, err := c.discovery.Detect(ctx)
if err != nil {
return nil, err
}
// Force mock-agent as available when enabled (skip file-presence discovery)
agentConfig, ok := c.agentRegistry.Get("mock-agent")
if ok && agentConfig.Enabled {
for i := range results {
if results[i].Name == "mock-agent" {
results[i].Available = true
break
}
}
}
return results, nil
}
// CommandPreviewRequest contains the draft settings for command preview // CommandPreviewRequest contains the draft settings for command preview
type CommandPreviewRequest struct { type CommandPreviewRequest struct {
Model string Model string
@@ -220,8 +220,9 @@ func (r *Repository) DeleteMessage(ctx context.Context, id string) error {
return nil return nil
} }
// GetMessageByToolCallID retrieves a tool message by session ID and tool_call_id in metadata // GetMessageByToolCallID retrieves a tool message by session ID and tool_call_id in metadata.
// Searches all tool types: tool_call, tool_read, tool_edit, tool_execute, tool_search // Searches all message types that have a tool_call_id in metadata (tool_call, tool_read, tool_edit,
// tool_execute, tool_search, todo, etc.).
func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) {
message := &models.Message{} message := &models.Message{}
var requestsInput int var requestsInput int
@@ -229,7 +230,7 @@ func (r *Repository) GetMessageByToolCallID(ctx context.Context, sessionID, tool
var metadataJSON string var metadataJSON string
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? AND type LIKE 'tool_%' AND json_extract(metadata, '$.tool_call_id') = ? FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ?
ORDER BY created_at ASC LIMIT 1 ORDER BY created_at ASC LIMIT 1
`, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, `, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
+1 -1
View File
@@ -22,7 +22,7 @@ export type DevOptions = {
export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise<void> { export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise<void> {
const ports = await pickPorts(backendPort, webPort); const ports = await pickPorts(backendPort, webPort);
const backendEnv = buildBackendEnv({ ports }); const backendEnv = buildBackendEnv({ ports, extra: { KANDEV_MOCK_AGENT: "true" } });
const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true }); const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true });
logPortConfig("dev", "using local repo", ports, true); logPortConfig("dev", "using local repo", ports, true);
+16 -4
View File
@@ -17,6 +17,7 @@ import {
getFilesPanelScrollPosition, getFilesPanelScrollPosition,
setFilesPanelScrollPosition, setFilesPanelScrollPosition,
} from '@/lib/local-storage'; } from '@/lib/local-storage';
import { useToast } from '@/components/toast-provider';
type FileBrowserProps = { type FileBrowserProps = {
sessionId: string; sessionId: string;
@@ -28,6 +29,7 @@ type FileBrowserProps = {
}; };
export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen = false, onCloseSearch, activeFilePath }: FileBrowserProps) { export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen = false, onCloseSearch, activeFilePath }: FileBrowserProps) {
const { toast } = useToast();
const [tree, setTree] = useState<FileTreeNode | null>(null); const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set()); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set()); const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
@@ -382,9 +384,14 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
isDirty: false, isDirty: false,
}); });
} catch (error) { } catch (error) {
console.error('Failed to load file content:', error); const reason = error instanceof Error ? error.message : 'Unknown error';
toast({
title: 'Failed to open file',
description: reason,
variant: 'error',
});
} }
}, [sessionId, onOpenFile]); }, [sessionId, onOpenFile, toast]);
// Open file from search result (path only) // Open file from search result (path only)
const openFileByPath = useCallback(async (path: string) => { const openFileByPath = useCallback(async (path: string) => {
@@ -410,9 +417,14 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
isDirty: false, isDirty: false,
}); });
} catch (error) { } catch (error) {
console.error('Failed to load file content:', error); const reason = error instanceof Error ? error.message : 'Unknown error';
toast({
title: 'Failed to open file',
description: reason,
variant: 'error',
});
} }
}, [sessionId, onOpenFile]); }, [sessionId, onOpenFile, toast]);
const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => { const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => {
const isExpanded = expandedPaths.has(node.path); const isExpanded = expandedPaths.has(node.path);
@@ -420,9 +420,14 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
}); });
setLeftTab(`file:${filePath}`); setLeftTab(`file:${filePath}`);
} catch (error) { } catch (error) {
console.error('Failed to open file from chat:', error); const reason = error instanceof Error ? error.message : 'Unknown error';
toast({
title: 'Failed to open file',
description: reason,
variant: 'error',
});
} }
}, [activeSessionId]); }, [activeSessionId, toast]);
// Handler for file content changes // Handler for file content changes
const handleFileChange = useCallback((path: string, newContent: string) => { const handleFileChange = useCallback((path: string, newContent: string) => {
+50 -59
View File
@@ -1,6 +1,33 @@
import type { StoreApi } from 'zustand'; import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store'; import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types'; import type { WsHandlers } from '@/lib/ws/handlers/types';
import type { KanbanState } from '@/lib/state/slices/kanban/types';
type KanbanTask = KanbanState['tasks'][number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildTaskFromPayload(payload: any, existing?: KanbanTask): KanbanTask {
return {
id: payload.task_id,
workflowStepId: payload.workflow_step_id,
title: payload.title,
description: payload.description,
position: payload.position ?? 0,
state: payload.state,
repositoryId: payload.repository_id ?? existing?.repositoryId,
primarySessionId: payload.primary_session_id ?? existing?.primarySessionId,
sessionCount: payload.session_count ?? existing?.sessionCount,
reviewStatus: payload.review_status ?? existing?.reviewStatus,
updatedAt: payload.updated_at ?? existing?.updatedAt,
};
}
function upsertTask(tasks: KanbanTask[], nextTask: KanbanTask): KanbanTask[] {
const exists = tasks.some((task) => task.id === nextTask.id);
return exists
? tasks.map((task) => (task.id === nextTask.id ? nextTask : task))
: [...tasks, nextTask];
}
export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers { export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
return { return {
@@ -9,28 +36,13 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
if (state.kanban.boardId !== message.payload.board_id) { if (state.kanban.boardId !== message.payload.board_id) {
return state; return state;
} }
const exists = state.kanban.tasks.some((task) => task.id === message.payload.task_id); const existing = state.kanban.tasks.find((task) => task.id === message.payload.task_id);
const existingTask = state.kanban.tasks.find((task) => task.id === message.payload.task_id); const nextTask = buildTaskFromPayload(message.payload, existing);
const nextTask = {
id: message.payload.task_id,
workflowStepId: message.payload.workflow_step_id,
title: message.payload.title,
description: message.payload.description,
position: message.payload.position ?? 0,
state: message.payload.state,
repositoryId: message.payload.repository_id ?? existingTask?.repositoryId,
primarySessionId: message.payload.primary_session_id ?? existingTask?.primarySessionId,
sessionCount: message.payload.session_count ?? existingTask?.sessionCount,
reviewStatus: message.payload.review_status ?? existingTask?.reviewStatus,
updatedAt: message.payload.updated_at ?? existingTask?.updatedAt,
};
return { return {
...state, ...state,
kanban: { kanban: {
...state.kanban, ...state.kanban,
tasks: exists tasks: upsertTask(state.kanban.tasks, nextTask),
? state.kanban.tasks.map((task) => (task.id === nextTask.id ? nextTask : task))
: [...state.kanban.tasks, nextTask],
}, },
}; };
}); });
@@ -40,66 +52,45 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
if (state.kanban.boardId !== message.payload.board_id) { if (state.kanban.boardId !== message.payload.board_id) {
return state; return state;
} }
const existingTask = state.kanban.tasks.find((task) => task.id === message.payload.task_id); const existing = state.kanban.tasks.find((task) => task.id === message.payload.task_id);
const nextTask = { const nextTask = buildTaskFromPayload(message.payload, existing);
id: message.payload.task_id,
workflowStepId: message.payload.workflow_step_id,
title: message.payload.title,
description: message.payload.description,
position: message.payload.position ?? 0,
state: message.payload.state,
repositoryId: message.payload.repository_id ?? existingTask?.repositoryId,
primarySessionId: message.payload.primary_session_id ?? existingTask?.primarySessionId,
sessionCount: message.payload.session_count ?? existingTask?.sessionCount,
reviewStatus: message.payload.review_status ?? existingTask?.reviewStatus,
updatedAt: message.payload.updated_at ?? existingTask?.updatedAt,
};
return { return {
...state, ...state,
kanban: { kanban: {
...state.kanban, ...state.kanban,
tasks: state.kanban.tasks.some((task) => task.id === nextTask.id) tasks: upsertTask(state.kanban.tasks, nextTask),
? state.kanban.tasks.map((task) => (task.id === nextTask.id ? nextTask : task))
: [...state.kanban.tasks, nextTask],
}, },
}; };
}); });
}, },
'task.deleted': (message) => { 'task.deleted': (message) => {
store.setState((state) => ({ store.setState((state) => {
...state, const deletedId = message.payload.task_id;
kanban: { const isActive = state.tasks.activeTaskId === deletedId;
...state.kanban, return {
tasks: state.kanban.tasks.filter((task) => task.id !== message.payload.task_id), ...state,
}, kanban: {
})); ...state.kanban,
tasks: state.kanban.tasks.filter((task) => task.id !== deletedId),
},
tasks: isActive
? { ...state.tasks, activeTaskId: null, activeSessionId: null }
: state.tasks,
};
});
}, },
'task.state_changed': (message) => { 'task.state_changed': (message) => {
store.setState((state) => { store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) { if (state.kanban.boardId !== message.payload.board_id) {
return state; return state;
} }
const existingTask = state.kanban.tasks.find((t) => t.id === message.payload.task_id); const existing = state.kanban.tasks.find((t) => t.id === message.payload.task_id);
const nextTask = { const nextTask = buildTaskFromPayload(message.payload, existing);
id: message.payload.task_id,
workflowStepId: message.payload.workflow_step_id,
title: message.payload.title,
description: message.payload.description,
position: message.payload.position ?? 0,
state: message.payload.state,
repositoryId: message.payload.repository_id ?? existingTask?.repositoryId,
primarySessionId: message.payload.primary_session_id ?? existingTask?.primarySessionId,
sessionCount: message.payload.session_count ?? existingTask?.sessionCount,
reviewStatus: message.payload.review_status ?? existingTask?.reviewStatus,
updatedAt: message.payload.updated_at ?? existingTask?.updatedAt,
};
return { return {
...state, ...state,
kanban: { kanban: {
...state.kanban, ...state.kanban,
tasks: state.kanban.tasks.some((task) => task.id === nextTask.id) tasks: upsertTask(state.kanban.tasks, nextTask),
? state.kanban.tasks.map((task) => (task.id === nextTask.id ? nextTask : task))
: [...state.kanban.tasks, nextTask],
}, },
}; };
}); });
+15
View File
@@ -34,6 +34,21 @@ export function registerTurnsHandlers(store: StoreApi<AppState>): WsHandlers {
); );
// Clear the active turn when it completes // Clear the active turn when it completes
store.getState().setActiveTurn(payload.session_id, null); store.getState().setActiveTurn(payload.session_id, null);
// Safety net: mark any tool calls still in "running" state as "complete".
// This handles edge cases where tool_update events were dropped or not processed.
const messages = store.getState().messages.bySession[payload.session_id];
if (messages) {
for (const msg of messages) {
const meta = msg.metadata as Record<string, unknown> | undefined;
if (meta?.status === 'running' && meta?.tool_call_id) {
store.getState().updateMessage({
...msg,
metadata: { ...meta, status: 'complete' },
});
}
}
}
}, },
}; };
} }