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 )
40 flag.StringVar(&inboxPath, "subagent-worker", "", "inbox socket path")
41 flag.StringVar(&sessionPath, "session", "", "session file path")
42 flag.StringVar(&cwd, "cwd", "", "working directory")
43 flag.Parse()
44
45 if inboxPath == "" {
46 fmt.Fprintln(os.Stderr, "stubchild: --subagent-worker required")
47 os.Exit(2)
48 }
49
50 emit := newEmitter()
51
52 // Open the inbox listener BEFORE doing any work so the supervisor
53 // can dial through as soon as it sees the agent_ready event. If we
54 // processed the initial task first, the parent's send call
55 // would race the stub's net.Listen and trip ErrNotReady.
56 ln, err := net.Listen("unix", inboxPath)
57 if err != nil {
58 emit("error", map[string]any{"message": err.Error()})
59 os.Exit(1)
60 }
61 defer os.Remove(inboxPath)
62
63 emit("agent_ready", map[string]any{
64 "inbox": inboxPath,
65 "session": sessionPath,
66 "cwd": cwd,
67 })
68
69 // Initial task lives in the positional. Process it as the first
70 // user turn so the supervisor's "initial task" path is exercised.
71 if task := flag.Arg(0); task != "" {
72 runTurn(emit, task, 0)
73 }
74
75 turn := 1
76 for {
77 c, err := ln.Accept()
78 if err != nil {
79 return
80 }
81 br := bufio.NewReader(c)
82 for {
83 line, err := br.ReadString('\n')
84 if line != "" {
85 msg := trimNL(line)
86 command, parseErr := subagents.ParseCommand(msg)
87 if parseErr == nil {
88 switch command.Type {
89 case subagents.CommandAgentShutdown:
90 emit("agent_stopped", map[string]any{"reason": "shutdown"})
91 _ = c.Close()
92 return
93 case subagents.CommandTurnCancel:
94 emit("turn_end", map[string]any{"stop": "cancelled"})
95 case subagents.CommandTurnStart:
96 var payload subagents.TurnStartPayload
97 if command.DecodePayload(&payload) == nil {
98 runTurn(emit, payload.Prompt, turn)
99 turn++
100 }
101 }
102 }
103 }
104
105 if err != nil {
106 _ = c.Close()
107 break
108 }
109 }
110 }
111}
112
113// runTurn fakes one model round-trip: turn_start, an echoed
114// user_message, an assistant_message that echoes back, and a
115// turn_end. Enough event variety that applyEventToSink has
116// something to interpret.
117func runTurn(emit emitter, text string, step int) {
118 emit("turn_start", map[string]any{"step": step})
119 emit("user_message", map[string]any{
120 "content": []any{
121 map[string]any{"type": "text", "text": text},
122 },
123 })
124 emit("assistant_message", map[string]any{
125 "content": []any{
126 map[string]any{"type": "text", "text": "echo: " + text},
127 },
128 })
129 emit("turn_end", map[string]any{"stop": "end"})
130}
131
132type emitter = func(string, map[string]any)
133
134func newEmitter() emitter {
135 var mu sync.Mutex
136 enc := json.NewEncoder(os.Stdout)
137 versioned := os.Getenv("ZUT_STUB_PROTOCOL") == "1"
138 return func(typ string, data map[string]any) {
139 mu.Lock()
140 defer mu.Unlock()
141 if data == nil {
142 data = map[string]any{}
143 }
144 if versioned {
145 envelope := subagents.NewEventEnvelope(typ, "stub-agent", "", data)
146 _ = enc.Encode(envelope)
147 return
148 }
149 data["type"] = typ
150 data["time"] = time.Now().Format(time.RFC3339Nano)
151 _ = enc.Encode(data)
152 }
153}
154
155func trimNL(s string) string {
156 for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
157 s = s[:len(s)-1]
158 }
159 return s
160}