// 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 &
// ./codey -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.
//
// A compaction request is answered separately, as plain JSON rather than SSE,
// because the summarization call does not stream. Reported usage grows with the
// size of the request, so the gauge climbs as you talk; pair it with a small
// -context-window to reach a threshold in a handful of turns:
//
// ./codey -base-url http://127.0.0.1:8899 -api-key mock -context-window 30000
//
// 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)
// Compaction is the one request Codey does not stream, so it cannot
// be answered with the SSE script below.
if strings.Contains(string(body), "Summarize the conversation above") {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": "msg_mock_summary", "type": "message", "role": "assistant",
"model": "claude-opus-5", "stop_reason": "end_turn",
"content": []map[string]any{{
"type": "text",
"text": "Ran git status and go build; the tree is clean and it compiles.",
}},
"usage": map[string]any{"input_tokens": 900, "output_tokens": 40},
})
return
}
// A tool_result in the history means Codey 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)
// Usage grows with the request, so the context gauge climbs as a real
// conversation would and the compaction thresholds can be reached by
// talking rather than only by shrinking -context-window.
read, fresh := estimateTokens(len(body))
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": fresh, "cache_read_input_tokens": read},
},
})
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"})
}
// estimateTokens turns a request body size into a plausible cached/fresh split
// at roughly four bytes per token. The exact numbers do not matter; that they
// rise with the conversation does.
func estimateTokens(bodyLen int) (cached, fresh int) {
total := 1200 + bodyLen/4
cached = total * 4 / 5
return cached, total - cached
}
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()
}