1// stubchild is a minimal subagent-worker stand-in used by the runner
2// end-to-end test. It speaks the daemon protocol the real zut
3// binary will implement next:
4//
5// - parses --subagent-worker <path>, --session <path>, --cwd <path>,
6// and an optional positional task,
7// - opens a unix-socket listener at the inbox path so the
8// supervisor's Inbox can dial through,
9// - emits well-formed JSONL events on stdout that the supervisor
10// mirrors into the durable event log,
11// - reads one line per supervisor message and echoes it back as
12// a "user_message" event followed by a fake "assistant_message"
13// so the runner sees the dialogue happen.
14//
15// The runner test compiles this binary into a tempdir, points
16// subagent's execRunner at it via Command, and asserts the events
17// flow through correctly without needing the full zut model
18// machinery.
19package main
20
21import (
22 "bufio"
23 "encoding/json"
24 "flag"
25 "fmt"
26 "net"
27 "os"
28 "sync"
29 "time"
30
31 "github.com/bnema/zut/packages/agent/subagents"
32)
33
34func main() {
35 var (
36 inboxPath string
37 sessionPath string
38 cwd string
39 webSearchPolicy string
40 )
41 flag.StringVar(&inboxPath, "subagent-worker", "", "inbox socket path")
42 flag.StringVar(&sessionPath, "session", "", "session file path")
43 flag.StringVar(&cwd, "cwd", "", "working directory")
44 // Accepted only to keep the fixture aligned with the real child argv;
45 // the stub does not construct a tool registry.
46 flag.StringVar(&webSearchPolicy, "web-search-policy", "", "web search capability policy")
47 flag.Parse()
48
49 if inboxPath == "" {
50 fmt.Fprintln(os.Stderr, "stubchild: --subagent-worker required")
51 os.Exit(2)
52 }
53
54 emit := newEmitter()
55
56 // Open the inbox listener BEFORE doing any work so the supervisor
57 // can dial through as soon as it sees the agent_ready event. If we
58 // processed the initial task first, the parent's send call
59 // would race the stub's net.Listen and trip ErrNotReady.
60 ln, err := net.Listen("unix", inboxPath)
61 if err != nil {
62 emit("error", map[string]any{"message": err.Error()})
63 os.Exit(1)
64 }
65 defer os.Remove(inboxPath)
66
67 emit("agent_ready", map[string]any{
68 "inbox": inboxPath,
69 "session": sessionPath,
70 "cwd": cwd,
71 })
72
73 // Initial task lives in the positional. Process it as the first
74 // user turn so the supervisor's "initial task" path is exercised.
75 initialTurnOpen := false
76 if task := flag.Arg(0); task != "" {
77 if os.Getenv("ZUT_STUB_BLOCK_INITIAL") == "1" {
78 initialTurnOpen = true
79 emit("turn_start", map[string]any{"step": 0})
80 emit("user_message", map[string]any{
81 "content": []any{
82 map[string]any{"type": "text", "text": task},
83 },
84 })
85 emit("message.delta", map[string]any{"delta": "partial answer"})
86 } else {
87 runTurn(emit, task, 0)
88 }
89 }
90
91 turn := 1
92 for {
93 c, err := ln.Accept()
94 if err != nil {
95 return
96 }
97 br := bufio.NewReader(c)
98 for {
99 line, err := br.ReadString('\n')
100 if line != "" {
101 msg := trimNL(line)
102 command, parseErr := subagents.ParseCommand(msg)
103 if parseErr == nil {
104 switch command.Type {
105 case subagents.CommandAgentShutdown:
106 if initialTurnOpen {
107 emit("turn_end", map[string]any{"stop": "cancelled"})
108 initialTurnOpen = false
109 }
110 emit("agent_stopped", map[string]any{"reason": "shutdown"})
111 _ = c.Close()
112 return
113 case subagents.CommandTurnCancel:
114 if initialTurnOpen {
115 initialTurnOpen = false
116 }
117 emit("turn_end", map[string]any{"stop": "cancelled"})
118 case subagents.CommandTurnStart:
119 var payload subagents.TurnStartPayload
120 if command.DecodePayload(&payload) == nil {
121 runTurn(emit, payload.Prompt, turn)
122 turn++
123 }
124 }
125 }
126 }
127
128 if err != nil {
129 _ = c.Close()
130 break
131 }
132 }
133 }
134}
135
136// runTurn fakes one model round-trip: turn_start, an echoed
137// user_message, an assistant_message that echoes back, and a
138// turn_end. Enough event variety that applyEventToSink has
139// something to interpret.
140func runTurn(emit emitter, text string, step int) {
141 emit("turn_start", map[string]any{"step": step})
142 emit("user_message", map[string]any{
143 "content": []any{
144 map[string]any{"type": "text", "text": text},
145 },
146 })
147 emit("assistant_message", map[string]any{
148 "content": []any{
149 map[string]any{"type": "text", "text": "echo: " + text},
150 },
151 })
152 emit("turn_end", map[string]any{"step": step, "stop": "end"})
153}
154
155type emitter = func(string, map[string]any)
156
157func newEmitter() emitter {
158 var mu sync.Mutex
159 enc := json.NewEncoder(os.Stdout)
160 versioned := os.Getenv("ZUT_STUB_PROTOCOL") == "1"
161 return func(typ string, data map[string]any) {
162 mu.Lock()
163 defer mu.Unlock()
164 if data == nil {
165 data = map[string]any{}
166 }
167 if versioned {
168 envelope := subagents.NewEventEnvelope(typ, "stub-agent", "", data)
169 _ = enc.Encode(envelope)
170 return
171 }
172 data["type"] = typ
173 data["time"] = time.Now().Format(time.RFC3339Nano)
174 _ = enc.Encode(data)
175 }
176}
177
178func trimNL(s string) string {
179 for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
180 s = s[:len(s)-1]
181 }
182 return s
183}