// Command mockapi is a fake Messages API for exercising the TUI by hand, // without an API key and without spending anything. // // It lives under testdata so it is invisible to ./... — it is a manual test // fixture, not part of the build. // // go run ./internal/tui/testdata/mockapi & // ./harness -C /tmp/demo -base-url http://127.0.0.1:8899 -api-key mock // // The script is fixed: the first turn streams a sentence and asks to run a // shell command, which makes the TUI raise an approval prompt; once a // tool_result comes back it streams a closing sentence and stops. That covers // streaming, tool rendering, approve, deny, and interrupt. // // With -spawn the script changes: the first turn asks for two subagents // instead of a shell command, and every request carrying a subagent's system // prompt is answered as that subagent — a short report, after a pause long // enough to watch the sidebar fill in. // // Flags: -addr to move it, -fail to make the command exit non-zero, -slow to // stretch the stream out for watching the spinner and ctrl-c. package main import ( "encoding/json" "flag" "fmt" "io" "log" "net/http" "strings" "sync" "time" ) var ( addr = flag.String("addr", "127.0.0.1:8899", "listen address") slow = flag.Duration("slow", 0, "pause between streamed words, e.g. 150ms") cmd = flag.String("cmd", "git status --short && go build ./...", "command the model asks to run") spawn = flag.Bool("spawn", false, "ask for two subagents on the first turn instead of a shell command") work = flag.Duration("work", 4*time.Second, "how long a subagent takes to answer, with -spawn") ) // listedOnce keeps the model-listing note to one line however many turns run. var listedOnce sync.Once func main() { flag.Parse() http.HandleFunc("/v1/messages", handle) http.HandleFunc("/v1/models", models) log.Printf("mock Messages API on http://%s", *addr) log.Fatal(http.ListenAndServe(*addr, nil)) } // models answers GET /v1/models, which the model picker opens on and which the // harness reads at startup to tell the agent what it may put a subagent on. func models(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "data": []map[string]any{ {"id": "claude-fable-5-1", "display_name": "Claude Fable 5.1", "created_at": "2026-06-01T00:00:00Z", "max_input_tokens": 1000000}, {"id": "claude-opus-5", "display_name": "Claude Opus 5", "created_at": "2026-04-01T00:00:00Z", "max_input_tokens": 1000000}, {"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5", "created_at": "2026-02-01T00:00:00Z", "max_input_tokens": 1000000}, {"id": "claude-haiku-4-5", "display_name": "Claude Haiku 4.5", "created_at": "2025-10-01T00:00:00Z", "max_input_tokens": 200000}, }, "has_more": false, }) } func handle(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) // A tool_result in the history means the harness ran (or refused) our // command and fed the outcome back, so the next turn is the last one. final := strings.Contains(string(body), "tool_result") // Say once whether the agent was actually told what it may spawn on: the // listing is assembled at startup and nothing else makes it visible. if strings.Contains(string(body), "Models this endpoint serves") { listedOnce.Do(func() { log.Print("the agent's system prompt carries the model listing") }) } // A subagent announces itself in its system prompt. It gets one turn: a // report, after a pause long enough to see it working in the sidebar. if strings.Contains(string(body), "You are a subagent") { time.Sleep(*work) answer(w, "Found it: the policy is consulted in internal/agent/loop.go:281, "+ "through Agent.authorizedExecute.") return } w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) send(w, "message_start", map[string]any{ "type": "message_start", "message": map[string]any{ "id": "msg_mock", "model": "claude-opus-5", "usage": map[string]any{"input_tokens": 1200, "cache_read_input_tokens": 8000}, }, }) text := "Checking the repository state first." if final { text = "That is everything I can tell from here." } if *spawn && !final { text = "Two independent questions — I will farm them out." } send(w, "content_block_start", map[string]any{ "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, }) for _, word := range strings.SplitAfter(text, " ") { send(w, "content_block_delta", map[string]any{ "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": word}, }) time.Sleep(*slow) } send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) stop := "end_turn" switch { case final: case *spawn: stop = "tool_use" // One on a model that takes no effort level and one that does, so a // run exercises both halves of the answer. toolCall(w, 1, "toolu_1", "spawn_agent", map[string]string{ "preset": "explore", "task": "find where the approval policy is consulted", "model": "claude-haiku-4-5", "effort": "low", }) toolCall(w, 2, "toolu_2", "spawn_agent", map[string]string{ "preset": "plan", "task": "work out how subagent transcripts could survive a resume", "model": "claude-fable-5-1", "effort": "low", }) default: stop = "tool_use" toolCall(w, 1, "toolu_1", "bash", map[string]string{"command": *cmd}) } send(w, "message_delta", map[string]any{ "type": "message_delta", "delta": map[string]any{"stop_reason": stop}, "usage": map[string]any{"output_tokens": 95}, }) send(w, "message_stop", map[string]any{"type": "message_stop"}) } // toolCall streams one tool_use block, arguments and all. func toolCall(w http.ResponseWriter, index int, id, name string, args map[string]string) { send(w, "content_block_start", map[string]any{ "type": "content_block_start", "index": index, "content_block": map[string]any{"type": "tool_use", "id": id, "name": name}, }) raw, _ := json.Marshal(args) send(w, "content_block_delta", map[string]any{ "type": "content_block_delta", "index": index, "delta": map[string]any{"type": "input_json_delta", "partial_json": string(raw)}, }) send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) } // answer streams a whole turn that is nothing but text, which is what a // subagent's report is. func answer(w http.ResponseWriter, text string) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) send(w, "message_start", map[string]any{ "type": "message_start", "message": map[string]any{ "id": "msg_mock", "model": "claude-haiku-4-5", "usage": map[string]any{"input_tokens": 900}, }, }) send(w, "content_block_start", map[string]any{ "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, }) send(w, "content_block_delta", map[string]any{ "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": text}, }) send(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}) send(w, "message_delta", map[string]any{ "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn"}, "usage": map[string]any{"output_tokens": 60}, }) send(w, "message_stop", map[string]any{"type": "message_stop"}) } func send(w http.ResponseWriter, event string, data any) { b, _ := json.Marshal(data) fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b) w.(http.Flusher).Flush() }