Detailed changes
@@ -0,0 +1,179 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "shelley.exe.dev/llm"
+)
+
+// SubagentRunner is the interface for running a subagent conversation.
+// This is implemented by the server package to avoid import cycles.
+type SubagentRunner interface {
+ // RunSubagent runs a subagent conversation and returns the last response.
+ // If wait is false, it starts processing in background and returns immediately.
+ // timeout is the maximum time to wait for a response.
+ RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error)
+}
+
+// SubagentDB is the database interface for subagent operations.
+// This is implemented by the db package.
+type SubagentDB interface {
+ // GetOrCreateSubagentConversation retrieves or creates a subagent conversation.
+ // Returns the conversation ID and the actual slug used (may differ from requested
+ // slug if a numeric suffix was added for uniqueness).
+ GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (conversationID, actualSlug string, err error)
+}
+
+// SubagentTool provides the ability to spawn and interact with subagent conversations.
+type SubagentTool struct {
+ DB SubagentDB
+ ParentConversationID string
+ WorkingDir *MutableWorkingDir
+ Runner SubagentRunner
+}
+
+const (
+ subagentName = "subagent"
+ subagentDescription = `Spawn or interact with a subagent conversation.
+
+Subagents are independent conversations that can work on subtasks in parallel.
+Use subagents for:
+- Long-running tasks that you want to delegate
+- Token-intensive tasks that produce lots of output, little of which is needed
+- Parallel exploration of different approaches
+- Breaking down complex problems into independent pieces
+
+Each subagent has its own slug identifier within this conversation.
+You can send messages to existing subagents by using the same slug.
+The tool returns the subagent's last response, or a status if the timeout is reached.
+`
+ subagentInputSchema = `
+{
+ "type": "object",
+ "required": ["slug", "prompt"],
+ "properties": {
+ "slug": {
+ "type": "string",
+ "description": "A short identifier for this subagent (e.g., 'research-api', 'test-runner')"
+ },
+ "prompt": {
+ "type": "string",
+ "description": "The message to send to the subagent"
+ },
+ "timeout_seconds": {
+ "type": "integer",
+ "description": "How long to wait for a response (default: 60, max: 300)"
+ },
+ "wait": {
+ "type": "boolean",
+ "description": "Whether to wait for completion (default: true). If false, returns immediately."
+ }
+ }
+}
+`
+)
+
+type subagentInput struct {
+ Slug string `json:"slug"`
+ Prompt string `json:"prompt"`
+ TimeoutSeconds int `json:"timeout_seconds,omitempty"`
+ Wait *bool `json:"wait,omitempty"`
+}
+
+// Tool returns an llm.Tool for the subagent functionality.
+func (s *SubagentTool) Tool() *llm.Tool {
+ return &llm.Tool{
+ Name: subagentName,
+ Description: strings.TrimSpace(subagentDescription),
+ InputSchema: llm.MustSchema(subagentInputSchema),
+ Run: s.Run,
+ }
+}
+
+func (s *SubagentTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ var req subagentInput
+ if err := json.Unmarshal(m, &req); err != nil {
+ return llm.ErrorfToolOut("failed to parse subagent input: %w", err)
+ }
+
+ // Validate slug
+ if req.Slug == "" {
+ return llm.ErrorfToolOut("slug is required")
+ }
+ req.Slug = sanitizeSlug(req.Slug)
+ if req.Slug == "" {
+ return llm.ErrorfToolOut("slug must contain alphanumeric characters")
+ }
+
+ if req.Prompt == "" {
+ return llm.ErrorfToolOut("prompt is required")
+ }
+
+ // Set defaults
+ timeout := 60 * time.Second
+ if req.TimeoutSeconds > 0 {
+ if req.TimeoutSeconds > 300 {
+ req.TimeoutSeconds = 300
+ }
+ timeout = time.Duration(req.TimeoutSeconds) * time.Second
+ }
+
+ wait := true
+ if req.Wait != nil {
+ wait = *req.Wait
+ }
+
+ // Get or create the subagent conversation
+ conversationID, actualSlug, err := s.DB.GetOrCreateSubagentConversation(ctx, req.Slug, s.ParentConversationID, s.WorkingDir.Get())
+ if err != nil {
+ return llm.ErrorfToolOut("failed to get/create subagent conversation: %w", err)
+ }
+
+ // Use the runner to execute the subagent
+ response, err := s.Runner.RunSubagent(ctx, conversationID, req.Prompt, wait, timeout)
+ if err != nil {
+ return llm.ErrorfToolOut("subagent error: %w", err)
+ }
+
+ // Include actual slug in response if it differs from requested
+ slugNote := ""
+ if actualSlug != req.Slug {
+ slugNote = fmt.Sprintf(" (Note: slug was changed to '%s' for uniqueness. Use '%s' for future messages to this subagent.)", actualSlug, actualSlug)
+ }
+
+ return llm.ToolOut{
+ LLMContent: llm.TextContent(fmt.Sprintf("Subagent '%s' response:%s\n%s", actualSlug, slugNote, response)),
+ Display: SubagentDisplayData{
+ Slug: actualSlug,
+ ConversationID: conversationID,
+ },
+ }
+}
+
+// SubagentDisplayData is the display data sent to the UI for subagent tool results.
+type SubagentDisplayData struct {
+ Slug string `json:"slug"`
+ ConversationID string `json:"conversation_id"`
+}
+
+func sanitizeSlug(slug string) string {
+ // Lowercase, keep alphanumeric and hyphens
+ var result strings.Builder
+ for _, r := range strings.ToLower(slug) {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
+ result.WriteRune(r)
+ } else if r == ' ' || r == '_' {
+ result.WriteRune('-')
+ }
+ }
+ // Remove consecutive hyphens and trim
+ s := result.String()
+ for strings.Contains(s, "--") {
+ s = strings.ReplaceAll(s, "--", "-")
+ }
+ return strings.Trim(s, "-")
+}
@@ -0,0 +1,154 @@
+package claudetool
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+// mockSubagentDB implements SubagentDB for testing.
+type mockSubagentDB struct {
+ conversations map[string]string // slug -> conversationID
+}
+
+func newMockSubagentDB() *mockSubagentDB {
+ return &mockSubagentDB{
+ conversations: make(map[string]string),
+ }
+}
+
+func (m *mockSubagentDB) GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (string, string, error) {
+ key := parentID + ":" + slug
+ if id, ok := m.conversations[key]; ok {
+ return id, slug, nil
+ }
+ id := "subagent-" + slug
+ m.conversations[key] = id
+ return id, slug, nil
+}
+
+// mockSubagentRunner implements SubagentRunner for testing.
+type mockSubagentRunner struct {
+ response string
+ err error
+}
+
+func (m *mockSubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) {
+ if m.err != nil {
+ return "", m.err
+ }
+ return m.response, nil
+}
+
+func TestSubagentTool_SanitizeSlug(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"test-slug", "test-slug"},
+ {"Test Slug", "test-slug"},
+ {"test_slug", "test-slug"},
+ {"test--slug", "test-slug"},
+ {"-test-slug-", "test-slug"},
+ {"test@slug!", "testslug"},
+ {"123-abc", "123-abc"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := sanitizeSlug(tt.input)
+ if result != tt.expected {
+ t.Errorf("sanitizeSlug(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSubagentTool_Run(t *testing.T) {
+ wd := NewMutableWorkingDir("/tmp")
+ db := newMockSubagentDB()
+ runner := &mockSubagentRunner{response: "Task completed successfully"}
+
+ tool := &SubagentTool{
+ DB: db,
+ ParentConversationID: "parent-123",
+ WorkingDir: wd,
+ Runner: runner,
+ }
+
+ input := subagentInput{
+ Slug: "test-task",
+ Prompt: "Do something useful",
+ }
+ inputJSON, _ := json.Marshal(input)
+
+ result := tool.Run(context.Background(), inputJSON)
+ if result.Error != nil {
+ t.Fatalf("unexpected error: %v", result.Error)
+ }
+
+ if len(result.LLMContent) == 0 {
+ t.Fatal("expected LLM content")
+ }
+
+ if result.LLMContent[0].Text == "" {
+ t.Error("expected non-empty response text")
+ }
+
+ // Check display data
+ if result.Display == nil {
+ t.Error("expected display data")
+ }
+ displayData, ok := result.Display.(SubagentDisplayData)
+ if !ok {
+ t.Error("display data should be SubagentDisplayData")
+ }
+ if displayData.Slug != "test-task" {
+ t.Errorf("expected slug 'test-task', got %q", displayData.Slug)
+ }
+}
+
+func TestSubagentTool_Validation(t *testing.T) {
+ wd := NewMutableWorkingDir("/tmp")
+ db := newMockSubagentDB()
+ runner := &mockSubagentRunner{response: "OK"}
+
+ tool := &SubagentTool{
+ DB: db,
+ ParentConversationID: "parent-123",
+ WorkingDir: wd,
+ Runner: runner,
+ }
+
+ // Test empty slug
+ t.Run("empty slug", func(t *testing.T) {
+ input := subagentInput{Slug: "", Prompt: "test"}
+ inputJSON, _ := json.Marshal(input)
+ result := tool.Run(context.Background(), inputJSON)
+ if result.Error == nil {
+ t.Error("expected error for empty slug")
+ }
+ })
+
+ // Test empty prompt
+ t.Run("empty prompt", func(t *testing.T) {
+ input := subagentInput{Slug: "test", Prompt: ""}
+ inputJSON, _ := json.Marshal(input)
+ result := tool.Run(context.Background(), inputJSON)
+ if result.Error == nil {
+ t.Error("expected error for empty prompt")
+ }
+ })
+
+ // Test invalid slug (only special chars)
+ t.Run("invalid slug", func(t *testing.T) {
+ input := subagentInput{Slug: "@#$%", Prompt: "test"}
+ inputJSON, _ := json.Marshal(input)
+ result := tool.Run(context.Background(), inputJSON)
+ if result.Error == nil {
+ t.Error("expected error for invalid slug")
+ }
+ })
+}
@@ -50,6 +50,13 @@ type ToolSetConfig struct {
// OnWorkingDirChange is called when the working directory changes.
// This can be used to persist the change to a database.
OnWorkingDirChange func(newDir string)
+ // SubagentRunner is the runner for subagent conversations.
+ // If set, the subagent tool will be available.
+ SubagentRunner SubagentRunner
+ // SubagentDB is the database for subagent conversations.
+ SubagentDB SubagentDB
+ // ParentConversationID is the ID of the parent conversation (for subagent tool).
+ ParentConversationID string
}
// ToolSet holds a set of tools for a single conversation.
@@ -120,6 +127,17 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
changeDirTool.Tool(),
}
+ // Add subagent tool if configured
+ if cfg.SubagentRunner != nil && cfg.SubagentDB != nil && cfg.ParentConversationID != "" {
+ subagentTool := &SubagentTool{
+ DB: cfg.SubagentDB,
+ ParentConversationID: cfg.ParentConversationID,
+ WorkingDir: wd,
+ Runner: cfg.SubagentRunner,
+ }
+ tools = append(tools, subagentTool.Tool())
+ }
+
var cleanup func()
if cfg.EnableBrowser {
// Get max image dimension from the LLM service
@@ -94,14 +94,15 @@ type conversationStateForTS struct {
}
type conversationWithStateForTS struct {
- ConversationID string `json:"conversation_id"`
- Slug *string `json:"slug"`
- UserInitiated bool `json:"user_initiated"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- Cwd *string `json:"cwd"`
- Archived bool `json:"archived"`
- Working bool `json:"working"`
+ ConversationID string `json:"conversation_id"`
+ Slug *string `json:"slug"`
+ UserInitiated bool `json:"user_initiated"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ Cwd *string `json:"cwd"`
+ Archived bool `json:"archived"`
+ ParentConversationID *string `json:"parent_conversation_id"`
+ Working bool `json:"working"`
}
type streamResponseForTS struct {
@@ -597,3 +597,95 @@ func (db *DB) DeleteConversation(ctx context.Context, conversationID string) err
return q.DeleteConversation(ctx, conversationID)
})
}
+
+// CreateSubagentConversation creates a new subagent conversation with a parent
+func (db *DB) CreateSubagentConversation(ctx context.Context, slug, parentID string, cwd *string) (*generated.Conversation, error) {
+ conversationID, err := generateConversationID()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate conversation ID: %w", err)
+ }
+ var conversation generated.Conversation
+ err = db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+ q := generated.New(tx.Conn())
+ conversation, err = q.CreateSubagentConversation(ctx, generated.CreateSubagentConversationParams{
+ ConversationID: conversationID,
+ Slug: &slug,
+ Cwd: cwd,
+ ParentConversationID: &parentID,
+ })
+ return err
+ })
+ return &conversation, err
+}
+
+// GetSubagents retrieves all subagent conversations for a parent conversation
+func (db *DB) GetSubagents(ctx context.Context, parentID string) ([]generated.Conversation, error) {
+ var conversations []generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversations, err = q.GetSubagents(ctx, &parentID)
+ return err
+ })
+ return conversations, err
+}
+
+// GetConversationBySlugAndParent retrieves a subagent conversation by slug and parent ID
+func (db *DB) GetConversationBySlugAndParent(ctx context.Context, slug, parentID string) (*generated.Conversation, error) {
+ var conversation generated.Conversation
+ err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+ q := generated.New(rx.Conn())
+ var err error
+ conversation, err = q.GetConversationBySlugAndParent(ctx, generated.GetConversationBySlugAndParentParams{
+ Slug: &slug,
+ ParentConversationID: &parentID,
+ })
+ return err
+ })
+ if err == sql.ErrNoRows {
+ return nil, nil // Not found, return nil without error
+ }
+ return &conversation, err
+}
+
+// SubagentDBAdapter adapts *DB to the claudetool.SubagentDB interface.
+type SubagentDBAdapter struct {
+ DB *DB
+}
+
+// GetOrCreateSubagentConversation implements claudetool.SubagentDB.
+// Returns the conversation ID and the actual slug used (may differ if a suffix was added).
+func (a *SubagentDBAdapter) GetOrCreateSubagentConversation(ctx context.Context, slug, parentID, cwd string) (string, string, error) {
+ // Try to find existing with exact slug
+ existing, err := a.DB.GetConversationBySlugAndParent(ctx, slug, parentID)
+ if err != nil {
+ return "", "", err
+ }
+ if existing != nil {
+ return existing.ConversationID, *existing.Slug, nil
+ }
+
+ // Try to create new, handling unique constraint violations by appending numbers
+ baseSlug := slug
+ actualSlug := slug
+ for attempt := 0; attempt < 100; attempt++ {
+ conv, err := a.DB.CreateSubagentConversation(ctx, actualSlug, parentID, &cwd)
+ if err == nil {
+ return conv.ConversationID, actualSlug, nil
+ }
+
+ // Check if this is a unique constraint violation
+ errLower := strings.ToLower(err.Error())
+ if strings.Contains(errLower, "unique constraint") ||
+ strings.Contains(errLower, "duplicate") {
+ // Try with a numeric suffix
+ actualSlug = fmt.Sprintf("%s-%d", baseSlug, attempt+1)
+ continue
+ }
+
+ // Some other error occurred
+ return "", "", err
+ }
+
+ return "", "", fmt.Errorf("failed to create unique subagent slug after 100 attempts")
+}
@@ -13,7 +13,7 @@ const archiveConversation = `-- name: ArchiveConversation :one
UPDATE conversations
SET archived = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
`
func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
@@ -27,6 +27,7 @@ func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
@@ -43,7 +44,7 @@ func (q *Queries) CountArchivedConversations(ctx context.Context) (int64, error)
}
const countConversations = `-- name: CountConversations :one
-SELECT COUNT(*) FROM conversations WHERE archived = FALSE
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE AND parent_conversation_id IS NULL
`
func (q *Queries) CountConversations(ctx context.Context) (int64, error) {
@@ -56,7 +57,7 @@ func (q *Queries) CountConversations(ctx context.Context) (int64, error) {
const createConversation = `-- name: CreateConversation :one
INSERT INTO conversations (conversation_id, slug, user_initiated, cwd)
VALUES (?, ?, ?, ?)
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
`
type CreateConversationParams struct {
@@ -82,6 +83,41 @@ func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversation
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
+ )
+ return i, err
+}
+
+const createSubagentConversation = `-- name: CreateSubagentConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd, parent_conversation_id)
+VALUES (?, ?, FALSE, ?, ?)
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
+`
+
+type CreateSubagentConversationParams struct {
+ ConversationID string `json:"conversation_id"`
+ Slug *string `json:"slug"`
+ Cwd *string `json:"cwd"`
+ ParentConversationID *string `json:"parent_conversation_id"`
+}
+
+func (q *Queries) CreateSubagentConversation(ctx context.Context, arg CreateSubagentConversationParams) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, createSubagentConversation,
+ arg.ConversationID,
+ arg.Slug,
+ arg.Cwd,
+ arg.ParentConversationID,
+ )
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
@@ -97,7 +133,7 @@ func (q *Queries) DeleteConversation(ctx context.Context, conversationID string)
}
const getConversation = `-- name: GetConversation :one
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
WHERE conversation_id = ?
`
@@ -112,12 +148,13 @@ func (q *Queries) GetConversation(ctx context.Context, conversationID string) (C
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
const getConversationBySlug = `-- name: GetConversationBySlug :one
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
WHERE slug = ?
`
@@ -132,12 +169,77 @@ func (q *Queries) GetConversationBySlug(ctx context.Context, slug *string) (Conv
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
+const getConversationBySlugAndParent = `-- name: GetConversationBySlugAndParent :one
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE slug = ? AND parent_conversation_id = ?
+`
+
+type GetConversationBySlugAndParentParams struct {
+ Slug *string `json:"slug"`
+ ParentConversationID *string `json:"parent_conversation_id"`
+}
+
+func (q *Queries) GetConversationBySlugAndParent(ctx context.Context, arg GetConversationBySlugAndParentParams) (Conversation, error) {
+ row := q.db.QueryRowContext(ctx, getConversationBySlugAndParent, arg.Slug, arg.ParentConversationID)
+ var i Conversation
+ err := row.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ &i.ParentConversationID,
+ )
+ return i, err
+}
+
+const getSubagents = `-- name: GetSubagents :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE parent_conversation_id = ?
+ORDER BY created_at ASC
+`
+
+func (q *Queries) GetSubagents(ctx context.Context, parentConversationID *string) ([]Conversation, error) {
+ rows, err := q.db.QueryContext(ctx, getSubagents, parentConversationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Conversation{}
+ for rows.Next() {
+ var i Conversation
+ if err := rows.Scan(
+ &i.ConversationID,
+ &i.Slug,
+ &i.UserInitiated,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Cwd,
+ &i.Archived,
+ &i.ParentConversationID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listArchivedConversations = `-- name: ListArchivedConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
WHERE archived = TRUE
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
@@ -165,6 +267,7 @@ func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchive
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
); err != nil {
return nil, err
}
@@ -180,8 +283,8 @@ func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchive
}
const listConversations = `-- name: ListConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
-WHERE archived = FALSE
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE archived = FALSE AND parent_conversation_id IS NULL
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`
@@ -208,6 +311,7 @@ func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsPa
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
); err != nil {
return nil, err
}
@@ -223,7 +327,7 @@ func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsPa
}
const searchArchivedConversations = `-- name: SearchArchivedConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
WHERE slug LIKE '%' || ? || '%' AND archived = TRUE
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
@@ -252,6 +356,7 @@ func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArc
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
); err != nil {
return nil, err
}
@@ -267,8 +372,8 @@ func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArc
}
const searchConversations = `-- name: SearchConversations :many
-SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
-WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE AND parent_conversation_id IS NULL
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`
@@ -296,6 +401,7 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
); err != nil {
return nil, err
}
@@ -311,7 +417,7 @@ func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversatio
}
const searchConversationsWithMessages = `-- name: SearchConversationsWithMessages :many
-SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived FROM conversations c
+SELECT DISTINCT c.conversation_id, c.slug, c.user_initiated, c.created_at, c.updated_at, c.cwd, c.archived, c.parent_conversation_id FROM conversations c
LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent')
WHERE c.archived = FALSE
AND (
@@ -332,6 +438,7 @@ type SearchConversationsWithMessagesParams struct {
}
// Search conversations by slug OR message content (user messages and agent responses, not system prompts)
+// Includes both top-level conversations and subagent conversations
func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg SearchConversationsWithMessagesParams) ([]Conversation, error) {
rows, err := q.db.QueryContext(ctx, searchConversationsWithMessages,
arg.Column1,
@@ -355,6 +462,7 @@ func (q *Queries) SearchConversationsWithMessages(ctx context.Context, arg Searc
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
); err != nil {
return nil, err
}
@@ -373,7 +481,7 @@ const unarchiveConversation = `-- name: UnarchiveConversation :one
UPDATE conversations
SET archived = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
`
func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
@@ -387,6 +495,7 @@ func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID stri
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
@@ -395,7 +504,7 @@ const updateConversationCwd = `-- name: UpdateConversationCwd :one
UPDATE conversations
SET cwd = ?, updated_at = CURRENT_TIMESTAMP
WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
`
type UpdateConversationCwdParams struct {
@@ -414,6 +523,7 @@ func (q *Queries) UpdateConversationCwd(ctx context.Context, arg UpdateConversat
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
@@ -422,7 +532,7 @@ const updateConversationSlug = `-- name: UpdateConversationSlug :one
UPDATE conversations
SET slug = ?, updated_at = CURRENT_TIMESTAMP
WHERE conversation_id = ?
-RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived, parent_conversation_id
`
type UpdateConversationSlugParams struct {
@@ -441,6 +551,7 @@ func (q *Queries) UpdateConversationSlug(ctx context.Context, arg UpdateConversa
&i.UpdatedAt,
&i.Cwd,
&i.Archived,
+ &i.ParentConversationID,
)
return i, err
}
@@ -9,13 +9,14 @@ import (
)
type Conversation struct {
- ConversationID string `json:"conversation_id"`
- Slug *string `json:"slug"`
- UserInitiated bool `json:"user_initiated"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- Cwd *string `json:"cwd"`
- Archived bool `json:"archived"`
+ ConversationID string `json:"conversation_id"`
+ Slug *string `json:"slug"`
+ UserInitiated bool `json:"user_initiated"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Cwd *string `json:"cwd"`
+ Archived bool `json:"archived"`
+ ParentConversationID *string `json:"parent_conversation_id"`
}
type Message struct {
@@ -13,7 +13,7 @@ WHERE slug = ?;
-- name: ListConversations :many
SELECT * FROM conversations
-WHERE archived = FALSE
+WHERE archived = FALSE AND parent_conversation_id IS NULL
ORDER BY updated_at DESC
LIMIT ? OFFSET ?;
@@ -25,12 +25,13 @@ LIMIT ? OFFSET ?;
-- name: SearchConversations :many
SELECT * FROM conversations
-WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE AND parent_conversation_id IS NULL
ORDER BY updated_at DESC
LIMIT ? OFFSET ?;
-- name: SearchConversationsWithMessages :many
-- Search conversations by slug OR message content (user messages and agent responses, not system prompts)
+-- Includes both top-level conversations and subagent conversations
SELECT DISTINCT c.* FROM conversations c
LEFT JOIN messages m ON c.conversation_id = m.conversation_id AND m.type IN ('user', 'agent')
WHERE c.archived = FALSE
@@ -64,7 +65,7 @@ DELETE FROM conversations
WHERE conversation_id = ?;
-- name: CountConversations :one
-SELECT COUNT(*) FROM conversations WHERE archived = FALSE;
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE AND parent_conversation_id IS NULL;
-- name: CountArchivedConversations :one
SELECT COUNT(*) FROM conversations WHERE archived = TRUE;
@@ -86,3 +87,18 @@ UPDATE conversations
SET cwd = ?, updated_at = CURRENT_TIMESTAMP
WHERE conversation_id = ?
RETURNING *;
+
+
+-- name: CreateSubagentConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd, parent_conversation_id)
+VALUES (?, ?, FALSE, ?, ?)
+RETURNING *;
+
+-- name: GetSubagents :many
+SELECT * FROM conversations
+WHERE parent_conversation_id = ?
+ORDER BY created_at ASC;
+
+-- name: GetConversationBySlugAndParent :one
+SELECT * FROM conversations
+WHERE slug = ? AND parent_conversation_id = ?;
@@ -0,0 +1,5 @@
+-- Add parent_conversation_id column for subagent conversations
+ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT REFERENCES conversations(conversation_id);
+
+-- Index for efficient parent-child lookups
+CREATE INDEX idx_conversations_parent_id ON conversations(parent_conversation_id) WHERE parent_conversation_id IS NOT NULL;
@@ -21,6 +21,7 @@ import (
// - "echo: <text>" - echoes the text back
// - "bash: <command>" - triggers bash tool with command
// - "think: <thoughts>" - triggers think tool
+// - "subagent: <slug> <prompt>" - triggers subagent tool
// - "delay: <seconds>" - delays response by specified seconds
// - See Do() method for complete list of supported patterns
type PredictableService struct {
@@ -164,6 +165,17 @@ func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Res
return s.makeScreenshotToolResponse(selector, inputTokens), nil
}
+ if strings.HasPrefix(inputText, "subagent: ") {
+ // Format: "subagent: <slug> <prompt>"
+ parts := strings.SplitN(strings.TrimPrefix(inputText, "subagent: "), " ", 2)
+ slug := parts[0]
+ prompt := "do the task"
+ if len(parts) > 1 {
+ prompt = parts[1]
+ }
+ return s.makeSubagentToolResponse(slug, prompt, inputTokens), nil
+ }
+
if strings.HasPrefix(inputText, "delay: ") {
delayStr := strings.TrimPrefix(inputText, "delay: ")
delaySeconds, err := strconv.ParseFloat(delayStr, 64)
@@ -520,6 +532,41 @@ func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTo
}
}
+func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response {
+ toolInputData := map[string]any{
+ "slug": slug,
+ "prompt": prompt,
+ }
+ toolInputBytes, _ := json.Marshal(toolInputData)
+ toolInput := json.RawMessage(toolInputBytes)
+ responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug)
+ outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+ if outputTokens == 0 {
+ outputTokens = 1
+ }
+ return &llm.Response{
+ ID: fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()),
+ Type: "message",
+ Role: llm.MessageRoleAssistant,
+ Model: "predictable-v1",
+ Content: []llm.Content{
+ {Type: llm.ContentTypeText, Text: responseText},
+ {
+ ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+ Type: llm.ContentTypeToolUse,
+ ToolName: "subagent",
+ ToolInput: toolInput,
+ },
+ },
+ StopReason: llm.StopReasonToolUse,
+ Usage: llm.Usage{
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CostUSD: 0.0,
+ },
+ }
+}
+
// makeToolSmorgasbordResponse creates a response that uses all available tool types
func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
baseNano := time.Now().UnixNano()
@@ -124,8 +124,19 @@ func (cm *ConversationManager) Hydrate(ctx context.Context) error {
return fmt.Errorf("failed to get conversation history: %w", err)
}
- if conversation.UserInitiated && !hasSystemMessage(messages) {
- systemMsg, err := cm.createSystemPrompt(ctx)
+ // Generate system prompt if missing:
+ // - For user-initiated conversations: full system prompt
+ // - For subagent conversations (has parent): minimal subagent prompt
+ if !hasSystemMessage(messages) {
+ var systemMsg *generated.Message
+ var err error
+ if conversation.ParentConversationID != nil {
+ // Subagent conversation - use minimal prompt
+ systemMsg, err = cm.createSubagentSystemPrompt(ctx)
+ } else if conversation.UserInitiated {
+ // User-initiated conversation - use full prompt
+ systemMsg, err = cm.createSystemPrompt(ctx)
+ }
if err != nil {
return err
}
@@ -253,6 +264,36 @@ func (cm *ConversationManager) createSystemPrompt(ctx context.Context) (*generat
return created, nil
}
+func (cm *ConversationManager) createSubagentSystemPrompt(ctx context.Context) (*generated.Message, error) {
+ systemPrompt, err := GenerateSubagentSystemPrompt(cm.cwd)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate subagent system prompt: %w", err)
+ }
+
+ if systemPrompt == "" {
+ cm.logger.Info("Skipping empty subagent system prompt generation")
+ return nil, nil
+ }
+
+ systemMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: systemPrompt}},
+ }
+
+ created, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{
+ ConversationID: cm.conversationID,
+ Type: db.MessageTypeSystem,
+ LLMData: systemMessage,
+ UsageData: llm.Usage{},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to store subagent system prompt: %w", err)
+ }
+
+ cm.logger.Info("Stored subagent system prompt", "length", len(systemPrompt))
+ return created, nil
+}
+
func (cm *ConversationManager) partitionMessages(messages []generated.Message) ([]llm.Message, []llm.SystemContent) {
var history []llm.Message
var system []llm.SystemContent
@@ -321,6 +362,7 @@ func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) e
// Create tools for this conversation with the conversation's working directory
toolSetConfig.WorkingDir = cwd
toolSetConfig.ModelID = modelID
+ toolSetConfig.ParentConversationID = conversationID // For subagent tool
toolSetConfig.OnWorkingDirChange = func(newDir string) {
// Persist working directory change to database
if err := db.UpdateConversationCwd(context.Background(), conversationID, newDir); err != nil {
@@ -552,6 +552,9 @@ func (s *Server) conversationMux() *http.ServeMux {
mux.HandleFunc("POST /{id}/rename", func(w http.ResponseWriter, r *http.Request) {
s.handleRenameConversation(w, r, r.PathValue("id"))
})
+ mux.HandleFunc("GET /{id}/subagents", func(w http.ResponseWriter, r *http.Request) {
+ s.handleGetSubagents(w, r, r.PathValue("id"))
+ })
return mux
}
@@ -221,7 +221,7 @@ type Server struct {
// NewServer creates a new server instance
func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool.ToolSetConfig, logger *slog.Logger, predictableOnly bool, terminalURL, defaultModel, requireHeader string, links []Link) *Server {
- return &Server{
+ s := &Server{
db: database,
llmManager: llmManager,
toolSetConfig: toolSetConfig,
@@ -233,6 +233,12 @@ func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool
requireHeader: requireHeader,
links: links,
}
+
+ // Set up subagent support
+ s.toolSetConfig.SubagentRunner = NewSubagentRunner(s)
+ s.toolSetConfig.SubagentDB = &db.SubagentDBAdapter{DB: database}
+
+ return s
}
// RegisterRoutes registers HTTP routes on the given mux
@@ -0,0 +1,187 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "shelley.exe.dev/claudetool"
+ "shelley.exe.dev/llm"
+)
+
+// SubagentRunner implements claudetool.SubagentRunner.
+type SubagentRunner struct {
+ server *Server
+}
+
+// NewSubagentRunner creates a new SubagentRunner.
+func NewSubagentRunner(s *Server) *SubagentRunner {
+ return &SubagentRunner{server: s}
+}
+
+// RunSubagent implements claudetool.SubagentRunner.
+func (r *SubagentRunner) RunSubagent(ctx context.Context, conversationID, prompt string, wait bool, timeout time.Duration) (string, error) {
+ s := r.server
+
+ // Get or create conversation manager for the subagent
+ manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+ if err != nil {
+ return "", fmt.Errorf("failed to get conversation manager: %w", err)
+ }
+
+ // Get the model ID from the server's default
+ // In predictable-only mode, use "predictable" as the model
+ modelID := s.defaultModel
+ if modelID == "" && s.predictableOnly {
+ modelID = "predictable"
+ }
+
+ // Get LLM service
+ llmService, err := s.llmManager.GetService(modelID)
+ if err != nil {
+ return "", fmt.Errorf("failed to get LLM service: %w", err)
+ }
+
+ // Create user message
+ userMessage := llm.Message{
+ Role: llm.MessageRoleUser,
+ Content: []llm.Content{{Type: llm.ContentTypeText, Text: prompt}},
+ }
+
+ // Accept the user message (this starts processing)
+ _, err = manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
+ if err != nil {
+ return "", fmt.Errorf("failed to accept user message: %w", err)
+ }
+
+ if !wait {
+ return fmt.Sprintf("Subagent started processing. Conversation ID: %s", conversationID), nil
+ }
+
+ // Wait for the agent to finish (or timeout)
+ return r.waitForResponse(ctx, conversationID, timeout)
+}
+
+func (r *SubagentRunner) waitForResponse(ctx context.Context, conversationID string, timeout time.Duration) (string, error) {
+ s := r.server
+
+ deadline := time.Now().Add(timeout)
+ pollInterval := 500 * time.Millisecond
+
+ for {
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ default:
+ }
+
+ if time.Now().After(deadline) {
+ return "Subagent is still working (timeout reached). Send another message to check status.", nil
+ }
+
+ // Check if agent is still working
+ working, err := r.isAgentWorking(ctx, conversationID)
+ if err != nil {
+ return "", fmt.Errorf("failed to check agent status: %w", err)
+ }
+
+ if !working {
+ // Agent is done, get the last message
+ return r.getLastAssistantResponse(ctx, conversationID)
+ }
+
+ // Wait before polling again
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case <-time.After(pollInterval):
+ }
+
+ // Don't hog the conversation manager mutex
+ s.mu.Lock()
+ if mgr, ok := s.activeConversations[conversationID]; ok {
+ mgr.Touch()
+ }
+ s.mu.Unlock()
+ }
+}
+
+func (r *SubagentRunner) isAgentWorking(ctx context.Context, conversationID string) (bool, error) {
+ s := r.server
+
+ // Get the conversation manager - it tracks the working state
+ s.mu.Lock()
+ mgr, ok := s.activeConversations[conversationID]
+ s.mu.Unlock()
+
+ if !ok {
+ // No active manager means the agent is not working
+ return false, nil
+ }
+
+ return mgr.IsAgentWorking(), nil
+}
+
+func (r *SubagentRunner) getLastAssistantResponse(ctx context.Context, conversationID string) (string, error) {
+ s := r.server
+
+ // Get the latest message
+ msg, err := s.db.GetLatestMessage(ctx, conversationID)
+ if err != nil {
+ return "", fmt.Errorf("failed to get latest message: %w", err)
+ }
+
+ // Extract text content
+ if msg.LlmData == nil {
+ return "", nil
+ }
+
+ var llmMsg llm.Message
+ if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+ return "", fmt.Errorf("failed to parse message: %w", err)
+ }
+
+ var texts []string
+ for _, content := range llmMsg.Content {
+ if content.Type == llm.ContentTypeText && content.Text != "" {
+ texts = append(texts, content.Text)
+ }
+ }
+
+ return strings.Join(texts, "\n"), nil
+}
+
+// createSubagentToolSetConfig creates a ToolSetConfig for subagent conversations.
+// Subagent conversations don't have nested subagents to avoid complexity.
+func (s *Server) createSubagentToolSetConfig(conversationID string) claudetool.ToolSetConfig {
+ return claudetool.ToolSetConfig{
+ LLMProvider: s.llmManager,
+ EnableJITInstall: true,
+ EnableBrowser: true, // Subagents can use browser tools
+ // No SubagentRunner/DB - subagents can't spawn nested subagents
+ }
+}
+
+// Ensure SubagentRunner implements claudetool.SubagentRunner.
+var _ claudetool.SubagentRunner = (*SubagentRunner)(nil)
+
+// handleGetSubagents returns the list of subagents for a conversation.
+func (s *Server) handleGetSubagents(w http.ResponseWriter, r *http.Request, conversationID string) {
+ if r.Method != "GET" {
+ http.Error(w, "Method not allowed", 405)
+ return
+ }
+
+ subagents, err := s.db.GetSubagents(r.Context(), conversationID)
+ if err != nil {
+ s.logger.Error("Failed to get subagents", "conversationID", conversationID, "error", err)
+ http.Error(w, "Failed to get subagents", 500)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(subagents)
+}
@@ -0,0 +1,13 @@
+You are a subagent of Shelley, a coding agent. You have been delegated a specific task by the parent agent.
+
+Key constraints:
+- Complete your assigned task thoroughly
+- Your final message will be returned to the parent agent as the result
+- Write important findings to files if the parent may need them later
+- Be concise in your final response - summarize what you did and the outcome
+- If you encounter blocking issues, explain them clearly so the parent can help
+
+Working directory: {{.WorkingDirectory}}
+{{if .GitInfo}}
+Git repository root: {{.GitInfo.Root}}
+{{end}}
@@ -13,6 +13,9 @@ import (
//go:embed system_prompt.txt
var systemPromptTemplate string
+//go:embed subagent_system_prompt.txt
+var subagentSystemPromptTemplate string
+
// SystemPromptData contains all the data needed to render the system prompt template
type SystemPromptData struct {
WorkingDirectory string
@@ -287,3 +290,44 @@ func isSudoAvailable() bool {
_, err := cmd.CombinedOutput()
return err == nil
}
+
+// SubagentSystemPromptData contains data for subagent system prompts (minimal subset)
+type SubagentSystemPromptData struct {
+ WorkingDirectory string
+ GitInfo *GitInfo
+}
+
+// GenerateSubagentSystemPrompt generates a minimal system prompt for subagent conversations.
+func GenerateSubagentSystemPrompt(workingDir string) (string, error) {
+ wd := workingDir
+ if wd == "" {
+ var err error
+ wd, err = os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("failed to get working directory: %w", err)
+ }
+ }
+
+ data := &SubagentSystemPromptData{
+ WorkingDirectory: wd,
+ }
+
+ // Try to collect git info
+ gitInfo, err := collectGitInfo()
+ if err == nil {
+ data.GitInfo = gitInfo
+ }
+
+ tmpl, err := template.New("subagent_system_prompt").Parse(subagentSystemPromptTemplate)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse subagent template: %w", err)
+ }
+
+ var buf strings.Builder
+ err = tmpl.Execute(&buf, data)
+ if err != nil {
+ return "", fmt.Errorf("failed to execute subagent template: %w", err)
+ }
+
+ return buf.String(), nil
+}
@@ -1132,3 +1132,134 @@ func TestGitStateChangeCreatesGitInfoMessage(t *testing.T) {
t.Fatal("Expected a gitinfo message to be created after git commit, but none was found")
}
}
+
+func TestSubagentEndToEnd(t *testing.T) {
+ // Create temporary database
+ tempDB := t.TempDir() + "/test.db"
+ database, err := db.New(db.Config{DSN: tempDB})
+ if err != nil {
+ t.Fatalf("Failed to create test database: %v", err)
+ }
+ defer database.Close()
+
+ // Run migrations
+ if err := database.Migrate(context.Background()); err != nil {
+ t.Fatalf("Failed to migrate database: %v", err)
+ }
+
+ // Create logger
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: slog.LevelDebug,
+ }))
+
+ // Create LLM service manager with predictable service
+ llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+
+ // Set up tools config
+ toolSetConfig := claudetool.ToolSetConfig{
+ WorkingDir: t.TempDir(),
+ EnableBrowser: false,
+ }
+
+ // Create server (predictable-only mode)
+ svr := server.NewServer(database, llmManager, toolSetConfig, logger, true, "", "", "", nil)
+
+ // Set up HTTP server
+ mux := http.NewServeMux()
+ svr.RegisterRoutes(mux)
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ client := &http.Client{Timeout: 60 * time.Second}
+
+ // Create a new conversation that will spawn a subagent
+ // The predictable service will respond with a subagent tool call for "subagent: test-worker echo hello"
+ chatReq := map[string]interface{}{
+ "message": "subagent: test-worker echo hello",
+ "model": "predictable",
+ }
+ reqBody, _ := json.Marshal(chatReq)
+
+ resp, err := client.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(reqBody))
+ if err != nil {
+ t.Fatalf("Failed to create conversation: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("Expected 200/201, got %d: %s", resp.StatusCode, string(body))
+ }
+
+ var createResp struct {
+ ConversationID string `json:"conversation_id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
+ t.Fatalf("Failed to decode response: %v", err)
+ }
+
+ parentConvID := createResp.ConversationID
+ t.Logf("Created parent conversation: %s", parentConvID)
+
+ // Wait for the conversation to complete (subagent should be created and executed)
+ time.Sleep(3 * time.Second)
+
+ // Check that subagents were created
+ subagentsResp, err := client.Get(ts.URL + "/api/conversation/" + parentConvID + "/subagents")
+ if err != nil {
+ t.Fatalf("Failed to get subagents: %v", err)
+ }
+ defer subagentsResp.Body.Close()
+
+ var subagents []generated.Conversation
+ if err := json.NewDecoder(subagentsResp.Body).Decode(&subagents); err != nil {
+ t.Fatalf("Failed to decode subagents: %v", err)
+ }
+
+ if len(subagents) == 0 {
+ t.Fatal("Expected at least one subagent to be created")
+ }
+
+ t.Logf("Created %d subagent(s)", len(subagents))
+ for _, sub := range subagents {
+ t.Logf(" - Subagent: %s (slug: %v)", sub.ConversationID, sub.Slug)
+ }
+
+ // Verify the subagent has the expected slug (or a suffixed version)
+ foundExpectedSlug := false
+ for _, sub := range subagents {
+ if sub.Slug != nil && (strings.HasPrefix(*sub.Slug, "test-worker")) {
+ foundExpectedSlug = true
+ break
+ }
+ }
+ if !foundExpectedSlug {
+ t.Errorf("Expected to find subagent with slug starting with 'test-worker'")
+ }
+
+ // Verify the subagent has a parent_conversation_id set
+ for _, sub := range subagents {
+ if sub.ParentConversationID == nil || *sub.ParentConversationID != parentConvID {
+ t.Errorf("Subagent %s has wrong parent_conversation_id: %v", sub.ConversationID, sub.ParentConversationID)
+ }
+ }
+
+ // Verify the subagent conversation has messages
+ subConvResp, err := client.Get(ts.URL + "/api/conversation/" + subagents[0].ConversationID)
+ if err != nil {
+ t.Fatalf("Failed to get subagent conversation: %v", err)
+ }
+ defer subConvResp.Body.Close()
+
+ var subConvData struct {
+ Messages []json.RawMessage `json:"messages"`
+ }
+ if err := json.NewDecoder(subConvResp.Body).Decode(&subConvData); err != nil {
+ t.Fatalf("Failed to decode subagent conversation: %v", err)
+ }
+
+ if len(subConvData.Messages) == 0 {
+ t.Error("Expected subagent conversation to have messages")
+ }
+ t.Logf("Subagent conversation has %d messages", len(subConvData.Messages))
+}
@@ -67,6 +67,8 @@ function App() {
const [diffViewerTrigger, setDiffViewerTrigger] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
+ const [subagentUpdate, setSubagentUpdate] = useState<Conversation | null>(null);
+ const [subagentStateUpdate, setSubagentStateUpdate] = useState<{ conversation_id: string; working: boolean } | null>(null);
const initialSlugResolved = useRef(false);
// Resolve initial slug from URL - uses the captured initialSlugFromUrl
@@ -120,6 +122,11 @@ function App() {
// Handle conversation list updates from the message stream
const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => {
if (update.type === "update" && update.conversation) {
+ // Handle subagent conversations separately
+ if (update.conversation.parent_conversation_id) {
+ setSubagentUpdate(update.conversation);
+ return;
+ }
setConversations((prev) => {
// Check if this conversation already exists
const existingIndex = prev.findIndex(
@@ -148,13 +155,21 @@ function App() {
// Handle conversation state updates (working state changes)
const handleConversationStateUpdate = useCallback(
(state: { conversation_id: string; working: boolean }) => {
- setConversations((prev) =>
- prev.map((conv) =>
- conv.conversation_id === state.conversation_id
- ? { ...conv, working: state.working }
- : conv,
- ),
- );
+ // Check if this is a top-level conversation
+ setConversations((prev) => {
+ const found = prev.find((conv) => conv.conversation_id === state.conversation_id);
+ if (found) {
+ return prev.map((conv) =>
+ conv.conversation_id === state.conversation_id
+ ? { ...conv, working: state.working }
+ : conv,
+ );
+ }
+ // Not a top-level conversation, might be a subagent
+ // Pass the state update to the drawer
+ setSubagentStateUpdate(state);
+ return prev;
+ });
},
[],
);
@@ -209,6 +224,10 @@ function App() {
};
const updateConversation = (updatedConversation: Conversation) => {
+ // Skip subagent conversations for the main list
+ if (updatedConversation.parent_conversation_id) {
+ return;
+ }
setConversations((prev) =>
prev.map((conv) =>
conv.conversation_id === updatedConversation.conversation_id
@@ -307,6 +326,8 @@ function App() {
onConversationArchived={handleConversationArchived}
onConversationUnarchived={handleConversationUnarchived}
onConversationRenamed={handleConversationRenamed}
+ subagentUpdate={subagentUpdate}
+ subagentStateUpdate={subagentStateUpdate}
/>
{/* Main chat interface */}
@@ -23,6 +23,7 @@ import ReadImageTool from "./ReadImageTool";
import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
import ChangeDirTool from "./ChangeDirTool";
import BrowserResizeTool from "./BrowserResizeTool";
+import SubagentTool from "./SubagentTool";
import DirectoryPickerModal from "./DirectoryPickerModal";
interface ContextUsageBarProps {
@@ -152,6 +153,7 @@ const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
browser_clear_console_logs: BrowserConsoleLogsTool,
change_dir: ChangeDirTool,
browser_resize: BrowserResizeTool,
+ subagent: SubagentTool,
};
function CoalescedToolCall({
@@ -14,6 +14,8 @@ interface ConversationDrawerProps {
onConversationArchived?: (id: string) => void;
onConversationUnarchived?: (conversation: Conversation) => void;
onConversationRenamed?: (conversation: Conversation) => void;
+ subagentUpdate?: Conversation | null; // When a subagent is created/updated
+ subagentStateUpdate?: { conversation_id: string; working: boolean } | null; // When a subagent's working state changes
}
function ConversationDrawer({
@@ -28,12 +30,16 @@ function ConversationDrawer({
onConversationArchived,
onConversationUnarchived,
onConversationRenamed,
+ subagentUpdate,
+ subagentStateUpdate,
}: ConversationDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
const [archivedConversations, setArchivedConversations] = useState<Conversation[]>([]);
const [loadingArchived, setLoadingArchived] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingSlug, setEditingSlug] = useState("");
+ const [subagents, setSubagents] = useState<Record<string, ConversationWithState[]>>({});
+ const [expandedSubagents, setExpandedSubagents] = useState<Set<string>>(new Set());
const renameInputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -42,6 +48,88 @@ function ConversationDrawer({
}
}, [showArchived]);
+ // Load subagents for the current conversation
+ useEffect(() => {
+ if (currentConversationId && !showArchived) {
+ loadSubagents(currentConversationId);
+ // Auto-expand the current conversation's subagents
+ setExpandedSubagents((prev) => new Set([...prev, currentConversationId]));
+ }
+ }, [currentConversationId, showArchived]);
+
+ // Handle real-time subagent updates
+ useEffect(() => {
+ if (subagentUpdate && subagentUpdate.parent_conversation_id) {
+ const parentId = subagentUpdate.parent_conversation_id;
+ setSubagents((prev) => {
+ const existing = prev[parentId] || [];
+ // Check if this subagent already exists
+ const existingIndex = existing.findIndex(
+ (s) => s.conversation_id === subagentUpdate.conversation_id,
+ );
+ if (existingIndex >= 0) {
+ // Update existing, preserving working state
+ const updated = [...existing];
+ updated[existingIndex] = { ...subagentUpdate, working: existing[existingIndex].working };
+ return { ...prev, [parentId]: updated };
+ } else {
+ // Add new subagent (not working by default)
+ return { ...prev, [parentId]: [...existing, { ...subagentUpdate, working: false }] };
+ }
+ });
+ // Auto-expand parent to show the new subagent
+ setExpandedSubagents((prev) => new Set([...prev, parentId]));
+ }
+ }, [subagentUpdate]);
+
+ // Handle subagent working state updates
+ useEffect(() => {
+ if (subagentStateUpdate) {
+ setSubagents((prev) => {
+ // Find which parent contains this subagent
+ for (const [parentId, subs] of Object.entries(prev)) {
+ const subIndex = subs.findIndex((s) => s.conversation_id === subagentStateUpdate.conversation_id);
+ if (subIndex >= 0) {
+ const updated = [...subs];
+ updated[subIndex] = { ...updated[subIndex], working: subagentStateUpdate.working };
+ return { ...prev, [parentId]: updated };
+ }
+ }
+ return prev;
+ });
+ }
+ }, [subagentStateUpdate]);
+
+ const loadSubagents = async (conversationId: string) => {
+ // Skip if already loaded
+ if (subagents[conversationId]) return;
+ try {
+ const subs = await api.getSubagents(conversationId);
+ if (subs && subs.length > 0) {
+ // Add working: false to each subagent
+ const subsWithState = subs.map((s) => ({ ...s, working: false }));
+ setSubagents((prev) => ({ ...prev, [conversationId]: subsWithState }));
+ }
+ } catch (err) {
+ console.error("Failed to load subagents:", err);
+ }
+ };
+
+ const toggleSubagents = (e: React.MouseEvent, conversationId: string) => {
+ e.stopPropagation();
+ setExpandedSubagents((prev) => {
+ const next = new Set(prev);
+ if (next.has(conversationId)) {
+ next.delete(conversationId);
+ } else {
+ next.add(conversationId);
+ // Load subagents if not already loaded
+ loadSubagents(conversationId);
+ }
+ return next;
+ });
+ };
+
const loadArchivedConversations = async () => {
setLoadingArchived(true);
try {
@@ -267,171 +355,267 @@ function ConversationDrawer({
<div className="conversation-list">
{displayedConversations.map((conversation) => {
const isActive = conversation.conversation_id === currentConversationId;
+ const hasSubagents = subagents[conversation.conversation_id]?.length > 0;
+ const isExpanded = expandedSubagents.has(conversation.conversation_id);
+ const conversationSubagents = subagents[conversation.conversation_id] || [];
return (
- <div
- key={conversation.conversation_id}
- className={`conversation-item ${isActive ? "active" : ""}`}
- onClick={() => {
- if (!showArchived) {
- onSelectConversation(conversation.conversation_id);
- }
- }}
- style={{ cursor: showArchived ? "default" : "pointer" }}
- >
- <div style={{ flex: 1, minWidth: 0 }}>
- <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
- <div style={{ flex: 1, minWidth: 0 }}>
- {editingId === conversation.conversation_id ? (
- <input
- ref={renameInputRef}
- type="text"
- value={editingSlug}
- onChange={(e) => setEditingSlug(e.target.value)}
- onBlur={() => handleRename(conversation.conversation_id)}
- onKeyDown={(e) =>
- handleRenameKeyDown(e, conversation.conversation_id)
- }
- onClick={(e) => e.stopPropagation()}
- autoFocus
- className="conversation-title"
+ <React.Fragment key={conversation.conversation_id}>
+ <div
+ className={`conversation-item ${isActive ? "active" : ""}`}
+ onClick={() => {
+ if (!showArchived) {
+ onSelectConversation(conversation.conversation_id);
+ }
+ }}
+ style={{ cursor: showArchived ? "default" : "pointer" }}
+ >
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
+ <div style={{ flex: 1, minWidth: 0 }}>
+ {editingId === conversation.conversation_id ? (
+ <input
+ ref={renameInputRef}
+ type="text"
+ value={editingSlug}
+ onChange={(e) => setEditingSlug(e.target.value)}
+ onBlur={() => handleRename(conversation.conversation_id)}
+ onKeyDown={(e) =>
+ handleRenameKeyDown(e, conversation.conversation_id)
+ }
+ onClick={(e) => e.stopPropagation()}
+ autoFocus
+ className="conversation-title"
+ style={{
+ width: "100%",
+ background: "transparent",
+ border: "none",
+ borderBottom: "1px solid var(--text-secondary)",
+ outline: "none",
+ padding: 0,
+ font: "inherit",
+ color: "inherit",
+ }}
+ />
+ ) : (
+ <div className="conversation-title">
+ {getConversationPreview(conversation)}
+ </div>
+ )}
+ </div>
+ {(conversation as ConversationWithState).working && (
+ <span
+ className="working-indicator"
+ title="Agent is working"
style={{
- width: "100%",
- background: "transparent",
- border: "none",
- borderBottom: "1px solid var(--text-secondary)",
- outline: "none",
- padding: 0,
- font: "inherit",
- color: "inherit",
+ width: "8px",
+ height: "8px",
+ borderRadius: "50%",
+ backgroundColor: "var(--accent-color, #3b82f6)",
+ flexShrink: 0,
+ animation: "pulse 2s ease-in-out infinite",
}}
/>
- ) : (
- <div className="conversation-title">
- {getConversationPreview(conversation)}
- </div>
)}
</div>
- {(conversation as ConversationWithState).working && (
- <span
- className="working-indicator"
- title="Agent is working"
- style={{
- width: "8px",
- height: "8px",
- borderRadius: "50%",
- backgroundColor: "var(--accent-color, #3b82f6)",
- flexShrink: 0,
- animation: "pulse 2s ease-in-out infinite",
- }}
- />
- )}
- </div>
- <div className="conversation-meta">
- <span className="conversation-date">
- {formatDate(conversation.updated_at)}
- </span>
- {conversation.cwd && (
- <span className="conversation-cwd" title={conversation.cwd}>
- {formatCwdForDisplay(conversation.cwd)}
+ <div className="conversation-meta">
+ <span className="conversation-date">
+ {formatDate(conversation.updated_at)}
</span>
- )}
- {!showArchived && (
- <div
- className="conversation-actions"
- style={{ display: "flex", gap: "0.25rem", marginLeft: "auto" }}
- >
- <button
- onClick={(e) => handleStartRename(e, conversation)}
- className="btn-icon-sm"
- title="Rename"
- aria-label="Rename conversation"
+ {conversation.cwd && (
+ <span className="conversation-cwd" title={conversation.cwd}>
+ {formatCwdForDisplay(conversation.cwd)}
+ </span>
+ )}
+ {!showArchived && (
+ <div
+ className="conversation-actions"
+ style={{ display: "flex", gap: "0.25rem", marginLeft: "auto" }}
>
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
+ <button
+ onClick={(e) => handleStartRename(e, conversation)}
+ className="btn-icon-sm"
+ title="Rename"
+ aria-label="Rename conversation"
>
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
- />
- </svg>
- </button>
- <button
- onClick={(e) => handleArchive(e, conversation.conversation_id)}
- className="btn-icon-sm"
- title="Archive"
- aria-label="Archive conversation"
- >
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <button
+ onClick={(e) => handleArchive(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ title="Archive"
+ aria-label="Archive conversation"
>
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
- />
- </svg>
- </button>
- </div>
- )}
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+ />
+ </svg>
+ </button>
+ {/* Subagent count indicator */}
+ {hasSubagents && (
+ <button
+ onClick={(e) => toggleSubagents(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: "0.125rem",
+ fontSize: "0.75rem",
+ minWidth: "auto",
+ padding: "0.125rem 0.25rem",
+ }}
+ title={isExpanded ? "Hide subagents" : "Show subagents"}
+ aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
+ >
+ <span style={{ fontWeight: 500 }}>
+ {conversationSubagents.length}
+ </span>
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{
+ width: "0.625rem",
+ height: "0.625rem",
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.15s ease",
+ }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M9 5l7 7-7 7"
+ />
+ </svg>
+ </button>
+ )}
+ </div>
+ )}
+ </div>
</div>
- </div>
- {showArchived && (
- <div
- className="conversation-actions"
- style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
- >
- <button
- onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
- className="btn-icon-sm"
- title="Restore"
- aria-label="Restore conversation"
+ {showArchived && (
+ <div
+ className="conversation-actions"
+ style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
>
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
+ <button
+ onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
+ className="btn-icon-sm"
+ title="Restore"
+ aria-label="Restore conversation"
>
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
- />
- </svg>
- </button>
- <button
- onClick={(e) => handleDelete(e, conversation.conversation_id)}
- className="btn-icon-sm btn-danger"
- title="Delete permanently"
- aria-label="Delete conversation"
- >
- <svg
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- style={{ width: "1rem", height: "1rem" }}
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ </button>
+ <button
+ onClick={(e) => handleDelete(e, conversation.conversation_id)}
+ className="btn-icon-sm btn-danger"
+ title="Delete permanently"
+ aria-label="Delete conversation"
>
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
- />
- </svg>
- </button>
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ style={{ width: "1rem", height: "1rem" }}
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
+ </div>
+ )}
+ </div>
+ {/* Render subagents if expanded */}
+ {!showArchived && isExpanded && conversationSubagents.length > 0 && (
+ <div className="subagent-list" style={{ marginLeft: "1.5rem" }}>
+ {conversationSubagents.map((sub) => {
+ const isSubActive = sub.conversation_id === currentConversationId;
+ return (
+ <div
+ key={sub.conversation_id}
+ className={`conversation-item subagent-item ${isSubActive ? "active" : ""}`}
+ onClick={() => onSelectConversation(sub.conversation_id)}
+ style={{
+ cursor: "pointer",
+ fontSize: "0.9em",
+ paddingLeft: "0.5rem",
+ borderLeft: "2px solid var(--border-color)",
+ }}
+ >
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div className="conversation-title">
+ {sub.slug || sub.conversation_id}
+ </div>
+ </div>
+ {sub.working && (
+ <span
+ className="working-indicator"
+ title="Subagent is working"
+ style={{
+ width: "6px",
+ height: "6px",
+ borderRadius: "50%",
+ backgroundColor: "var(--accent-color, #3b82f6)",
+ flexShrink: 0,
+ animation: "pulse 2s ease-in-out infinite",
+ }}
+ />
+ )}
+ </div>
+ <div className="conversation-meta">
+ <span
+ className="conversation-date"
+ style={{ fontSize: "0.85em" }}
+ >
+ {formatDate(sub.updated_at)}
+ </span>
+ </div>
+ </div>
+ </div>
+ );
+ })}
</div>
)}
- </div>
+ </React.Fragment>
);
})}
</div>
@@ -13,6 +13,7 @@ import ReadImageTool from "./ReadImageTool";
import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
import ChangeDirTool from "./ChangeDirTool";
import BrowserResizeTool from "./BrowserResizeTool";
+import SubagentTool from "./SubagentTool";
import UsageDetailModal from "./UsageDetailModal";
import MessageActionBar from "./MessageActionBar";
@@ -370,6 +371,10 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
if (content.ToolName === "browser_resize") {
return <BrowserResizeTool toolInput={content.ToolInput} isRunning={true} />;
}
+ // Use specialized component for subagent tool
+ if (content.ToolName === "subagent") {
+ return <SubagentTool toolInput={content.ToolInput} isRunning={true} />;
+ }
// Use specialized component for browser console logs tools
if (
content.ToolName === "browser_recent_console_logs" ||
@@ -569,6 +574,20 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
);
}
+ // Use specialized component for subagent tool
+ if (toolName === "subagent") {
+ return (
+ <SubagentTool
+ toolInput={toolInput}
+ isRunning={false}
+ toolResult={content.ToolResult}
+ hasError={hasError}
+ executionTime={executionTime}
+ displayData={content.Display as { slug?: string; conversation_id?: string }}
+ />
+ );
+ }
+
// Use specialized component for browser console logs tools
if (
toolName === "browser_recent_console_logs" ||
@@ -0,0 +1,143 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface SubagentToolProps {
+ // For tool_use (pending state)
+ toolInput?: unknown; // { slug: string, prompt: string, timeout_seconds?: number, wait?: boolean }
+ isRunning?: boolean;
+
+ // For tool_result (completed state)
+ toolResult?: LLMContent[];
+ hasError?: boolean;
+ executionTime?: string;
+ displayData?: { slug?: string; conversation_id?: string };
+}
+
+function SubagentTool({
+ toolInput,
+ isRunning,
+ toolResult,
+ hasError,
+ executionTime,
+ displayData,
+}: SubagentToolProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Extract fields from toolInput
+ const input =
+ typeof toolInput === "object" && toolInput !== null
+ ? (toolInput as { slug?: string; prompt?: string; timeout_seconds?: number; wait?: boolean })
+ : {};
+
+ const slug = input.slug || displayData?.slug || "subagent";
+ const prompt = input.prompt || "";
+ const wait = input.wait !== false;
+ const timeout = input.timeout_seconds || 60;
+
+ // Extract result text
+ const resultText =
+ toolResult
+ ?.filter((r) => r.Type === 2) // ContentTypeText
+ .map((r) => r.Text)
+ .join("\n") || "";
+
+ // Truncate prompt for display
+ const truncateText = (text: string, maxLen: number = 60) => {
+ if (!text) return "";
+ const firstLine = text.split("\n")[0];
+ if (firstLine.length <= maxLen) return firstLine;
+ return firstLine.substring(0, maxLen) + "...";
+ };
+
+ const displayPrompt = truncateText(prompt);
+ const isComplete = !isRunning && toolResult !== undefined;
+
+ return (
+ <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+ <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+ <div className="tool-summary">
+ <span className={`tool-emoji ${isRunning ? "running" : ""}`}>⚡</span>
+ <span className="tool-name">subagent</span>
+ {isComplete && hasError && <span className="tool-error">✗</span>}
+ {isComplete && !hasError && <span className="tool-success">✓</span>}
+ <span className="tool-command">
+ Subagent '{slug}' {isRunning ? (wait ? "running..." : "started") : ""}
+ {displayPrompt && !isRunning && ` ${displayPrompt}`}
+ </span>
+ </div>
+ <button
+ className="tool-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {isExpanded && (
+ <div className="tool-details">
+ <div className="tool-section">
+ <div className="tool-label">
+ Prompt to '{slug}':
+ {!wait && <span className="tool-badge">fire-and-forget</span>}
+ {timeout !== 60 && <span className="tool-badge">timeout: {timeout}s</span>}
+ </div>
+ <div className="tool-code">{prompt || "(no prompt)"}</div>
+ </div>
+
+ {isComplete && (
+ <div className="tool-section">
+ <div className="tool-label">
+ Response:
+ {executionTime && <span className="tool-time">{executionTime}</span>}
+ </div>
+ <div className={`tool-code ${hasError ? "error" : ""}`}>
+ {resultText || "(no response)"}
+ </div>
+ </div>
+ )}
+
+ {displayData?.conversation_id && (
+ <div className="tool-section">
+ <div className="tool-label">Conversation:</div>
+ <div className="tool-code">
+ <a
+ href={`/c/${slug}`}
+ onClick={(e) => {
+ e.preventDefault();
+ // Navigate to the subagent conversation
+ window.history.pushState({}, "", `/c/${slug}`);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ }}
+ style={{ color: "var(--link-color)", textDecoration: "underline" }}
+ >
+ View subagent conversation →
+ </a>
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default SubagentTool;
@@ -4,59 +4,61 @@
// DO NOT EDIT. This file is automatically generated.
export interface Conversation {
- conversation_id: string;
- slug: string | null;
- user_initiated: boolean;
- created_at: string;
- updated_at: string;
- cwd: string | null;
- archived: boolean;
+ conversation_id: string;
+ slug: string | null;
+ user_initiated: boolean;
+ created_at: string;
+ updated_at: string;
+ cwd: string | null;
+ archived: boolean;
+ parent_conversation_id: string | null;
}
export interface Usage {
- input_tokens: number;
- cache_creation_input_tokens: number;
- cache_read_input_tokens: number;
- output_tokens: number;
- cost_usd: number;
- model?: string;
- start_time?: string | null;
- end_time?: string | null;
+ input_tokens: number;
+ cache_creation_input_tokens: number;
+ cache_read_input_tokens: number;
+ output_tokens: number;
+ cost_usd: number;
+ model?: string;
+ start_time?: string | null;
+ end_time?: string | null;
}
export interface ApiMessageForTS {
- message_id: string;
- conversation_id: string;
- sequence_id: number;
- type: string;
- llm_data?: string | null;
- user_data?: string | null;
- usage_data?: string | null;
- created_at: string;
- display_data?: string | null;
- end_of_turn?: boolean | null;
+ message_id: string;
+ conversation_id: string;
+ sequence_id: number;
+ type: string;
+ llm_data?: string | null;
+ user_data?: string | null;
+ usage_data?: string | null;
+ created_at: string;
+ display_data?: string | null;
+ end_of_turn?: boolean | null;
}
export interface ConversationStateForTS {
- conversation_id: string;
- working: boolean;
+ conversation_id: string;
+ working: boolean;
}
export interface StreamResponseForTS {
- messages: ApiMessageForTS[] | null;
- conversation: Conversation;
- conversation_state?: ConversationStateForTS | null;
+ messages: ApiMessageForTS[] | null;
+ conversation: Conversation;
+ conversation_state?: ConversationStateForTS | null;
}
export interface ConversationWithStateForTS {
- conversation_id: string;
- slug: string | null;
- user_initiated: boolean;
- created_at: string;
- updated_at: string;
- cwd: string | null;
- archived: boolean;
- working: boolean;
+ conversation_id: string;
+ slug: string | null;
+ user_initiated: boolean;
+ created_at: string;
+ updated_at: string;
+ cwd: string | null;
+ archived: boolean;
+ parent_conversation_id: string | null;
+ working: boolean;
}
-export type MessageType = "user" | "agent" | "tool" | "error" | "system" | "gitinfo";
+export type MessageType = 'user' | 'agent' | 'tool' | 'error' | 'system' | 'gitinfo';
@@ -200,6 +200,14 @@ class ApiService {
}
return response.json();
}
+
+ async getSubagents(conversationId: string): Promise<Conversation[]> {
+ const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/subagents`);
+ if (!response.ok) {
+ throw new Error(`Failed to get subagents: ${response.statusText}`);
+ }
+ return response.json();
+ }
}
export const api = new ApiService();