1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
//! Multi-step exchange-loop tests. Drives the loop through
//! [`StubToolExecutor`] to assert §2.5 pairing, per-step on-disk shape,
//! and the `Finish{!ToolUse}` termination rule over brazen `v=1` events.
use super::fixtures::*;
use crate::prompt::run;
use brazen::FinishReason;
use serde_json::{Value, json};
fn tool_use_stream(id: &str, name: &str, cmd_key: &str, cmd_val: &str) -> Vec<u8> {
stream_of(
FinishReason::ToolUse,
&[Block::ToolUse {
id,
name,
input: json!({ cmd_key: cmd_val }),
}],
)
}
pub(super) fn final_stream() -> Vec<u8> {
stream_of(FinishReason::Stop, &[Block::Text("done")])
}
fn last_line_type(bytes: &[u8]) -> String {
let lines = parse_jsonl(bytes);
lines.last().unwrap()["type"].as_str().unwrap().to_string()
}
fn finish_reason(bytes: &[u8]) -> String {
parse_jsonl(bytes)
.into_iter()
.find(|e| e["type"] == "finish")
.unwrap()["reason"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn loop_runs_two_steps_when_first_completion_is_tool_use() {
let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
let harness = scaffold_harness_root();
let r1 = tool_use_stream("toolu_01", "bash", "cmd", "ls");
let r2 = final_stream();
// Two model calls, and a version guard before each: config resolves
// at every step boundary (bl-e580), so step 2's load-time guard
// (§4.4) runs again with it.
let adapter = StubAdapter::scripted([
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r1),
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r2),
]);
let git = StubGit::ok();
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (sleeper, tool_executor) = (
StubSleeper::default(),
StubToolExecutor::with_reply("bash", "files: a b"),
);
let branch = run(
repo.path(),
"list files",
None,
None,
None,
crate::prompt::PinnedDocs::none(),
None,
&valid_deps(
&adapter,
&sleeper,
&git,
&clock,
&id,
&tool_executor,
harness.path(),
),
)
.unwrap();
assert_eq!(branch, "ct-1-deadbeef");
let worktree = worktree_path(repo.path());
// Executor saw one call in step 1 with the emitted tool_use.
let tool_calls = tool_executor.invocations.borrow().clone();
assert_eq!(tool_calls.len(), 1);
let (step_dir, tid, name, input) = &tool_calls[0];
assert_eq!(step_dir, &repo.path().join("steps/ct-1-deadbeef/001"));
assert_eq!(
(tid.as_str(), name.as_str(), &input["cmd"]),
("toolu_01", "bash", &json!("ls"))
);
assert!(!worktree.join("steps").exists());
let step1_dir = repo.path().join("steps/ct-1-deadbeef/001");
let step2_dir = repo.path().join("steps/ct-1-deadbeef/002");
// Step 1 request: one user message, the front-door delivery of the
// initial message (§2.11) — its deposit frontmatter travels with the
// body and is model-visible (`deposited_at` is the first clock tick).
let req1: Value =
serde_json::from_slice(&std::fs::read(step1_dir.join("request.json")).unwrap()).unwrap();
assert_eq!(req1["messages"].as_array().unwrap().len(), 1);
assert_eq!(
req1["messages"][0]["content"][0]["text"],
"---\nfrom: user\ndeposited_at: iso-1\n---\nlist files"
);
// Step 2 request: §2.5 pairing — assistant tool_use + tool-side
// tool_result (canonical `Role::Tool`, whose content is a canonical
// `Content` array; the provider protocol projects the role, §2.3).
let req2: Value =
serde_json::from_slice(&std::fs::read(step2_dir.join("request.json")).unwrap()).unwrap();
let msgs = req2["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"][0]["id"], "toolu_01");
assert_eq!(msgs[2]["role"], "tool");
assert_eq!(msgs[2]["content"][0]["tool_use_id"], "toolu_01");
assert_eq!(msgs[2]["content"][0]["content"][0]["text"], "files: a b");
assert!(worktree.join("goal.md").exists());
// Step 2's response terminal is brazen's `end`; the finish reason
// is `stop`.
let resp2 = std::fs::read(step2_dir.join("response.json")).unwrap();
assert_eq!(last_line_type(&resp2), "end");
assert_eq!(finish_reason(&resp2), "stop");
// Git op log: 11 (the start's preamble — the fork-point lineage query
// and the settle-the-name living-names scan, §2.3; then control
// resolution, §2.2 — the `config/*` head enumeration and merge-base,
// the followed-tip enumeration and its containment merge-base
// (§2.2, bl-403b),
// plus five `show` reads, `version` first for the §10 schema-version
// guard, manifest.yaml before the soul, §5.2)
// + 12 (step 1 setup: spawn, control rm, the descriptor
// derivation's five ops — four `cat-file -e` existence reads against
// the governing config commit and one `checkout`, §3.3 — the facts
// cut's own existence read and `checkout` (§5.5), the
// settled-name stage (§2.3), add, commit) + 1 (step-1 drain
// stray-probe) + 2 (user-message delivery add+commit) + 1 (step 1
// rev-parse) + 2 (step-1 model-output transcript entry add+commit)
// + 1 (the tool window's unconditional hold-mark probe, §3.3 *Tool
// control*) + 2 (the tool transcript entry add+commit) + 12 (the step-2
// boundary's own config resolution — bl-e580: the config question is
// asked again here, because a `litany config` edit or a `litany
// workflow` mark that landed during step 1 governs step 2, so the
// lineage enumeration and containment merge-base run again (4), the
// role is derived from the branch this time rather than defaulted for
// a fork (1), and `version`/`providers.yaml`, the workflow-mark
// probe, the mark's own §10 guard, `workflow.yaml`, `manifest.yaml`
// and the soul are read again (7 — `StubGit` answers every
// `rev-parse` with a sha, so the mark probe reads as a standing mark
// and pays a second `version` read a live unmarked agent does not))
// + 1 (step-2 drain stray-probe) + 6 (the step-2 descriptor refresh,
// bl-37cd: the cut is re-derived against the commit that just
// resolved, so it costs a `cat-file -e` schema probe and a skill
// probe per granted tool (4), one `checkout` of what the grant
// covers, and one `status` asking whether any of it moved — which
// here it did not, so no commit follows) + 1 (step 2 rev-parse) + 2
// (step-2 model-output entry add+commit) = 54. The terminal result deposit
// adds none: the
// last prompter is `user` (the on-ramp message), so the reply
// addresses no inbox and neither the branch-tip read nor the returned
// mark runs (§2.6). Merge-back is gone. The version guard runs no git.
let runs = git.runs.borrow();
assert_eq!(runs.len(), 54);
assert_eq!(runs[20].1, vec!["add", "name"]);
assert_eq!(runs[21].1, vec!["add", "goal.md", "soul.md"]);
assert!(runs[22].1[2].contains("step 001: dispatch"));
// Step-1 drain (§2.11): stray-probe, then the initial user message (001).
assert_eq!(runs[23].1, vec!["status", "--porcelain", "--", "messages"]);
assert_eq!(runs[24].1, vec!["add", "messages/001-user.md"]);
assert!(runs[25].1[2].contains("transcript 001: user"));
assert_eq!(runs[26].1, vec!["rev-parse", "HEAD"]);
// Step 1's transcript: model-output entry (002), then the tool result
// entry (003) — the §2.3 ordering (model output before its tool
// results). Counters are max-present-plus-one from the messages/
// listing, so they never collide with the step number. The model
// output's origin token is the authoring model id (§2.3).
assert_eq!(runs[27].1, vec!["add", "messages/002-claude-sonnet-5.json"]);
assert!(runs[28].1[2].contains("transcript 002: claude-sonnet-5"));
// The tool window opens with the unconditional hold-mark probe
// (§3.3 *Tool control* — the mark, not the config, asserts a park).
assert_eq!(runs[29].1[..2], ["cat-file", "blob"]);
// A tool commit stages the whole worktree (`git add -A`, §2.3) so any
// worktree side effect the tool produced lands with its result entry.
assert_eq!(runs[30].1, vec!["add", "-A"]);
assert!(runs[31].1[2].contains("transcript 003: tool"));
// Step 2 opens by resolving config at its own boundary (bl-e580):
// the governing-lineage enumeration, this branch's role, and the
// control reads — the same `resolve_worker` a `litany advance` hop
// makes, against this agent's own ref rather than the fork point.
assert_eq!(runs[32].1[..2], ["for-each-ref", "--format=%(refname)"]);
assert_eq!(runs[33].1[..2], ["merge-base", "agents/ct-1-deadbeef"]);
assert_eq!(runs[36].1[0], "log");
assert_eq!(
runs[43].1[1],
"cafecafecafecafecafecafecafecafecafecafe:souls/worker.md"
);
// Then its boundary drain (empty inbox → stray-probe only), the
// branch-tip capture (advanced by step 1's transcript commits), and
// its own model-output entry (004).
assert_eq!(runs[44].1, vec!["status", "--porcelain", "--", "messages"]);
// Then the boundary descriptor refresh (bl-37cd): the cut re-derived
// against the commit step 2 just resolved, ending in the one
// question that decides whether it commits — did anything move?
assert_eq!(runs[49].1[0], "checkout");
assert_eq!(
runs[50].1,
vec!["status", "--porcelain", "--", "descriptions"]
);
assert_eq!(runs[51].1, vec!["rev-parse", "HEAD"]);
assert_eq!(runs[52].1, vec!["add", "messages/004-claude-sonnet-5.json"]);
assert!(runs[53].1[2].contains("transcript 004: claude-sonnet-5"));
// The terminal result deposit is one structural no-op — the operator
// prompted this agent, so its reply addresses no inbox (§2.6) — so
// the entry commit is the last git op and no merge-back follows.
// The tool entry on disk is the canonical tool_result block.
let tool_entry = worktree.join("messages/003-tool.json");
let blocks: Value = serde_json::from_slice(&std::fs::read(&tool_entry).unwrap()).unwrap();
assert_eq!(blocks[0]["type"], "tool_result");
assert_eq!(blocks[0]["tool_use_id"], "toolu_01");
assert_eq!(blocks[0]["content"][0]["text"], "files: a b");
}
#[test]
fn loop_runs_three_steps_when_two_completions_in_a_row_are_tool_use() {
let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
let harness = scaffold_harness_root();
let r1 = tool_use_stream("toolu_01", "bash", "cmd", "ls");
let r2 = tool_use_stream("toolu_02", "bash", "cmd", "pwd");
let r3 = final_stream();
// One version guard per step boundary (bl-e580, above).
let adapter = StubAdapter::scripted([
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r1),
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r2),
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r3),
]);
let git = StubGit::ok();
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (sleeper, tool_executor) = (StubSleeper::default(), StubToolExecutor::ok());
run(
repo.path(),
"go",
None,
None,
None,
crate::prompt::PinnedDocs::none(),
None,
&valid_deps(
&adapter,
&sleeper,
&git,
&clock,
&id,
&tool_executor,
harness.path(),
),
)
.unwrap();
let step3_resp = repo.path().join("steps/ct-1-deadbeef/003/response.json");
assert!(step3_resp.exists());
assert!(!repo.path().join("steps/ct-1-deadbeef/004").exists());
let invocations = tool_executor.invocations.borrow().clone();
assert_eq!(invocations.len(), 2);
assert!(invocations[0].0.ends_with("steps/ct-1-deadbeef/001"));
assert!(invocations[1].0.ends_with("steps/ct-1-deadbeef/002"));
assert_eq!(invocations[0].1, "toolu_01");
assert_eq!(invocations[1].1, "toolu_02");
}
// Loop-termination cases (tool-executor failure) live in
// [`super::multi_step_terminal`].
mod emission_order;