Files
kandev/apps/backend/cmd/mock-agent/handler.go
T

674 lines
24 KiB
Go

package main
import (
"context"
"fmt"
"math/rand"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
acp "github.com/coder/acp-go-sdk"
)
// overloaded529Message is the exact transient 529 message the real Anthropic
// API returns and the claude-agent-acp adapter surfaces over ACP. The
// orchestrator's routingerr classifier matches the "529 ... Overloaded"
// signature in this string and routes it to the retry-with-backoff path.
const overloaded529Message = "Internal error: API Error: 529 Overloaded. This is a server-side issue, " +
"usually temporary. Try again in a moment. If it persists, check https://status.claude.com."
// overloadedCmdRe matches `/overloaded` or `/e2e:overloaded`, optionally
// followed by `:N` — the number of consecutive prompts to fail with a 529
// before recovering (default 1, so the turn self-heals on the first retry).
// Use a large N (e.g. `/overloaded:9`) to exhaust the retry budget and fall
// through to the red recovery banner.
var (
overloadedCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?overloaded(?::(\d+))?$`)
changesWalkthroughPromptRefRe = regexp.MustCompile(`^@changes-walkthrough(?:\s|$)`)
)
const changesWalkthroughPromptMarker = "Please create an agent-authored walkthrough of the current changes"
func isChangesWalkthroughRequest(prompt string) bool {
cmd := stripKandevSystem(strings.TrimSpace(prompt))
legacyPrompt := strings.Contains(cmd, changesWalkthroughPromptMarker) &&
strings.Contains(cmd, "show_walkthrough_kandev") &&
strings.Contains(cmd, "Available changed files:")
promptReference := changesWalkthroughPromptRefRe.MatchString(cmd)
return legacyPrompt || promptReference
}
// parseOverloadedCmd reports whether the prompt is the /overloaded command and,
// if so, how many consecutive prompts it should fail before recovering
// (default 1). Returns ok=false for any other prompt.
func parseOverloadedCmd(prompt string) (failTimes int, ok bool) {
cmd := stripKandevSystem(strings.TrimSpace(prompt))
m := overloadedCmdRe.FindStringSubmatch(cmd)
if m == nil {
return 0, false
}
failTimes = 1
if m[1] != "" {
if n, err := strconv.Atoi(m[1]); err == nil && n >= 0 {
failTimes = n
}
}
return failTimes, true
}
// handleOverloaded implements the /overloaded scenario: it returns a real
// prompt-time ACP error carrying the production 529 string for the first N
// prompts of a session, then recovers with a normal text response. Returns
// handled=false when the prompt is not the overloaded command.
func (a *mockAgent) handleOverloaded(ctx context.Context, sid acp.SessionId, prompt string) (acp.PromptResponse, error, bool) {
failTimes, ok := parseOverloadedCmd(prompt)
if !ok {
return acp.PromptResponse{}, nil, false
}
// The orchestrator's backoff retry tears the agent process down and
// relaunches it via --resume, so an in-memory counter would reset every
// attempt and never recover. Persist the attempt count in a file keyed by
// the (resume-stable) session id so fail-then-recover survives relaunch.
attempt := nextOverloadedAttempt(sid)
if attempt <= failTimes {
_, _ = fmt.Fprintf(logOutput, "mock-agent[%d]: emitting 529 Overloaded for session %s (failure %d/%d)\n",
os.Getpid(), sid, attempt, failTimes)
return acp.PromptResponse{}, &acp.RequestError{
Code: -32603,
Message: overloaded529Message,
Data: map[string]any{"errorKind": "server_error"},
}, true
}
// Recovered: clear the counter and emit a normal success response so the
// turn completes (and the orchestrator clears the retry budget).
_ = os.Remove(overloadedCounterPath(sid))
e := &emitter{ctx: ctx, conn: a.conn, sid: sid}
e.text(fmt.Sprintf("Provider recovered after %d transient failure(s). Here is your response.", failTimes))
return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil, true
}
// overloadedCounterPath returns the temp-file path tracking how many times the
// /overloaded scenario has fired for a session. Keyed by the resume-stable ACP
// session id so the count survives process relaunch across backoff retries.
func overloadedCounterPath(sid acp.SessionId) string {
safe := strings.NewReplacer("/", "_", "\\", "_", "..", "_").Replace(string(sid))
return filepath.Join(os.TempDir(), "kandev-mock-overloaded-"+safe+".count")
}
// nextOverloadedAttempt increments and returns the persisted attempt count.
func nextOverloadedAttempt(sid acp.SessionId) int {
path := overloadedCounterPath(sid)
prev := 0
if raw, err := os.ReadFile(path); err == nil {
if n, convErr := strconv.Atoi(strings.TrimSpace(string(raw))); convErr == nil {
prev = n
}
}
next := prev + 1
_ = os.WriteFile(path, []byte(strconv.Itoa(next)), 0o600)
return next
}
// bulkCmdRe matches `/bulk` or `/e2e:bulk`, optionally followed by `:N` or ` N`
// — the number of agent messages to emit (default 120, capped at 1000). Used to
// populate a long chat history for manually testing scrollback and the "Load
// older messages" pagination in dev/preview.
var bulkCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?bulk(?:[: ]+(\d+))?$`)
const (
bulkDefaultCount = 120
bulkMaxCount = 1000
// Tool-input/output map keys, shared to avoid repeating the literals.
toolKeyFilePath = "file_path"
toolKeyContent = "content"
toolKeyTaskID = "task_id"
toolKeyError = "error"
toolKeyResult = "result"
)
// parseBulkCmd reports whether the prompt is the /bulk command and, if so, how
// many messages to emit (default 120, capped at 1000). Returns ok=false for any
// other prompt.
func parseBulkCmd(prompt string) (count int, ok bool) {
cmd := stripKandevSystem(strings.TrimSpace(prompt))
m := bulkCmdRe.FindStringSubmatch(cmd)
if m == nil {
return 0, false
}
count = bulkDefaultCount
if m[1] != "" {
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
count = n
}
}
if count > bulkMaxCount {
count = bulkMaxCount
}
return count, true
}
// emitBulk emits `count` short agent messages, each followed by a lightweight
// completed tool call. The tool-call boundary flushes the buffered text above
// into its own message row — consecutive agent text chunks otherwise coalesce
// into a single message — so the result is a long, paginated conversation. Handy
// for manually exercising chat scrollback and the "Load older messages" button
// in dev/preview without a real agent. Usage: `/bulk`, `/bulk:300`, `/bulk 300`.
func emitBulk(e *emitter, count int) {
e.text(fmt.Sprintf("Generating %d messages to populate the chat history…", count))
for i := 1; i <= count; i++ {
e.text(fmt.Sprintf(
"Bulk message %d of %d: filler content for testing chat pagination and the load-older button.",
i, count))
// The tool-call start flushes the text above into its own message row.
toolID := nextToolID()
e.startTool(toolID, fmt.Sprintf("Read file-%d.txt", i), acp.ToolKindRead,
map[string]any{toolKeyFilePath: fmt.Sprintf("/tmp/bulk/file-%d.txt", i)})
e.completeTool(toolID, map[string]any{toolKeyContent: fmt.Sprintf("contents of file %d", i)})
fixedDelay(8)
}
e.text(fmt.Sprintf(
"Done. Emitted %d messages. Scroll up and use “Load older messages” to page through them.",
count))
}
// delayRange returns min/max delay in milliseconds based on model name.
func delayRange(model string) (int, int) {
switch model {
case modelFast:
return 10, 50
case modelSlow:
return 500, 3000
default:
return 100, 500
}
}
// randomDelay sleeps for a random duration within the model's delay range.
func randomDelay(model string) {
lo, hi := delayRange(model)
ms := lo + rand.Intn(hi-lo+1)
time.Sleep(time.Duration(ms) * time.Millisecond)
}
// fixedDelay sleeps for a fixed duration (for e2e scenarios).
func fixedDelay(ms int) {
time.Sleep(time.Duration(ms) * time.Millisecond)
}
// waitForDelay is the cancellation-aware counterpart used by inline E2E
// scripts. Real ACP prompts stop producing work when the request context is
// cancelled; honoring that context keeps the mock fixture suitable for
// testing acknowledged cancellation instead of forcing every test to wait
// for an artificial sleep to expire.
func waitForDelay(ctx context.Context, ms int) {
if ctx == nil {
fixedDelay(ms)
return
}
timer := time.NewTimer(time.Duration(ms) * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
// stripKandevSystem removes all <kandev-system>...</kandev-system> blocks from the
// prompt. Tags can be prepended (backend system context injection) or appended
// (frontend plan/document context), so we strip all occurrences.
func stripKandevSystem(prompt string) string {
result := kandevSystemRegex.ReplaceAllString(prompt, "")
return strings.TrimSpace(result)
}
var kandevSystemRegex = regexp.MustCompile(`<kandev-system>[\s\S]*?</kandev-system>`)
var autopilotParentReplyRegex = regexp.MustCompile(`task_id=([0-9a-fA-F-]+), reply_to_question_id=([0-9a-fA-F-]+)`)
const autopilotParentReplyDelayMillis = 5_000
// handleAutopilotParentQuestion makes the mock parent follow the production
// parent-question protocol. The prompt is generated by Kandev, so this keeps
// the E2E path deterministic without adding a second test-only API.
func handleAutopilotParentQuestion(e *emitter, prompt string) bool {
if !strings.Contains(prompt, "AUTOPILOT CHILD QUESTION") {
return false
}
matches := autopilotParentReplyRegex.FindStringSubmatch(prompt)
if len(matches) != 3 {
e.text("Mock parent could not parse the autopilot question reply route.")
return true
}
childTaskID, questionID := matches[1], matches[2]
// Keep the pending state visible long enough for the desktop and mobile
// specs to observe the question icon before the deterministic answer.
waitForDelay(e.ctx, autopilotParentReplyDelayMillis)
toolID := nextToolID()
e.startTool(toolID, "message_task_kandev", acp.ToolKindOther, map[string]any{
toolKeyTaskID: childTaskID,
"reply_to_question_id": questionID,
})
result, err := callMCPTool("kandev", "message_task_kandev", map[string]any{
toolKeyTaskID: childTaskID,
clarificationPromptKey: "Use the first safe option and continue.",
"reply_to_question_id": questionID,
})
if err != nil {
e.completeTool(toolID, map[string]any{toolKeyError: err.Error()})
e.text("Mock parent could not answer the autopilot question.")
return true
}
e.completeTool(toolID, map[string]any{toolKeyResult: result})
e.text("Answered the autopilot child question.")
return true
}
// handlePrompt routes a user prompt to the appropriate sequence generator.
func handlePrompt(e *emitter, prompt, model string) {
prompt = strings.TrimSpace(prompt)
// Extract the user-facing content for command routing.
cmd := stripKandevSystem(prompt)
if handleAutopilotParentQuestion(e, cmd) {
return
}
// Script mode: each line is a command (e2e:message, e2e:mcp:*, etc.)
if isScriptMode(cmd) {
executeScript(e, prompt, cmd)
return
}
// Bulk mode: emit many messages to populate a long, paginated chat history.
// Handled before the switch so `/e2e:bulk` doesn't fall into the generic
// `/e2e:<scenario>` branch.
if count, ok := parseBulkCmd(prompt); ok {
emitBulk(e, count)
return
}
if isChangesWalkthroughRequest(cmd) {
scenarioWalkthroughRequested(e)
return
}
// Native code review: the backend sends a generated prompt (not a slash
// command), so match on its sentinel and reply with the fenced JSON the
// parser expects.
if isCodeReviewRequest(prompt) {
e.text(codeReviewResponse(prompt))
return
}
switch {
case strings.EqualFold(cmd, "all") || strings.EqualFold(cmd, "/all"):
emitAllTypes(e, model)
case strings.EqualFold(cmd, "/error"):
emitError(e, model)
case strings.EqualFold(cmd, "/slow") || strings.HasPrefix(strings.ToLower(cmd), "/slow "):
emitSlowResponse(e, cmd, model)
case strings.EqualFold(cmd, "/thinking"):
emitThinkingSequence(e, model)
case strings.HasPrefix(cmd, "/tool:"):
toolName := strings.TrimPrefix(cmd, "/tool:")
emitSpecificTool(e, strings.TrimSpace(toolName), model)
case strings.HasPrefix(cmd, "/subagent"):
emitSubagentSequence(e, model)
case strings.EqualFold(cmd, "/subtask") || strings.HasPrefix(strings.ToLower(cmd), "/subtask "):
emitCreateSubtask(e, cmd, model)
case strings.HasPrefix(cmd, "/e2e:"):
rest := strings.TrimPrefix(cmd, "/e2e:")
scenarioName, _, _ := strings.Cut(strings.TrimSpace(rest), " ")
emitPredefinedScenario(e, scenarioName)
// Friendly aliases for the two clarification e2e scenarios so the slash menu
// exposes them without forcing users to type /e2e:clarification(-multi).
case strings.EqualFold(cmd, "/ask-single"):
emitPredefinedScenario(e, "clarification")
case strings.EqualFold(cmd, "/ask-multiple"):
emitPredefinedScenario(e, "clarification-multi")
case strings.EqualFold(cmd, "/crash"):
emitCrash(e, model)
case strings.HasPrefix(cmd, "/todo"):
emitTodoSequence(e, model)
case strings.EqualFold(cmd, "/mermaid"):
emitMermaidSequence(e, model)
case strings.EqualFold(cmd, "/markdown"):
emitMarkdownShowcase(e, model)
case strings.EqualFold(cmd, "/sleep") || strings.HasPrefix(strings.ToLower(cmd), "/sleep "):
emitSleep(e, cmd)
case strings.EqualFold(cmd, "/background") || strings.HasPrefix(strings.ToLower(cmd), "/background "):
emitBackgroundWork(e, cmd)
case strings.EqualFold(cmd, "/detached-background") || strings.HasPrefix(strings.ToLower(cmd), "/detached-background "):
emitDetachedBackgroundWork(e, cmd)
case strings.EqualFold(cmd, "/async-subagent-lifecycle") || strings.HasPrefix(strings.ToLower(cmd), "/async-subagent-lifecycle "):
emitAsyncSubagentLifecycle(e, cmd, true)
case strings.EqualFold(cmd, "/async-subagent-teardown"):
emitAsyncSubagentLifecycle(e, cmd, false)
default:
emitRandomResponse(e, cmd, model)
}
}
const asyncSubagentAgentID = "agent_e2e_async_lifecycle"
// emitAsyncSubagentLifecycle replays the ordering emitted by a detached Claude
// Agent rather than the shell-oriented /detached-background sequence:
//
// 1. Agent tool launch acknowledgement with a stable agentId
// 2. human-origin usage boundary (foreground idle)
// 3. final thought and assistant output from the same prompt
// 4. Prompt returns to its caller, completing the foreground turn
// 5. optional ID-less task-notification boundary after the child finishes
//
// The teardown variant intentionally omits step 5 so execution termination is
// the only evidence available to reconcile the registration.
func emitAsyncSubagentLifecycle(e *emitter, cmd string, emitCompletion bool) {
d := parseCommandDuration(cmd, 20*time.Second)
toolCallID := nextToolID()
e.launchAsyncSubagentTool(
toolCallID,
"Async subagent exploration",
"Continue independently after the foreground turn ends",
"general-purpose",
)
e.foregroundIdle()
e.thought("The child is launched; I can return control to the operator.")
e.text("Foreground response after async launch.")
if !emitCompletion {
return
}
backgroundEmitter := &emitter{ctx: context.Background(), conn: e.conn, sid: e.sid}
go func() {
time.Sleep(d)
// Claude's async Agent invocation is terminal at launch. The later
// completion is a task-notification usage frame, not a second tool update.
backgroundEmitter.completeDetachedWork()
}()
}
// emitDetachedBackgroundWork launches work that outlives this prompt response.
// It is intentionally different from /background, whose foreground prompt stays
// open for the entire delay, so E2E can cover settled+background explicitly.
func emitDetachedBackgroundWork(e *emitter, cmd string) {
d := parseCommandDuration(cmd, 8*time.Second)
e.text("Launching detached background work; this foreground turn is complete.")
taskToolID := nextToolID()
e.launchAsyncSubagentTool(taskToolID,
"Detached background exploration",
"Continue working after the foreground response ends",
"general-purpose")
backgroundEmitter := &emitter{ctx: context.Background(), conn: e.conn, sid: e.sid}
go func() {
time.Sleep(d)
backgroundEmitter.completeDetachedWork()
}()
}
// emitSleep sleeps for the requested duration (default 10s) then responds.
// Useful for simulating a slow agent turn without any tool calls.
func emitSleep(e *emitter, cmd string) {
d := 10 * time.Second
parts := strings.Fields(cmd)
if len(parts) >= 2 {
if secs, err := time.ParseDuration(parts[1] + "s"); err == nil && secs > 0 {
d = secs
} else if parsed, err2 := time.ParseDuration(parts[1]); err2 == nil && parsed > 0 {
d = parsed
}
}
time.Sleep(d)
e.text(fmt.Sprintf("Slept for %s.", d))
}
// emitBackgroundWork reproduces the fine-grained busy signal window
// (ADR-0049): the foreground emits a line, spawns a
// top-level subagent Task that holds the turn open, then goes IDLE for the
// requested duration (default 8s) with no foreground output — the exact state
// where the orchestrator narrows the busy gate so the composer accepts input
// while the session still reads RUNNING. When the hold elapses the subagent
// completes, the foreground resumes, and the turn ends (→ done).
func emitBackgroundWork(e *emitter, cmd string) {
d := parseCommandDuration(cmd, 8*time.Second)
e.text("Kicking off background work; I'll keep going in the background.")
taskToolID := nextToolID()
e.startSubagentTool(taskToolID,
"Background exploration",
"Explore the codebase while the foreground stays idle",
"general-purpose")
e.foregroundIdle()
// Hold the turn open with NO foreground output so the session stays in the
// background-idle substate for the whole window.
time.Sleep(d)
e.completeSubagentTool(taskToolID, "Background work finished", subagentResult{
agentID: "agent_e2e_background",
subagentType: "general-purpose",
durationMs: d.Milliseconds(),
totalTokens: 4242,
toolUseCount: 1,
})
e.text("Background work complete.")
}
// parseCommandDuration reads an optional command duration, returning def when
// it is absent or unparseable. Explicit units are honored as-is; bare numbers
// are treated as seconds. The explicit-unit parse is tried first so a value
// like `1m` is not misread as the valid-but-wrong `1ms`.
func parseCommandDuration(cmd string, def time.Duration) time.Duration {
parts := strings.Fields(cmd)
if len(parts) < 2 {
return def
}
if parsed, err := time.ParseDuration(parts[1]); err == nil && parsed > 0 {
return parsed
}
if secs, err := time.ParseDuration(parts[1] + "s"); err == nil && secs > 0 {
return secs
}
return def
}
// emitError emits an error message.
func emitError(e *emitter, model string) {
randomDelay(model)
e.text("Simulating an error condition...")
randomDelay(model)
e.text("Mock error: something went wrong during processing")
}
// emitCrash simulates an agent crash by exiting with code 1 after emitting
// some output. Useful for testing recovery flows.
func emitCrash(e *emitter, model string) {
randomDelay(model)
e.text("Processing your request...")
randomDelay(model)
fmt.Fprintln(os.Stderr, "mock-agent: simulating crash (exit 1)")
os.Exit(1)
}
// emitSlowResponse generates a response with configurable total duration.
func emitSlowResponse(e *emitter, prompt, model string) {
totalDuration := parseCommandDuration(prompt, 5*time.Second)
steps := 5
stepDelay := totalDuration / time.Duration(steps)
emitThinking(e, model)
time.Sleep(stepDelay)
e.text(fmt.Sprintf("Running slow response (%s total)...", totalDuration))
time.Sleep(stepDelay)
emitReadFile(e, model)
time.Sleep(stepDelay)
emitCodeSearch(e, model)
time.Sleep(stepDelay)
e.text(fmt.Sprintf("Slow response complete after %s.", totalDuration))
time.Sleep(stepDelay)
}
// emitRandomResponse generates a random mix of 2-5 events.
func emitRandomResponse(e *emitter, prompt, model string) {
generators := []func(){
func() { emitThinking(e, model) },
func() { e.text("I'll help you with that. Let me look into it.") },
func() { emitReadFile(e, model) },
func() { emitCodeSearch(e, model) },
func() { emitWebFetch(e, model) },
}
// Always start with thinking
emitThinking(e, model)
randomDelay(model)
// Pick 1-4 more random events
count := 1 + rand.Intn(4)
for i := 0; i < count; i++ {
idx := rand.Intn(len(generators))
generators[idx]()
randomDelay(model)
}
// End with a text summary
e.text("I've completed the analysis of your request: \"" + prompt + "\". Everything looks good!")
}
// emitAllTypes emits one of every message type.
func emitAllTypes(e *emitter, model string) {
emitThinking(e, model)
randomDelay(model)
e.text("Starting comprehensive demonstration of all message types...")
randomDelay(model)
emitReadFile(e, model)
randomDelay(model)
emitEditFile(e, model)
randomDelay(model)
emitShellExec(e, model)
randomDelay(model)
emitCodeSearch(e, model)
randomDelay(model)
emitSubagent(e, model)
randomDelay(model)
emitTodo(e, model)
randomDelay(model)
emitWebFetch(e, model)
randomDelay(model)
e.text("All message types demonstrated successfully!")
}
// emitThinkingSequence emits extended thinking/reasoning blocks.
func emitThinkingSequence(e *emitter, model string) {
thoughts := []string{
"Let me analyze this problem step by step...",
"First, I need to consider the architecture and how the components interact.",
"The key insight is that we need to handle both synchronous and asynchronous flows.",
"I should also consider edge cases: what happens when the input is empty? What about concurrent access?",
"After careful analysis, I believe the best approach is to use a channel-based pattern with proper synchronization.",
}
for _, thought := range thoughts {
randomDelay(model)
e.thought(thought)
}
randomDelay(model)
e.text("After careful reasoning, here is my analysis:\n\n1. The architecture is sound\n2. Error handling covers edge cases\n3. The implementation follows Go best practices")
}
// emitSpecificTool emits a single specific tool call.
func emitSpecificTool(e *emitter, toolName, model string) {
switch strings.ToLower(toolName) {
case "read":
emitReadFile(e, model)
case "edit":
emitEditFile(e, model)
case "exec", "bash":
emitShellExec(e, model)
case "search", "grep":
emitCodeSearch(e, model)
case "webfetch", "web":
emitWebFetch(e, model)
default:
e.text("Unknown tool: " + toolName + ". Available: read, edit, exec, search, webfetch")
}
}
// emitTodoSequence emits a todo management sequence.
func emitTodoSequence(e *emitter, model string) {
emitThinking(e, model)
randomDelay(model)
e.text("I'll create a task list for this work.")
randomDelay(model)
emitTodo(e, model)
randomDelay(model)
e.text("Task list has been updated.")
}
// emitSubagentSequence emits a subagent Task sequence.
func emitSubagentSequence(e *emitter, model string) {
emitThinking(e, model)
randomDelay(model)
e.text("I'll delegate this to a subagent for parallel processing.")
randomDelay(model)
emitSubagent(e, model)
randomDelay(model)
e.text("Subagent task completed successfully.")
}
// emitCreateSubtask calls the kandev MCP `create_task_kandev` tool with
// parent_id="self" to create a subtask of the current task. Useful for
// manually exercising sidebar subtask UI in dev with KANDEV_MOCK_AGENT=true.
// Usage: `/subtask` or `/subtask My subtask title`.
func emitCreateSubtask(e *emitter, cmd, model string) {
title := parseSubtaskTitle(cmd)
args := map[string]any{
"title": title,
"parent_id": "self",
"start_agent": false,
}
toolID := nextToolID()
e.startTool(toolID, "create_task_kandev", acp.ToolKindOther, args)
randomDelay(model)
result, err := callMCPTool("kandev", "create_task_kandev", args)
if err != nil {
e.completeTool(toolID, map[string]any{toolKeyError: "MCP error: " + err.Error()})
e.text(fmt.Sprintf("Failed to create subtask: %v", err))
return
}
e.completeTool(toolID, map[string]any{toolKeyResult: result})
e.text(fmt.Sprintf("Created subtask %q under the current task.", title))
}
// parseSubtaskTitle extracts the title from a /subtask command. The dispatch
// matches case-insensitively, so we slice by prefix length rather than using
// strings.TrimPrefix (which would no-op on "/SubTask foo" and leak the prefix
// into the title). Returns an auto-generated title when no title is supplied.
func parseSubtaskTitle(cmd string) string {
const prefix = "/subtask"
title := ""
if len(cmd) >= len(prefix) {
title = strings.TrimSpace(cmd[len(prefix):])
}
if title == "" {
title = fmt.Sprintf("Mock subtask %d", time.Now().Unix()%10000)
}
return title
}