feat: mock agent (#208)
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.", "")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.", "")
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user