v0.13.0 befd698

main.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close