From e60b456f18a9d241718fb58e1204baf8738899d7 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sat, 14 Mar 2026 16:45:15 +0800 Subject: [PATCH 01/15] feat(tools): add SpawnStatusTool for reporting subagent statuses --- pkg/agent/loop.go | 1 + pkg/tools/spawn_status.go | 127 ++++++++++++++++++ pkg/tools/spawn_status_test.go | 237 +++++++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 pkg/tools/spawn_status.go create mode 100644 pkg/tools/spawn_status_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f20a56b9c4..5bc7a328e3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -233,6 +233,7 @@ func registerSharedTools( return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) agent.Tools.Register(spawnTool) + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) } else { logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) } diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go new file mode 100644 index 0000000000..7a1872eda5 --- /dev/null +++ b/pkg/tools/spawn_status.go @@ -0,0 +1,127 @@ +package tools + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// SpawnStatusTool reports the status of subagents that were spawned via the +// spawn tool. It can query a specific task by ID, or list every known task with +// a summary count broken-down by status. +type SpawnStatusTool struct { + manager *SubagentManager +} + +// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager. +func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool { + return &SpawnStatusTool{manager: manager} +} + +func (t *SpawnStatusTool) Name() string { + return "spawn_status" +} + +func (t *SpawnStatusTool) Description() string { + return "Get the status of spawned subagents. " + + "Returns a list of all subagents and their current state " + + "(running, completed, failed, or canceled), or retrieves details " + + "for a specific subagent task when task_id is provided." +} + +func (t *SpawnStatusTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "task_id": map[string]any{ + "type": "string", + "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + + "subagent. When omitted, all known subagents are listed.", + }, + }, + "required": []string{}, + } +} + +func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.manager == nil { + return ErrorResult("Subagent manager not configured") + } + + taskID, _ := args["task_id"].(string) + taskID = strings.TrimSpace(taskID) + + if taskID != "" { + task, ok := t.manager.GetTask(taskID) + if !ok { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + return NewToolResult(spawnStatusFormatTask(task)) + } + + tasks := t.manager.ListTasks() + if len(tasks) == 0 { + return NewToolResult("No subagents have been spawned yet.") + } + + // Deterministic ordering: sort by ID string (e.g. "subagent-1" < "subagent-2"). + sort.Slice(tasks, func(i, j int) bool { + return tasks[i].ID < tasks[j].ID + }) + + counts := map[string]int{} + for _, task := range tasks { + counts[task.Status]++ + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks))) + for _, status := range []string{"running", "completed", "failed", "canceled"} { + if n := counts[status]; n > 0 { + label := strings.ToUpper(status[:1]) + status[1:] + ":" + sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n)) + } + } + sb.WriteString("\n") + + for _, task := range tasks { + sb.WriteString(spawnStatusFormatTask(task)) + sb.WriteString("\n\n") + } + + return NewToolResult(strings.TrimRight(sb.String(), "\n")) +} + +// spawnStatusFormatTask renders a single SubagentTask as a human-readable block. +func spawnStatusFormatTask(task *SubagentTask) string { + var sb strings.Builder + + header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status) + if task.Label != "" { + header += fmt.Sprintf(" label=%q", task.Label) + } + if task.AgentID != "" { + header += fmt.Sprintf(" agent=%s", task.AgentID) + } + if task.Created > 0 { + created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC") + header += fmt.Sprintf(" created=%s", created) + } + sb.WriteString(header) + + if task.Task != "" { + sb.WriteString(fmt.Sprintf("\n task: %s", task.Task)) + } + if task.Result != "" { + result := task.Result + const maxResultLen = 300 + if len(result) > maxResultLen { + result = result[:maxResultLen] + "…" + } + sb.WriteString(fmt.Sprintf("\n result: %s", result)) + } + + return sb.String() +} diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go new file mode 100644 index 0000000000..491fd39c85 --- /dev/null +++ b/pkg/tools/spawn_status_test.go @@ -0,0 +1,237 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +func TestSpawnStatusTool_Name(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + if tool.Name() != "spawn_status" { + t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name()) + } +} + +func TestSpawnStatusTool_Description(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + desc := tool.Description() + if desc == "" { + t.Error("Description should not be empty") + } + if !strings.Contains(strings.ToLower(desc), "subagent") { + t.Errorf("Description should mention 'subagent', got: %s", desc) + } +} + +func TestSpawnStatusTool_Parameters(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + params := tool.Parameters() + if params["type"] != "object" { + t.Errorf("Expected type 'object', got: %v", params["type"]) + } + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("Expected 'properties' to be a map") + } + if _, hasTaskID := props["task_id"]; !hasTaskID { + t.Error("Expected 'task_id' parameter in properties") + } +} + +func TestSpawnStatusTool_NilManager(t *testing.T) { + tool := &SpawnStatusTool{manager: nil} + result := tool.Execute(context.Background(), map[string]any{}) + if !result.IsError { + t.Error("Expected error result when manager is nil") + } +} + +func TestSpawnStatusTool_Empty(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No subagents") { + t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + now := time.Now().UnixMilli() + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Do task A", + Label: "task-a", + Status: "running", + Created: now, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", + Task: "Do task B", + Label: "task-b", + Status: "completed", + Result: "Done successfully", + Created: now, + } + manager.tasks["subagent-3"] = &SubagentTask{ + ID: "subagent-3", + Task: "Do task C", + Status: "failed", + Result: "Error: something went wrong", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + + // Summary header + if !strings.Contains(result.ForLLM, "3 total") { + t.Errorf("Expected total count in header, got: %s", result.ForLLM) + } + + // Individual task IDs + for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} { + if !strings.Contains(result.ForLLM, id) { + t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM) + } + } + + // Status values + for _, status := range []string{"running", "completed", "failed"} { + if !strings.Contains(result.ForLLM, status) { + t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM) + } + } + + // Result content + if !strings.Contains(result.ForLLM, "Done successfully") { + t.Errorf("Expected result text in output, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-42"] = &SubagentTask{ + ID: "subagent-42", + Task: "Specific task", + Label: "my-task", + Status: "failed", + Result: "Something went wrong", + Created: time.Now().UnixMilli(), + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"}) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-42") { + t.Errorf("Expected task ID in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "failed") { + t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Something went wrong") { + t.Errorf("Expected result text in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "my-task") { + t.Errorf("Expected label in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"}) + if !result.IsError { + t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "nonexistent-999") { + t.Errorf("Expected task ID in error message, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ResultTruncation(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + longResult := strings.Repeat("X", 500) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Long task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // Output should be shorter than the raw result due to truncation + if len(result.ForLLM) >= len(longResult) { + t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM)) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_StatusCounts(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + for i, status := range []string{"running", "running", "completed", "failed", "canceled"} { + id := fmt.Sprintf("subagent-%d", i+1) + manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status} + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + // The summary line should mention all statuses that have counts + for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} { + if !strings.Contains(result.ForLLM, want) { + t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM) + } + } +} From 2ce7aebec1f790b5b885e3a5693e2d3cd3a8f5b8 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sat, 14 Mar 2026 17:07:13 +0800 Subject: [PATCH 02/15] feat(tools): enhance SpawnStatusTool to restrict task visibility by conversation context --- pkg/tools/spawn_status.go | 60 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index 7a1872eda5..fd17e1abcb 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -50,6 +50,12 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too return ErrorResult("Subagent manager not configured") } + // Derive the calling conversation's identity so we can scope results to the + // current chat only — preventing cross-conversation task leakage in + // multi-user deployments. + callerChannel := ToolChannel(ctx) + callerChatID := ToolChatID(ctx) + taskID, _ := args["task_id"].(string) taskID = strings.TrimSpace(taskID) @@ -58,21 +64,63 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too if !ok { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } - return NewToolResult(spawnStatusFormatTask(task)) + + // Snapshot before formatting to avoid racing with the subagent goroutine. + taskCopy := *task + + // Restrict lookup to tasks that belong to this conversation. + if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID { + return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) + } + + return NewToolResult(spawnStatusFormatTask(&taskCopy)) + } + + origTasks := t.manager.ListTasks() + if len(origTasks) == 0 { + return NewToolResult("No subagents have been spawned yet.") + } + + // Snapshot each task to avoid reading concurrently mutated state via shared + // pointers once the manager lock is released. + tasks := make([]*SubagentTask, 0, len(origTasks)) + for _, task := range origTasks { + if task == nil { + continue + } + cpy := *task + + // Filter to tasks that originate from the current conversation only. + if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel { + continue + } + if callerChatID != "" && cpy.OriginChatID != "" && cpy.OriginChatID != callerChatID { + continue + } + + tasks = append(tasks, &cpy) } - tasks := t.manager.ListTasks() if len(tasks) == 0 { return NewToolResult("No subagents have been spawned yet.") } // Deterministic ordering: sort by ID string (e.g. "subagent-1" < "subagent-2"). sort.Slice(tasks, func(i, j int) bool { + if tasks[i] == nil || tasks[j] == nil { + return false + } return tasks[i].ID < tasks[j].ID }) counts := map[string]int{} for _, task := range tasks { + if task == nil { + continue + } counts[task.Status]++ } @@ -87,6 +135,9 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too sb.WriteString("\n") for _, task := range tasks { + if task == nil { + continue + } sb.WriteString(spawnStatusFormatTask(task)) sb.WriteString("\n\n") } @@ -117,8 +168,9 @@ func spawnStatusFormatTask(task *SubagentTask) string { if task.Result != "" { result := task.Result const maxResultLen = 300 - if len(result) > maxResultLen { - result = result[:maxResultLen] + "…" + runes := []rune(result) + if len(runes) > maxResultLen { + result = string(runes[:maxResultLen]) + "…" } sb.WriteString(fmt.Sprintf("\n result: %s", result)) } From af6ef94f114ea364d57730f79af1b9bf73cdf56c Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sat, 14 Mar 2026 17:08:23 +0800 Subject: [PATCH 03/15] feat(tests): add Unicode result truncation and channel filtering tests for SpawnStatusTool --- pkg/tools/spawn_status_test.go | 108 +++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index 491fd39c85..4d77d0a542 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -211,6 +211,36 @@ func TestSpawnStatusTool_ResultTruncation(t *testing.T) { } } +func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + // Each "字" is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit. + longResult := strings.Repeat("字", 400) + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", + Task: "Unicode task", + Status: "completed", + Result: longResult, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "…") { + t.Errorf("Expected truncation indicator in output") + } + // The truncated result must be valid UTF-8 (no split rune boundaries). + if !strings.Contains(result.ForLLM, "字") { + t.Errorf("Expected CJK runes to appear intact in output") + } +} + func TestSpawnStatusTool_StatusCounts(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") @@ -235,3 +265,81 @@ func TestSpawnStatusTool_StatusCounts(t *testing.T) { } } } + +func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", Task: "mine", Status: "running", + OriginChannel: "telegram", OriginChatID: "chat-A", + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", Task: "other user", Status: "running", + OriginChannel: "telegram", OriginChatID: "chat-B", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // Caller is chat-A — should only see subagent-1. + ctx := WithToolContext(context.Background(), "telegram", "chat-A") + result := tool.Execute(ctx, map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-1") { + t.Errorf("Expected own task in output, got:\n%s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "subagent-2") { + t.Errorf("Should NOT see other chat's task, got:\n%s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-99"] = &SubagentTask{ + ID: "subagent-99", Task: "secret", Status: "completed", Result: "private data", + OriginChannel: "slack", OriginChatID: "room-Z", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // Different chat trying to look up subagent-99 by ID. + ctx := WithToolContext(context.Background(), "slack", "room-OTHER") + result := tool.Execute(ctx, map[string]any{"task_id": "subagent-99"}) + + if !result.IsError { + t.Errorf("Expected error (cross-chat lookup blocked), got: %s", result.ForLLM) + } +} + +func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + manager.mu.Lock() + manager.tasks["subagent-1"] = &SubagentTask{ + ID: "subagent-1", Task: "t", Status: "completed", + OriginChannel: "telegram", OriginChatID: "chat-A", + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + + // No tool context (e.g. CLI) — callerChannel and callerChatID are both "". + // The filter conditions require a non-empty caller value, so all tasks pass through. + result := tool.Execute(context.Background(), map[string]any{}) + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subagent-1") { + t.Errorf("Expected task visible from no-context caller, got:\n%s", result.ForLLM) + } +} From 100da45f4a262a406f4f6514c8e480fef073ddfd Mon Sep 17 00:00:00 2001 From: Desmond Foo <102380796+SHINE-six@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:13:55 +0800 Subject: [PATCH 04/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/tools/spawn_status.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index fd17e1abcb..03aeb21cab 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -25,10 +25,11 @@ func (t *SpawnStatusTool) Name() string { } func (t *SpawnStatusTool) Description() string { - return "Get the status of spawned subagents. " + - "Returns a list of all subagents and their current state " + - "(running, completed, failed, or canceled), or retrieves details " + - "for a specific subagent task when task_id is provided." + return "Get the status of spawned subagents associated with the current tool context " + + "(for example, the current channel or chat). Returns a list of all such subagents " + + "and their current state (running, completed, failed, or canceled), or retrieves " + + "details for a specific subagent task when task_id is provided. If the tool is " + + "invoked without any associated context, the result will be empty." } func (t *SpawnStatusTool) Parameters() map[string]any { @@ -38,7 +39,8 @@ func (t *SpawnStatusTool) Parameters() map[string]any { "task_id": map[string]any{ "type": "string", "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + - "subagent. When omitted, all known subagents are listed.", + "subagent. When omitted, all known subagents in the current tool context " + + "are listed; if the tool has no associated context, the result will be empty.", }, }, "required": []string{}, From 6d370011b5e2b00ee91ed00fe00b0a4e5df9dc02 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sat, 14 Mar 2026 17:22:28 +0800 Subject: [PATCH 05/15] feat(tools): enhance SpawnStatusTool with task ID validation and sorting by creation timestamp --- pkg/tools/spawn_status.go | 44 +++++++++++++---------------- pkg/tools/spawn_status_test.go | 51 ++++++++++++++++++++++++++++++++++ pkg/tools/subagent.go | 27 ++++++++++++++++++ 3 files changed, 98 insertions(+), 24 deletions(-) diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index fd17e1abcb..fec37bd992 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -56,18 +56,23 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too callerChannel := ToolChannel(ctx) callerChatID := ToolChatID(ctx) - taskID, _ := args["task_id"].(string) - taskID = strings.TrimSpace(taskID) + var taskID string + if rawTaskID, ok := args["task_id"]; ok && rawTaskID != nil { + taskIDStr, ok := rawTaskID.(string) + if !ok { + return ErrorResult("task_id must be a string") + } + taskID = strings.TrimSpace(taskIDStr) + } if taskID != "" { - task, ok := t.manager.GetTask(taskID) + // GetTaskCopy returns a consistent snapshot under the manager lock, + // eliminating any data race with the concurrent subagent goroutine. + taskCopy, ok := t.manager.GetTaskCopy(taskID) if !ok { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } - // Snapshot before formatting to avoid racing with the subagent goroutine. - taskCopy := *task - // Restrict lookup to tasks that belong to this conversation. if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) @@ -79,19 +84,15 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too return NewToolResult(spawnStatusFormatTask(&taskCopy)) } - origTasks := t.manager.ListTasks() + // ListTaskCopies returns consistent snapshots under the manager lock. + origTasks := t.manager.ListTaskCopies() if len(origTasks) == 0 { return NewToolResult("No subagents have been spawned yet.") } - // Snapshot each task to avoid reading concurrently mutated state via shared - // pointers once the manager lock is released. tasks := make([]*SubagentTask, 0, len(origTasks)) - for _, task := range origTasks { - if task == nil { - continue - } - cpy := *task + for i := range origTasks { + cpy := &origTasks[i] // Filter to tasks that originate from the current conversation only. if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel { @@ -101,26 +102,24 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too continue } - tasks = append(tasks, &cpy) + tasks = append(tasks, cpy) } if len(tasks) == 0 { return NewToolResult("No subagents have been spawned yet.") } - // Deterministic ordering: sort by ID string (e.g. "subagent-1" < "subagent-2"). + // Order by creation time (ascending) so spawning order is preserved. + // Fall back to ID string for tasks created in the same millisecond. sort.Slice(tasks, func(i, j int) bool { - if tasks[i] == nil || tasks[j] == nil { - return false + if tasks[i].Created != tasks[j].Created { + return tasks[i].Created < tasks[j].Created } return tasks[i].ID < tasks[j].ID }) counts := map[string]int{} for _, task := range tasks { - if task == nil { - continue - } counts[task.Status]++ } @@ -135,9 +134,6 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too sb.WriteString("\n") for _, task := range tasks { - if task == nil { - continue - } sb.WriteString(spawnStatusFormatTask(task)) sb.WriteString("\n\n") } diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index 4d77d0a542..89e6bfe3a3 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -182,6 +182,22 @@ func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) { } } +func TestSpawnStatusTool_TaskID_NonString(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnStatusTool(manager) + + for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} { + result := tool.Execute(context.Background(), map[string]any{"task_id": badVal}) + if !result.IsError { + t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM) + } + if !strings.Contains(result.ForLLM, "task_id must be a string") { + t.Errorf("Expected type-error message, got: %s", result.ForLLM) + } + } +} + func TestSpawnStatusTool_ResultTruncation(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") @@ -266,6 +282,41 @@ func TestSpawnStatusTool_StatusCounts(t *testing.T) { } } +func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + + now := time.Now().UnixMilli() + manager.mu.Lock() + // Intentionally insert with out-of-order IDs and timestamps that reflect + // true spawn order: subagent-2 was spawned first, subagent-10 second. + manager.tasks["subagent-10"] = &SubagentTask{ + ID: "subagent-10", Task: "second", Status: "running", + Created: now + 1, + } + manager.tasks["subagent-2"] = &SubagentTask{ + ID: "subagent-2", Task: "first", Status: "running", + Created: now, + } + manager.mu.Unlock() + + tool := NewSpawnStatusTool(manager) + result := tool.Execute(context.Background(), map[string]any{}) + + if result.IsError { + t.Fatalf("Unexpected error: %s", result.ForLLM) + } + + pos2 := strings.Index(result.ForLLM, "subagent-2") + pos10 := strings.Index(result.ForLLM, "subagent-10") + if pos2 < 0 || pos10 < 0 { + t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM) + } + if pos2 > pos10 { + t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM) + } +} + func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index e51cbaafae..fc13c0f098 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -109,8 +109,10 @@ func (sm *SubagentManager) Spawn( } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { + sm.mu.Lock() task.Status = "running" task.Created = time.Now().UnixMilli() + sm.mu.Unlock() // Build system prompt for subagent systemPrompt := `You are a subagent. Complete the given task independently and report the result. @@ -219,6 +221,18 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { return task, ok } +// GetTaskCopy returns a copy of the task with the given ID, taken under the +// read lock, so the caller receives a consistent snapshot with no data race. +func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + task, ok := sm.tasks[taskID] + if !ok { + return SubagentTask{}, false + } + return *task, true +} + func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() defer sm.mu.RUnlock() @@ -230,6 +244,19 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask { return tasks } +// ListTaskCopies returns value copies of all tasks, taken under the read lock, +// so callers receive consistent snapshots with no data race. +func (sm *SubagentManager) ListTaskCopies() []SubagentTask { + sm.mu.RLock() + defer sm.mu.RUnlock() + + copies := make([]SubagentTask, 0, len(sm.tasks)) + for _, task := range sm.tasks { + copies = append(copies, *task) + } + return copies +} + // SubagentTool executes a subagent task synchronously and returns the result. // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion // and returns the result directly in the ToolResult. From e7518e8e41b3eae1273e1fca357e11579308785f Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sat, 14 Mar 2026 17:26:07 +0800 Subject: [PATCH 06/15] feat(tools): update SpawnStatusTool description and parameter documentation for clarity --- pkg/tools/spawn_status.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index a293d86ec8..dd429c1c0d 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -25,11 +25,12 @@ func (t *SpawnStatusTool) Name() string { } func (t *SpawnStatusTool) Description() string { - return "Get the status of spawned subagents associated with the current tool context " + - "(for example, the current channel or chat). Returns a list of all such subagents " + - "and their current state (running, completed, failed, or canceled), or retrieves " + - "details for a specific subagent task when task_id is provided. If the tool is " + - "invoked without any associated context, the result will be empty." + return "Get the status of spawned subagents. " + + "Returns a list of all subagents and their current state " + + "(running, completed, failed, or canceled), or retrieves details " + + "for a specific subagent task when task_id is provided. " + + "Results are scoped to the current conversation when a channel/chat " + + "context is available; without context (e.g. CLI), all tasks are listed." } func (t *SpawnStatusTool) Parameters() map[string]any { @@ -39,8 +40,7 @@ func (t *SpawnStatusTool) Parameters() map[string]any { "task_id": map[string]any{ "type": "string", "description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " + - "subagent. When omitted, all known subagents in the current tool context " + - "are listed; if the tool has no associated context, the result will be empty.", + "subagent. When omitted, all visible subagents are listed.", }, }, "required": []string{}, From de6a04bf9c1d60d963e8b7cbe69eb2498d3aba31 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sat, 14 Mar 2026 19:35:23 +0800 Subject: [PATCH 07/15] refactor(tests): improve comments for clarity in ChannelFiltering test case --- pkg/tools/spawn_status_test.go | 6 +++++- pkg/tools/subagent.go | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index 89e6bfe3a3..fcd3c4a361 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -384,7 +384,11 @@ func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) { tool := NewSpawnStatusTool(manager) - // No tool context (e.g. CLI) — callerChannel and callerChatID are both "". + // No ToolContext injected (e.g. a direct programmatic call that bypasses + // WithToolContext entirely) — callerChannel and callerChatID are both "". + // Note: the normal CLI path uses ProcessDirectWithChannel("cli", "direct"), + // which *does* inject a non-empty context; this test covers the case where + // no context injection happens at all. // The filter conditions require a non-empty caller value, so all tasks pass through. result := tool.Execute(context.Background(), map[string]any{}) if result.IsError { diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index fc13c0f098..40c9e6ae9a 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -111,7 +111,6 @@ func (sm *SubagentManager) Spawn( func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { sm.mu.Lock() task.Status = "running" - task.Created = time.Now().UnixMilli() sm.mu.Unlock() // Build system prompt for subagent From 9a2cf38d548b1efaf2e4d01e1de2157f40ecd605 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sun, 15 Mar 2026 15:30:12 +0800 Subject: [PATCH 08/15] fix(tools): update no subagents message for clarity and remove unnecessary locking in runTask --- pkg/tools/spawn_status.go | 2 +- pkg/tools/subagent.go | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index dd429c1c0d..e2aa74d7dd 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -108,7 +108,7 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too } if len(tasks) == 0 { - return NewToolResult("No subagents have been spawned yet.") + return NewToolResult("No subagents found for this conversation.") } // Order by creation time (ascending) so spawning order is preserved. diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 40c9e6ae9a..c37a5ee0f2 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -109,10 +109,6 @@ func (sm *SubagentManager) Spawn( } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { - sm.mu.Lock() - task.Status = "running" - sm.mu.Unlock() - // Build system prompt for subagent systemPrompt := `You are a subagent. Complete the given task independently and report the result. You have access to tools - use them as needed to complete your task. From 12f570e56f0e86887c197d4872a5344640955cf9 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sun, 15 Mar 2026 15:59:49 +0800 Subject: [PATCH 09/15] fix(tools): improve description clarity for SpawnStatusTool regarding task context --- pkg/tools/spawn_status.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index e2aa74d7dd..416fd2226d 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -29,8 +29,9 @@ func (t *SpawnStatusTool) Description() string { "Returns a list of all subagents and their current state " + "(running, completed, failed, or canceled), or retrieves details " + "for a specific subagent task when task_id is provided. " + - "Results are scoped to the current conversation when a channel/chat " + - "context is available; without context (e.g. CLI), all tasks are listed." + "Results are scoped to the current conversation's channel and chat ID; " + + "all tasks are listed only when no channel/chat context is injected " + + "(e.g. direct programmatic calls via Execute)." } func (t *SpawnStatusTool) Parameters() map[string]any { From 70b967672a57d49e2c7a18feac9a2944531167b8 Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sun, 15 Mar 2026 18:33:26 +0800 Subject: [PATCH 10/15] feat(tools): add spawn_status tool configuration and registration --- pkg/agent/loop.go | 4 +++- pkg/config/config.go | 3 +++ pkg/config/defaults.go | 3 +++ web/backend/api/tools.go | 14 +++++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5bc7a328e3..df8c392c79 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -233,7 +233,9 @@ func registerSharedTools( return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) agent.Tools.Register(spawnTool) - agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) + if cfg.Tools.IsToolEnabled("spawn_status") { + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) + } } else { logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 1903412248..a577073913 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -748,6 +748,7 @@ type ToolsConfig struct { ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` @@ -1043,6 +1044,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.ReadFile.Enabled case "spawn": return t.Spawn.Enabled + case "spawn_status": + return t.SpawnStatus.Enabled case "spi": return t.SPI.Enabled case "subagent": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 189af0a845..3d656e6115 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -512,6 +512,9 @@ func DefaultConfig() *Config { Spawn: ToolConfig{ Enabled: true, }, + SpawnStatus: ToolConfig{ + Enabled: true, + }, SPI: ToolConfig{ Enabled: false, // Hardware tool - Linux only }, diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 373a3be12d..9df4a70911 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -118,6 +118,12 @@ var toolCatalog = []toolCatalogEntry{ Category: "agents", ConfigKey: "spawn", }, + { + Name: "spawn_status", + Description: "Query the status of spawned subagents.", + Category: "agents", + ConfigKey: "spawn_status", + }, { Name: "i2c", Description: "Interact with I2C hardware devices exposed on the host.", @@ -205,7 +211,7 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { reasonCode = "requires_skills" } } - case "spawn": + case "spawn", "spawn_status": if cfg.Tools.IsToolEnabled(entry.ConfigKey) { if cfg.Tools.IsToolEnabled("subagent") { status = "enabled" @@ -300,6 +306,12 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { if enabled { cfg.Tools.Subagent.Enabled = true } + case "spawn_status": + cfg.Tools.SpawnStatus.Enabled = enabled + if enabled { + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true + } case "i2c": cfg.Tools.I2C.Enabled = enabled case "spi": From 7614f03e5bea5380e1e185b2636900c362450134 Mon Sep 17 00:00:00 2001 From: Desmond Foo <102380796+SHINE-six@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:37:44 +0800 Subject: [PATCH 11/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/tools/spawn_status_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index fcd3c4a361..d4bba4346d 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -10,7 +10,8 @@ import ( func TestSpawnStatusTool_Name(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test") + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) if tool.Name() != "spawn_status" { @@ -20,7 +21,8 @@ func TestSpawnStatusTool_Name(t *testing.T) { func TestSpawnStatusTool_Description(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test") + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) desc := tool.Description() @@ -34,7 +36,8 @@ func TestSpawnStatusTool_Description(t *testing.T) { func TestSpawnStatusTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test") + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) params := tool.Parameters() @@ -60,7 +63,8 @@ func TestSpawnStatusTool_NilManager(t *testing.T) { func TestSpawnStatusTool_Empty(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test") + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) tool := NewSpawnStatusTool(manager) result := tool.Execute(context.Background(), map[string]any{}) From 521db349ff3de763ed889aa6fa9b304513f8fe5f Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Sun, 15 Mar 2026 18:43:34 +0800 Subject: [PATCH 12/15] fix(agent): improve subagent management for spawn and spawn_status tools --- pkg/agent/loop.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index df8c392c79..85f165d550 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -222,23 +222,26 @@ func registerSharedTools( } } - // Spawn tool with allowlist checker - if cfg.Tools.IsToolEnabled("spawn") { - if cfg.Tools.IsToolEnabled("subagent") { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + // Spawn and spawn_status tools share a SubagentManager. + // Construct it when either tool is enabled (both require subagent). + spawnEnabled := cfg.Tools.IsToolEnabled("spawn") + spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") + if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + if spawnEnabled { spawnTool := tools.NewSpawnTool(subagentManager) currentAgentID := agentID spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) agent.Tools.Register(spawnTool) - if cfg.Tools.IsToolEnabled("spawn_status") { - agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) - } - } else { - logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) } + if spawnStatusEnabled { + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) + } + } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { + logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) } } } From a8f7c863579a3cb364079bd2b28fc07221486c4c Mon Sep 17 00:00:00 2001 From: Desmond Foo <102380796+SHINE-six@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:43:42 +0800 Subject: [PATCH 13/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/tools/spawn_status_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index d4bba4346d..cb8af126cf 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -78,7 +78,8 @@ func TestSpawnStatusTool_Empty(t *testing.T) { func TestSpawnStatusTool_ListAll(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test") + workspace := t.TempDir() + manager := NewSubagentManager(provider, "test-model", workspace) now := time.Now().UnixMilli() manager.mu.Lock() From c908b849620a45f71f70f5f12ad7fe4698626701 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:51:12 +0800 Subject: [PATCH 14/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/config/defaults.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 3d656e6115..e48a07fff0 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -513,7 +513,7 @@ func DefaultConfig() *Config { Enabled: true, }, SpawnStatus: ToolConfig{ - Enabled: true, + Enabled: false, }, SPI: ToolConfig{ Enabled: false, // Hardware tool - Linux only From 4f3b7c6d3fed2406e7a47be0f8196aa1ab3f2cee Mon Sep 17 00:00:00 2001 From: SHINE-six Date: Tue, 17 Mar 2026 12:49:32 +0800 Subject: [PATCH 15/15] fix(tests): update ResultTruncation_Unicode test to use valid CJK character --- pkg/tools/spawn_status_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index cb8af126cf..9c772d61ab 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -236,8 +236,9 @@ func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) { provider := &MockLLMProvider{} manager := NewSubagentManager(provider, "test-model", "/tmp/test") - // Each "字" is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit. - longResult := strings.Repeat("字", 400) + // Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit. + cjkChar := string(rune(0x5b57)) + longResult := strings.Repeat(cjkChar, 400) manager.mu.Lock() manager.tasks["subagent-1"] = &SubagentTask{ ID: "subagent-1", @@ -257,7 +258,7 @@ func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) { t.Errorf("Expected truncation indicator in output") } // The truncated result must be valid UTF-8 (no split rune boundaries). - if !strings.Contains(result.ForLLM, "字") { + if !strings.Contains(result.ForLLM, cjkChar) { t.Errorf("Expected CJK runes to appear intact in output") } }