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 if task := flag.Arg(0); task != "" {
76 runTurn(emit, task, 0)
77 }
78
79 turn := 1
80 for {
81 c, err := ln.Accept()
82 if err != nil {
83 return
84 }
85 br := bufio.NewReader(c)
86 for {
87 line, err := br.ReadString('\n')
88 if line != "" {
89 msg := trimNL(line)
90 command, parseErr := subagents.ParseCommand(msg)
91 if parseErr == nil {
92 switch command.Type {
93 case subagents.CommandAgentShutdown:
94 emit("agent_stopped", map[string]any{"reason": "shutdown"})
95 _ = c.Close()
96 return
97 case subagents.CommandTurnCancel:
98 emit("turn_end", map[string]any{"stop": "cancelled"})
99 case subagents.CommandTurnStart:
100 var payload subagents.TurnStartPayload
101 if command.DecodePayload(&payload) == nil {
102 runTurn(emit, payload.Prompt, turn)
103 turn++
104 }
105 }
106 }
107 }
108
109 if err != nil {
110 _ = c.Close()
111 break
112 }
113 }
114 }
115}
116
117// runTurn fakes one model round-trip: turn_start, an echoed
118// user_message, an assistant_message that echoes back, and a
119// turn_end. Enough event variety that applyEventToSink has
120// something to interpret.
121func runTurn(emit emitter, text string, step int) {
122 emit("turn_start", map[string]any{"step": step})
123 emit("user_message", map[string]any{
124 "content": []any{
125 map[string]any{"type": "text", "text": text},
126 },
127 })
128 emit("assistant_message", map[string]any{
129 "content": []any{
130 map[string]any{"type": "text", "text": "echo: " + text},
131 },
132 })
133 emit("turn_end", map[string]any{"stop": "end"})
134}
135
136type emitter = func(string, map[string]any)
137
138func newEmitter() emitter {
139 var mu sync.Mutex
140 enc := json.NewEncoder(os.Stdout)
141 versioned := os.Getenv("ZUT_STUB_PROTOCOL") == "1"
142 return func(typ string, data map[string]any) {
143 mu.Lock()
144 defer mu.Unlock()
145 if data == nil {
146 data = map[string]any{}
147 }
148 if versioned {
149 envelope := subagents.NewEventEnvelope(typ, "stub-agent", "", data)
150 _ = enc.Encode(envelope)
151 return
152 }
153 data["type"] = typ
154 data["time"] = time.Now().Format(time.RFC3339Nano)
155 _ = enc.Encode(data)
156 }
157}
158
159func trimNL(s string) string {
160 for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
161 s = s[:len(s)-1]
162 }
163 return s
164}