Commit Diff


commit - ffccea59b4d4877ec18fbcbac7f41c955416b635
commit + f82b590e4d17bcf395c594962292546a67ee7c8a
blob - 09b03732599422322cc16ad000349f3775b5efef
blob + fc82d725238f321e7772e907d668f823f1a322ea
--- regress/Makefile
+++ regress/Makefile
@@ -1,5 +1,5 @@
 SUBDIR=	agentcfg anthropic buf conf deploy diff editor generation http imsgev journal json log markdown \
-	model_window openai output print sandbox skills sse term tools turn ui_config \
-	turn_txn web xmalloc
+	model_window openai output print sandbox skills sse term tools turn \
+	turn_mechanics turn_txn ui_config web xmalloc
 
 .include <bsd.subdir.mk>
blob - /dev/null
blob + 12f905a99af298d8f5ca9a9f06b840832bde9dee (mode 644)
--- /dev/null
+++ regress/turn_mechanics/Makefile
@@ -0,0 +1,5 @@
+PROG=	turn_mechanics_test
+SRCS=	turn_mechanics_test.c turn_mechanics.c generation.c msg.c json.c \
+	buf.c log.c xmalloc.c
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 4f26ab1c8e976dcba4f063e969217872a3924c8e (mode 644)
--- /dev/null
+++ regress/turn_mechanics/turn_mechanics_test.c
@@ -0,0 +1,1915 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/queue.h>
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "msg.h"
+#include "turn_mechanics.h"
+
+#include "regress.h"
+
+/*
+ * Mutation calibration, all restored: skipping missing-result validation
+ * produced 135 checks/2 failures; changing the batch-cap >= to > produced
+ * 135/8; omitting/doubling Generation data charge produced 135/39 and 135/41;
+ * omitting the post-Generation/post-Tool checkpoint produced 135/15 and
+ * 135/7; omitting nested-usage settlement produced 135/5; changing the usage
+ * overflow guard from > to >= produced 135/4.
+ */
+
+#define SCRIPT_GENERATIONS_MAX	16
+#define SCRIPT_EVENTS_MAX	16
+#define SCRIPT_TOOLS_MAX	16
+#define SCRIPT_RESULTS_MAX	(GENERATION_CALL_MAX + 1)
+
+struct scripted_event {
+	enum generation_kind	 kind;
+	int			 index;
+	const char		*id;
+	const char		*name;
+	const void		*data;
+	size_t			 len;
+	struct generation_usage usage;
+};
+
+struct scripted_generation {
+	enum turn_tools_mode	 tools;
+	struct scripted_event	 events[SCRIPT_EVENTS_MAX];
+	size_t			 nevents;
+	enum turn_io		 io;
+	const char		*error;
+	int			 sent;
+	int			 sent_twice;
+	int			 fill_error;
+};
+
+struct scripted_result {
+	size_t		 ordinal;
+	const char	*id;
+	const void	*data;
+	size_t		 len;
+	int		 is_error;
+};
+
+struct scripted_tool {
+	struct scripted_result results[SCRIPT_RESULTS_MAX];
+	size_t			 nresults;
+	const void		*text;
+	size_t			 text_len;
+	struct generation_usage usage;
+	int			 has_usage;
+	enum turn_io		 io;
+	const char		*error;
+	int			 fill_error;
+};
+
+struct observed_assistant {
+	const struct msg		*message;
+	enum generation_outcome outcome;
+	char			 stop_reason[GENERATION_STOP_MAX];
+	struct generation_span	 synthesized_text;
+	size_t			 tool_calls;
+};
+
+struct observed_batch {
+	const struct msg	*assistant;
+	size_t		 count;
+	char		 id[GENERATION_CALL_MAX][GENERATION_TOOL_ID_MAX];
+	char		 name[GENERATION_CALL_MAX][GENERATION_TOOL_NAME_MAX];
+	char		 input[GENERATION_CALL_MAX][128];
+	size_t		 input_len[GENERATION_CALL_MAX];
+};
+
+struct fixture {
+	struct msglist		 accepted;
+	struct scripted_generation generations[SCRIPT_GENERATIONS_MAX];
+	size_t			 ngenerations;
+	size_t			 generation_next;
+	struct scripted_tool	 tools[SCRIPT_TOOLS_MAX];
+	size_t			 ntools;
+	size_t			 tool_next;
+	struct observed_assistant assistants[SCRIPT_GENERATIONS_MAX];
+	size_t			 nassistants;
+	struct observed_batch	 batches[SCRIPT_TOOLS_MAX];
+	size_t			 nbatches;
+	size_t			 projection_count[SCRIPT_GENERATIONS_MAX];
+	enum turn_tools_mode	 request_tools[SCRIPT_GENERATIONS_MAX];
+	int			 set_result_rc[SCRIPT_RESULTS_MAX];
+	size_t			 nset_result_rc;
+	int			 add_text_rc;
+	int			 add_usage_rc;
+	int			 accepts;
+	int			 projections;
+	int			 sent_callbacks;
+	int			 marking_sent;
+	int			 checkpoints;
+	int			 interrupt_checkpoint;
+	int			 bad;
+};
+
+static void
+copy_string(char *dst, size_t dstsz, const char *src)
+{
+	size_t len;
+
+	if (dstsz == 0)
+		return;
+	len = strlen(src);
+	if (len >= dstsz)
+		len = dstsz - 1;
+	if (len > 0)
+		memcpy(dst, src, len);
+	dst[len] = '\0';
+}
+
+static struct msg *
+text_message(enum msg_role role, const char *text)
+{
+	struct msg *message;
+
+	message = msg_new(role);
+	msg_add_text(message, text, strlen(text));
+	return (message);
+}
+
+static size_t
+message_count(const struct msglist *messages)
+{
+	const struct msg	*message;
+	size_t		 n = 0;
+
+	TAILQ_FOREACH(message, messages, entry)
+		n++;
+	return (n);
+}
+
+static struct msg *
+message_at(const struct msglist *messages, size_t want)
+{
+	struct msg	*message;
+	size_t		 n = 0;
+
+	TAILQ_FOREACH(message, messages, entry) {
+		if (n++ == want)
+			return (message);
+	}
+	return (NULL);
+}
+
+static struct scripted_generation *
+fixture_generation(struct fixture *f, enum turn_tools_mode tools)
+{
+	struct scripted_generation *script;
+
+	if (f->ngenerations >= SCRIPT_GENERATIONS_MAX)
+		abort();
+	script = &f->generations[f->ngenerations++];
+	memset(script, 0, sizeof(*script));
+	script->tools = tools;
+	script->io = TURN_IO_OK;
+	script->sent = 1;
+	return (script);
+}
+
+static struct scripted_event *
+script_event(struct scripted_generation *script, enum generation_kind kind)
+{
+	struct scripted_event *event;
+
+	if (script->nevents >= SCRIPT_EVENTS_MAX)
+		abort();
+	event = &script->events[script->nevents++];
+	memset(event, 0, sizeof(*event));
+	event->kind = kind;
+	return (event);
+}
+
+static void
+script_bytes(struct scripted_generation *script, enum generation_kind kind,
+    const void *data, size_t len)
+{
+	struct scripted_event *event;
+
+	event = script_event(script, kind);
+	event->data = data;
+	event->len = len;
+}
+
+static void
+script_text(struct scripted_generation *script, const char *text)
+{
+	script_bytes(script, GENERATION_TEXT, text, strlen(text));
+}
+
+static void
+script_done(struct scripted_generation *script, const char *reason)
+{
+	script_bytes(script, GENERATION_DONE, reason, strlen(reason));
+}
+
+static void
+script_provider_error(struct scripted_generation *script, const char *error)
+{
+	script_bytes(script, GENERATION_ERROR, error, strlen(error));
+}
+
+static void
+script_usage(struct scripted_generation *script,
+    const struct generation_usage *usage)
+{
+	struct scripted_event *event;
+
+	event = script_event(script, GENERATION_USAGE);
+	event->usage = *usage;
+}
+
+static void
+script_tool_begin(struct scripted_generation *script, int index,
+    const char *id, const char *name)
+{
+	struct scripted_event *event;
+
+	event = script_event(script, GENERATION_TOOL_BEGIN);
+	event->index = index;
+	event->id = id;
+	event->name = name;
+}
+
+static void
+script_tool_input(struct scripted_generation *script, int index,
+    const void *data, size_t len)
+{
+	struct scripted_event *event;
+
+	event = script_event(script, GENERATION_TOOL_INPUT);
+	event->index = index;
+	event->data = data;
+	event->len = len;
+}
+
+static struct scripted_tool *
+fixture_tool(struct fixture *f)
+{
+	struct scripted_tool *tool;
+
+	if (f->ntools >= SCRIPT_TOOLS_MAX)
+		abort();
+	tool = &f->tools[f->ntools++];
+	memset(tool, 0, sizeof(*tool));
+	tool->io = TURN_IO_OK;
+	return (tool);
+}
+
+static void
+script_result(struct scripted_tool *tool, size_t ordinal, const char *id,
+    const void *data, size_t len, int is_error)
+{
+	struct scripted_result *result;
+
+	if (tool->nresults >= SCRIPT_RESULTS_MAX)
+		abort();
+	result = &tool->results[tool->nresults++];
+	memset(result, 0, sizeof(*result));
+	result->ordinal = ordinal;
+	result->id = id;
+	result->data = data;
+	result->len = len;
+	result->is_error = is_error;
+}
+
+static void
+fixture_projection(void *arg, struct turn_projection *projection)
+{
+	struct fixture *f = arg;
+
+	f->projections++;
+	memset(projection, 0, sizeof(*projection));
+	projection->messages = &f->accepted;
+	projection->message_count = message_count(&f->accepted);
+	projection->cache_tail_msg = -1;
+	projection->receipt = projection->message_count + 100;
+}
+
+static void
+fixture_projection_sent(void *arg, const struct turn_projection *projection)
+{
+	struct fixture *f = arg;
+
+	if (!f->marking_sent || projection->receipt !=
+	    projection->message_count + 100)
+		f->bad = 1;
+	f->sent_callbacks++;
+}
+
+static void
+fixture_accept(void *arg, struct msg *message)
+{
+	struct fixture *f = arg;
+
+	TAILQ_INSERT_TAIL(&f->accepted, message, entry);
+	f->accepts++;
+}
+
+static void
+fixture_assistant_accepted(void *arg, const struct turn_assistant_event *event)
+{
+	struct fixture		*f = arg;
+	struct observed_assistant *observed;
+
+	if (f->nassistants >= SCRIPT_GENERATIONS_MAX ||
+	    TAILQ_LAST(&f->accepted, msglist) != event->message) {
+		f->bad = 1;
+		return;
+	}
+	observed = &f->assistants[f->nassistants++];
+	memset(observed, 0, sizeof(*observed));
+	observed->message = event->message;
+	observed->outcome = event->outcome;
+	copy_string(observed->stop_reason, sizeof(observed->stop_reason),
+	    event->stop_reason);
+	observed->synthesized_text = event->synthesized_text;
+	observed->tool_calls = event->tool_calls;
+}
+
+static size_t
+encode_begin(u_char *payload, int index, const char *id, const char *name)
+{
+	size_t idlen = strlen(id), namelen = strlen(name);
+
+	memcpy(payload, &index, sizeof(index));
+	memcpy(payload + sizeof(index), id, idlen + 1);
+	memcpy(payload + sizeof(index) + idlen + 1, name, namelen);
+	return (sizeof(index) + idlen + 1 + namelen);
+}
+
+static enum generation_feed_status
+fixture_feed(struct turn_generation_io *io, const struct scripted_event *event)
+{
+	u_char payload[sizeof(int) + GENERATION_TOOL_ID_MAX + 128];
+	size_t len;
+
+	switch (event->kind) {
+	case GENERATION_TOOL_BEGIN:
+		len = encode_begin(payload, event->index, event->id, event->name);
+		return (turn_generation_feed(io, event->kind, payload, len, NULL));
+	case GENERATION_TOOL_INPUT:
+		if (event->len > sizeof(payload) - sizeof(event->index))
+			abort();
+		memcpy(payload, &event->index, sizeof(event->index));
+		if (event->len > 0)
+			memcpy(payload + sizeof(event->index), event->data,
+			    event->len);
+		return (turn_generation_feed(io, event->kind, payload,
+		    sizeof(event->index) + event->len, NULL));
+	case GENERATION_USAGE:
+		return (turn_generation_feed(io, event->kind, &event->usage,
+		    sizeof(event->usage), NULL));
+	default:
+		return (turn_generation_feed(io, event->kind, event->data,
+		    event->len, NULL));
+	}
+}
+
+static enum turn_io
+fixture_generate(void *arg, const struct turn_generation_request *request,
+    struct turn_generation_io *io, char *error, size_t errorsz)
+{
+	struct fixture		*f = arg;
+	struct scripted_generation *script;
+	enum generation_feed_status status = GENERATION_MORE;
+	size_t			 i, slot;
+
+	if (f->generation_next >= f->ngenerations) {
+		f->bad = 1;
+		return (TURN_IO_ERROR);
+	}
+	slot = f->generation_next;
+	script = &f->generations[f->generation_next++];
+	f->projection_count[slot] = request->projection.message_count;
+	f->request_tools[slot] = request->tools;
+	if (request->projection.messages != &f->accepted ||
+	    request->tools != script->tools)
+		f->bad = 1;
+	if (script->sent) {
+		f->marking_sent = 1;
+		if (turn_generation_sent(io) == -1)
+			f->bad = 1;
+		if (script->sent_twice && turn_generation_sent(io) != -1)
+			f->bad = 1;
+		f->marking_sent = 0;
+	}
+	for (i = 0; i < script->nevents; i++) {
+		status = fixture_feed(io, &script->events[i]);
+		if (status == GENERATION_PROTOCOL)
+			break;
+	}
+	if (script->error != NULL && errorsz > 0)
+		(void)snprintf(error, errorsz, "%s", script->error);
+	if (script->fill_error && errorsz > 0)
+		memset(error, 'G', errorsz);
+	return (script->io);
+}
+
+static enum turn_checkpoint
+fixture_checkpoint(void *arg)
+{
+	struct fixture *f = arg;
+
+	f->checkpoints++;
+	if (f->interrupt_checkpoint != 0 &&
+	    f->checkpoints == f->interrupt_checkpoint)
+		return (TURN_CHECKPOINT_INTERRUPT);
+	return (TURN_CHECKPOINT_CONTINUE);
+}
+
+static void
+observe_batch(struct fixture *f, const struct turn_tool_batch *batch)
+{
+	struct observed_batch *observed;
+	size_t i;
+
+	if (f->nbatches >= SCRIPT_TOOLS_MAX ||
+	    TAILQ_LAST(&f->accepted, msglist) != batch->assistant) {
+		f->bad = 1;
+		return;
+	}
+	observed = &f->batches[f->nbatches++];
+	memset(observed, 0, sizeof(*observed));
+	observed->assistant = batch->assistant;
+	observed->count = batch->count;
+	for (i = 0; i < batch->count && i < GENERATION_CALL_MAX; i++) {
+		copy_string(observed->id[i], sizeof(observed->id[i]),
+		    batch->calls[i].id);
+		copy_string(observed->name[i], sizeof(observed->name[i]),
+		    batch->calls[i].name);
+		observed->input_len[i] = batch->calls[i].input_len;
+		if (batch->calls[i].input_len <= sizeof(observed->input[i]))
+			memcpy(observed->input[i], batch->calls[i].input,
+			    batch->calls[i].input_len);
+		else
+			f->bad = 1;
+	}
+}
+
+static enum turn_io
+fixture_execute(void *arg, const struct turn_tool_batch *batch,
+    struct turn_tool_results *results, char *error, size_t errorsz)
+{
+	struct fixture		*f = arg;
+	struct scripted_tool	*script;
+	struct scripted_result	*result;
+	const char		*id;
+	size_t			 i;
+
+	if (f->tool_next >= f->ntools) {
+		f->bad = 1;
+		return (TURN_IO_ERROR);
+	}
+	script = &f->tools[f->tool_next++];
+	observe_batch(f, batch);
+	if (script->has_usage)
+		f->add_usage_rc = turn_tool_results_add_usage(results,
+		    &script->usage);
+	for (i = 0; i < script->nresults; i++) {
+		result = &script->results[i];
+		id = result->id;
+		if (id == NULL && result->ordinal < batch->count)
+			id = batch->calls[result->ordinal].id;
+		f->set_result_rc[f->nset_result_rc++] = turn_tool_result_set(
+		    results, result->ordinal, id, result->data, result->len,
+		    result->is_error);
+	}
+	if (script->text != NULL || script->text_len != 0)
+		f->add_text_rc = turn_tool_results_add_text(results, script->text,
+		    script->text_len);
+	if (script->error != NULL && errorsz > 0)
+		(void)snprintf(error, errorsz, "%s", script->error);
+	if (script->fill_error && errorsz > 0)
+		memset(error, 'T', errorsz);
+	return (script->io);
+}
+
+static void
+fixture_init(struct fixture *f, struct turn_adapter *adapter,
+    const char *prompt)
+{
+	struct msg *message;
+
+	memset(f, 0, sizeof(*f));
+	TAILQ_INIT(&f->accepted);
+	message = text_message(ROLE_USER, prompt);
+	TAILQ_INSERT_TAIL(&f->accepted, message, entry);
+	memset(adapter, 0, sizeof(*adapter));
+	adapter->arg = f;
+	adapter->projection = fixture_projection;
+	adapter->projection_sent = fixture_projection_sent;
+	adapter->accept = fixture_accept;
+	adapter->assistant_accepted = fixture_assistant_accepted;
+	adapter->checkpoint = fixture_checkpoint;
+	adapter->generate = fixture_generate;
+	adapter->execute = fixture_execute;
+}
+
+static void
+options_init(struct turn_options *options, enum turn_policy_kind policy,
+    size_t data_used, size_t tool_batch_limit)
+{
+	memset(options, 0, sizeof(*options));
+	options->policy = policy;
+	options->data_limit = 1024;
+	options->data_used = data_used;
+	options->tool_batch_limit = tool_batch_limit;
+}
+
+static void
+test_normal_text_completes(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "question");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "answer");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_LEAD, 8, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0);
+	CHECK(result.status == TURN_STATUS_DONE &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 14 &&
+	    result.tool_batches == 0 && result.assistant != NULL &&
+	    strcmp(result.stop_reason, "stop") == 0 &&
+	    result.error[0] == '\0');
+	CHECK(result.usage.input_tokens == 0 &&
+	    result.usage.output_tokens == 0 && result.usage.cache_read == 0 &&
+	    result.usage.cache_write == 0);
+	CHECK(f.generation_next == 1 && f.accepts == 1 &&
+	    f.sent_callbacks == 1 && f.nassistants == 1 &&
+	    f.request_tools[0] == TURN_TOOLS_ENABLED &&
+	    f.projection_count[0] == 1 && message_count(&f.accepted) == 2);
+	CHECK(f.assistants[0].outcome == GENERATION_NORMAL &&
+	    f.assistants[0].tool_calls == 0 &&
+	    strcmp(f.assistants[0].stop_reason, "stop") == 0 &&
+	    f.assistants[0].synthesized_text.data == NULL &&
+	    f.assistants[0].synthesized_text.len == 0);
+	message = TAILQ_LAST(&f.accepted, msglist);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(message == result.assistant && message->role == ROLE_ASSISTANT &&
+	    block != NULL && block->type == BLOCK_TEXT && block->textlen == 6 &&
+	    memcmp(block->text, "answer", 6) == 0 &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_subagent_normal_text_completes_without_conclusion(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "answer");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE &&
+	    result.failure == TURN_FAILURE_NONE &&
+	    result.conclusion_failure == TURN_FAILURE_NONE &&
+	    result.data_used == 7 && result.tool_batches == 0 &&
+	    strcmp(result.stop_reason, "stop") == 0);
+	CHECK(f.generation_next == 1 && f.request_tools[0] == TURN_TOOLS_ENABLED &&
+	    f.accepts == 1 && f.nassistants == 1 && f.tool_next == 0 &&
+	    message_count(&f.accepted) == 2);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_tool_results_are_paired_before_next_generation(void)
+{
+	static const char input[] = "{\"path\":\"x\"}";
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "working");
+	script_tool_begin(generation, 7, "call-a", "read");
+	script_tool_input(generation, 7, input, sizeof(input) - 1);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	script_result(tool, 0, NULL, "file", 4, 0);
+	tool->text = "steer";
+	tool->text_len = 5;
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "done");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0);
+	CHECK(result.status == TURN_STATUS_DONE &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 33 &&
+	    result.tool_batches == 1 && strcmp(result.stop_reason, "stop") == 0);
+	CHECK(f.bad == 0 && f.generation_next == 2 && f.tool_next == 1 &&
+	    f.accepts == 3 && f.sent_callbacks == 2 && f.nassistants == 2 &&
+	    f.nbatches == 1 && f.nset_result_rc == 1 &&
+	    f.set_result_rc[0] == 0 && f.add_text_rc == 0);
+	CHECK(f.projection_count[0] == 1 && f.projection_count[1] == 3 &&
+	    f.request_tools[0] == TURN_TOOLS_ENABLED &&
+	    f.request_tools[1] == TURN_TOOLS_ENABLED);
+	CHECK(f.batches[0].count == 1 &&
+	    strcmp(f.batches[0].id[0], "call-a") == 0 &&
+	    strcmp(f.batches[0].name[0], "read") == 0 &&
+	    f.batches[0].input_len[0] == sizeof(input) - 1 &&
+	    memcmp(f.batches[0].input[0], input, sizeof(input) - 1) == 0);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(message != NULL && message->role == ROLE_USER && block != NULL &&
+	    block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "call-a") == 0 &&
+	    block->result_len == 4 && memcmp(block->result, "file", 4) == 0 &&
+	    block->is_error == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT && block->textlen == 5 &&
+	    memcmp(block->text, "steer", 5) == 0 &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	message = message_at(&f.accepted, 3);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(message == result.assistant && block != NULL &&
+	    block->type == BLOCK_TEXT && block->textlen == 4 &&
+	    memcmp(block->text, "done", 4) == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_invalid_generations_are_never_accepted(void)
+{
+	static const struct {
+		enum generation_invalid_cause cause;
+		const char *input;
+		size_t input_len;
+	} cases[] = {
+		{ GENERATION_INVALID_EMPTY_REPLY, NULL, 0 },
+		{ GENERATION_INVALID_TOOL_ARGUMENTS, "[]", 2 }
+	};
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	size_t			 i;
+	int			 rc;
+
+	for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+		fixture_init(&f, &adapter, "q");
+		generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+		if (cases[i].input != NULL) {
+			script_tool_begin(generation, 0, "bad", "read");
+			script_tool_input(generation, 0, cases[i].input,
+			    cases[i].input_len);
+			script_done(generation, "tool_use");
+		} else
+			script_done(generation, "stop");
+		options_init(&options, TURN_POLICY_LEAD, 1, 2);
+		rc = turn_run(&adapter, &options, &result);
+
+		CHECK(rc == 0 && f.bad == 0 &&
+		    result.status == TURN_STATUS_FAILED &&
+		    result.failure == TURN_FAILURE_GENERATION &&
+		    result.invalid_cause == cases[i].cause &&
+		    result.assistant == NULL && result.stop_reason[0] == '\0' &&
+		    result.error[0] != '\0');
+		CHECK(f.generation_next == 1 && f.sent_callbacks == 1 &&
+		    f.accepts == 0 && f.nassistants == 0 && f.tool_next == 0 &&
+		    message_count(&f.accepted) == 1);
+		msglist_free(&f.accepted);
+	}
+}
+
+static void
+test_no_argument_call_uses_canonical_empty_object(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 4, "none", "ls");
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	script_result(tool, 0, NULL, "ok", 2, 0);
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "done");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE &&
+	    result.data_used == 9 && result.tool_batches == 1 &&
+	    f.generation_next == 2 && f.tool_next == 1);
+	CHECK(f.batches[0].count == 1 && f.batches[0].input_len[0] == 2 &&
+	    memcmp(f.batches[0].input[0], "{}", 2) == 0);
+	message = message_at(&f.accepted, 1);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_USE &&
+	    block->tool_input_len == 2 &&
+	    memcmp(block->tool_input, "{}", 2) == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_lead_length_text_completes_and_calls_continue(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_done(generation, "max_tokens");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE &&
+	    result.data_used == 8 && strcmp(result.stop_reason, "max_tokens") == 0 &&
+	    f.generation_next == 1 && f.tool_next == 0 && f.nassistants == 1);
+	CHECK(f.assistants[0].outcome == GENERATION_LENGTH &&
+	    f.assistants[0].tool_calls == 0 &&
+	    strcmp(f.assistants[0].stop_reason, "max_tokens") == 0);
+	msglist_free(&f.accepted);
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 1, "length-call", "read");
+	script_tool_input(generation, 1, "{}", 2);
+	script_done(generation, "length");
+	tool = fixture_tool(&f);
+	script_result(tool, 0, NULL, "ok", 2, 0);
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "after");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE &&
+	    result.tool_batches == 1 && f.generation_next == 2 &&
+	    f.tool_next == 1 && f.nbatches == 1 && f.accepts == 3);
+	CHECK(f.assistants[0].outcome == GENERATION_LENGTH &&
+	    f.assistants[0].tool_calls == 1 &&
+	    strcmp(f.batches[0].id[0], "length-call") == 0 &&
+	    f.request_tools[1] == TURN_TOOLS_ENABLED);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_length_placeholder_is_observed_after_accept(void)
+{
+	static const char placeholder[] = GENERATION_LENGTH_EMPTY;
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_done(generation, "max_tokens");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	message = message_at(&f.accepted, 1);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE &&
+	    result.data_used == 1 + sizeof(placeholder) - 1 &&
+	    f.nassistants == 1 &&
+	    f.assistants[0].outcome == GENERATION_LENGTH);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    block->textlen == sizeof(placeholder) - 1 &&
+	    memcmp(block->text, placeholder, sizeof(placeholder) - 1) == 0 &&
+	    f.assistants[0].synthesized_text.data == block->text &&
+	    f.assistants[0].synthesized_text.len == sizeof(placeholder) - 1);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_subagent_length_pairs_dangling_calls_then_concludes(void)
+{
+	static const char dangling[] =
+	    "subagent tool call was truncated and was not executed";
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct msg		*message;
+	struct block		*block;
+	size_t			 want_data;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_tool_begin(generation, 2, "dangling-a", "read");
+	script_tool_input(generation, 2, "{}", 2);
+	script_tool_begin(generation, 8, "dangling-b", "grep");
+	script_tool_input(generation, 8, "{}", 2);
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_text(generation, "conclusion");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	want_data = 1 + 7 + 2 + 2 + 2 * (sizeof(dangling) - 1) + 10;
+
+	CHECK(rc == 0);
+	CHECK(result.status == TURN_STATUS_CONCLUDED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == want_data &&
+	    result.tool_batches == 0 &&
+	    strcmp(result.stop_reason, "stop") == 0);
+	CHECK(f.bad == 0 && f.generation_next == 2 && f.tool_next == 0 &&
+	    f.nbatches == 0 && f.accepts == 3 && f.nassistants == 2 &&
+	    f.sent_callbacks == 2);
+	CHECK(f.request_tools[0] == TURN_TOOLS_ENABLED &&
+	    f.request_tools[1] == TURN_TOOLS_DISABLED &&
+	    f.projection_count[0] == 1 && f.projection_count[1] == 3 &&
+	    f.assistants[0].outcome == GENERATION_LENGTH &&
+	    f.assistants[0].tool_calls == 2 &&
+	    f.assistants[1].outcome == GENERATION_NORMAL &&
+	    f.assistants[1].tool_calls == 0);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "dangling-a") == 0 &&
+	    block->is_error == 1 && block->result_len == sizeof(dangling) - 1 &&
+	    memcmp(block->result, dangling, sizeof(dangling) - 1) == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "dangling-b") == 0 &&
+	    block->is_error == 1 && block->result_len == sizeof(dangling) - 1 &&
+	    memcmp(block->result, dangling, sizeof(dangling) - 1) == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    strstr(block->text, "Conclude now") != NULL &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	message = message_at(&f.accepted, 3);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(message == result.assistant && block != NULL &&
+	    block->type == BLOCK_TEXT && block->textlen == 10 &&
+	    memcmp(block->text, "conclusion", 10) == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+script_tool_round(struct fixture *f, const char *id)
+{
+	struct scripted_generation *generation;
+	struct scripted_tool *tool;
+
+	generation = fixture_generation(f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, id, "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(f);
+	script_result(tool, 0, NULL, "ok", 2, 0);
+}
+
+static void
+script_two_calls(struct fixture *f)
+{
+	struct scripted_generation *generation;
+
+	generation = fixture_generation(f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 3, "call-a", "read");
+	script_tool_input(generation, 3, "{}", 2);
+	script_tool_begin(generation, 9, "call-b", "grep");
+	script_tool_input(generation, 9, "{}", 2);
+	script_done(generation, "tool_use");
+}
+
+static void
+test_lead_cap_follows_the_final_paired_result(void)
+{
+	struct fixture	 f;
+	struct turn_adapter adapter;
+	struct turn_options options;
+	struct turn_result result;
+	struct msg	*message;
+	struct block	*block;
+	int		 rc;
+
+	fixture_init(&f, &adapter, "q");
+	script_tool_round(&f, "round-a");
+	script_tool_round(&f, "round-b");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_CAP &&
+	    result.failure == TURN_FAILURE_NONE && result.tool_batches == 2 &&
+	    result.data_used == 9 && f.generation_next == 2 && f.tool_next == 2);
+	CHECK(f.accepts == 4 && f.nassistants == 2 && f.nbatches == 2 &&
+	    f.sent_callbacks == 2 && message_count(&f.accepted) == 5 &&
+	    f.projection_count[0] == 1 && f.projection_count[1] == 3);
+	message = message_at(&f.accepted, 4);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(message != NULL && message->role == ROLE_USER && block != NULL &&
+	    block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "round-b") == 0 &&
+	    block->result_len == 2 && memcmp(block->result, "ok", 2) == 0 &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_subagent_cap_gets_one_tools_disabled_conclusion(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	script_tool_round(&f, "round-a");
+	script_tool_round(&f, "round-b");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_text(generation, "final");
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0);
+	CHECK(result.status == TURN_STATUS_CONCLUDED &&
+	    result.failure == TURN_FAILURE_NONE && result.tool_batches == 2 &&
+	    result.data_used == 14 && strcmp(result.stop_reason, "stop") == 0);
+	CHECK(f.bad == 0 && f.generation_next == 3 && f.tool_next == 2 &&
+	    f.accepts == 5 && f.nassistants == 3 && f.nbatches == 2 &&
+	    f.sent_callbacks == 3 && message_count(&f.accepted) == 6);
+	CHECK(f.request_tools[0] == TURN_TOOLS_ENABLED &&
+	    f.request_tools[1] == TURN_TOOLS_ENABLED &&
+	    f.request_tools[2] == TURN_TOOLS_DISABLED &&
+	    f.projection_count[0] == 1 && f.projection_count[1] == 3 &&
+	    f.projection_count[2] == 5);
+	message = message_at(&f.accepted, 4);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "round-b") == 0 &&
+	    block->result_len == 2 && memcmp(block->result, "ok", 2) == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    strstr(block->text, "Conclude now") != NULL &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	message = message_at(&f.accepted, 5);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(message == result.assistant && block != NULL &&
+	    block->type == BLOCK_TEXT && block->textlen == 5 &&
+	    memcmp(block->text, "final", 5) == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_initial_checkpoint_precedes_first_projection(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "must not run");
+	script_done(generation, "stop");
+	f.interrupt_checkpoint = 1;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 1 &&
+	    result.assistant == NULL);
+	CHECK(f.checkpoints == 1 && f.projections == 0 &&
+	    f.generation_next == 0 && f.sent_callbacks == 0 &&
+	    f.accepts == 0 && f.nassistants == 0 &&
+	    message_count(&f.accepted) == 1);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_generation_helper_misuse_is_rejected(void)
+{
+	enum helper_misuse {
+		HELPER_EVENT_BEFORE_SENT,
+		HELPER_SENT_TWICE,
+		HELPER_NO_TERMINAL
+	};
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 misuse, rc;
+
+	for (misuse = HELPER_EVENT_BEFORE_SENT;
+	    misuse <= HELPER_NO_TERMINAL; misuse++) {
+		fixture_init(&f, &adapter, "q");
+		generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+		if (misuse == HELPER_EVENT_BEFORE_SENT) {
+			generation->sent = 0;
+			script_text(generation, "x");
+			script_done(generation, "stop");
+		} else if (misuse == HELPER_SENT_TWICE)
+			generation->sent_twice = 1;
+		else
+			generation->fill_error = 1;
+		options_init(&options, TURN_POLICY_LEAD, 1, 2);
+		rc = turn_run(&adapter, &options, &result);
+
+		CHECK(rc == 0 && f.bad == 0 &&
+		    result.status == TURN_STATUS_FAILED &&
+		    result.failure == TURN_FAILURE_ADAPTER &&
+		    result.data_used == 1 && result.assistant == NULL &&
+		    result.error[0] != '\0');
+		CHECK(f.checkpoints == 2 && f.projections == 1 &&
+		    f.generation_next == 1 && f.accepts == 0 &&
+		    f.nassistants == 0 && message_count(&f.accepted) == 1 &&
+		    f.sent_callbacks ==
+		    (misuse == HELPER_EVENT_BEFORE_SENT ? 0 : 1));
+		if (misuse == HELPER_NO_TERMINAL)
+			CHECK(result.error[0] == 'G' &&
+			    result.error[TURN_ERROR_MAX - 2] == 'G' &&
+			    result.error[TURN_ERROR_MAX - 1] == '\0');
+		msglist_free(&f.accepted);
+	}
+}
+
+static void
+test_adapter_interrupted_results_are_authoritative(void)
+{
+	static const struct generation_usage nested = { 5, 6, 7, 8 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	generation->io = TURN_IO_INTERRUPTED;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 1 &&
+	    f.checkpoints == 1 && f.projections == 1 &&
+	    f.generation_next == 1 && f.sent_callbacks == 1 && f.accepts == 0);
+	msglist_free(&f.accepted);
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "interrupt", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	tool->io = TURN_IO_INTERRUPTED;
+	tool->usage = nested;
+	tool->has_usage = 1;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 3 &&
+	    result.tool_batches == 0 && result.assistant == NULL &&
+	    f.checkpoints == 4 && f.tool_next == 1 && f.accepts == 1);
+	CHECK(result.usage.input_tokens == 5 &&
+	    result.usage.output_tokens == 6 && result.usage.cache_read == 7 &&
+	    result.usage.cache_write == 8 && f.add_usage_rc == 0);
+	msglist_free(&f.accepted);
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "error", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	tool->io = TURN_IO_ERROR;
+	tool->fill_error = 1;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
+	    result.failure == TURN_FAILURE_ADAPTER && result.data_used == 3 &&
+	    result.error[0] == 'T' &&
+	    result.error[TURN_ERROR_MAX - 2] == 'T' &&
+	    result.error[TURN_ERROR_MAX - 1] == '\0' &&
+	    f.checkpoints == 5 && f.tool_next == 1 && f.accepts == 1);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_terminal_generation_is_checkpointed_before_accept(void)
+{
+	static const struct generation_usage usage = { 3, 4, 5, 6 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "do not accept");
+	script_usage(generation, &usage);
+	script_done(generation, "stop");
+	f.interrupt_checkpoint = 2;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 14 &&
+	    result.assistant == NULL && result.stop_reason[0] == '\0');
+	CHECK(result.usage.input_tokens == 3 &&
+	    result.usage.output_tokens == 4 && result.usage.cache_read == 5 &&
+	    result.usage.cache_write == 6);
+	CHECK(f.generation_next == 1 && f.sent_callbacks == 1 &&
+	    f.checkpoints == 2 && f.accepts == 0 && f.nassistants == 0 &&
+	    f.tool_next == 0 &&
+	    message_count(&f.accepted) == 1);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_accepted_assistant_is_checkpointed_before_tool_decision(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "pending", "read");
+	script_done(generation, "tool_use");
+	f.interrupt_checkpoint = 4;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 3 &&
+	    result.assistant == NULL && f.checkpoints == 4);
+	CHECK(f.accepts == 1 && f.nassistants == 1 && f.tool_next == 0 &&
+	    f.nbatches == 0 && message_count(&f.accepted) == 2);
+	message = message_at(&f.accepted, 1);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_USE &&
+	    strcmp(block->tool_id, "pending") == 0 &&
+	    block->tool_input_len == 2 &&
+	    memcmp(block->tool_input, "{}", 2) == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_accepted_results_are_checkpointed_before_next_decision(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	struct msg		*message;
+	struct block		*block;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "pending-result", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	script_result(tool, 0, NULL, "ok", 2, 0);
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "must not project");
+	script_done(generation, "stop");
+	f.interrupt_checkpoint = 7;
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 5 &&
+	    result.tool_batches == 1 && result.assistant == NULL &&
+	    f.checkpoints == 7);
+	CHECK(f.bad == 0 && f.generation_next == 1 && f.tool_next == 1 &&
+	    f.accepts == 2 && f.nassistants == 1 && f.nbatches == 1 &&
+	    f.sent_callbacks == 1 && message_count(&f.accepted) == 3);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "pending-result") == 0 &&
+	    block->result_len == 2 && memcmp(block->result, "ok", 2) == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_cancellation_precedes_generation_failure_classification(void)
+{
+	static const struct generation_usage usage = { 11, 12, 13, 14 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 invalid, rc;
+
+	for (invalid = 0; invalid <= 1; invalid++) {
+		fixture_init(&f, &adapter, "q");
+		generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+		if (invalid) {
+			script_tool_begin(generation, 0, "bad", "read");
+			script_tool_input(generation, 0, "[]", 2);
+			script_usage(generation, &usage);
+			script_done(generation, "tool_use");
+		} else {
+			script_usage(generation, &usage);
+			script_provider_error(generation, "provider down");
+		}
+		f.interrupt_checkpoint = 2;
+		options_init(&options, TURN_POLICY_LEAD, 1, 2);
+		rc = turn_run(&adapter, &options, &result);
+
+		CHECK(rc == 0 && f.bad == 0 &&
+		    result.status == TURN_STATUS_INTERRUPTED &&
+		    result.failure == TURN_FAILURE_NONE &&
+		    result.data_used == (invalid ? 3 : 1) &&
+		    result.assistant == NULL && result.error[0] == '\0');
+		CHECK(result.usage.input_tokens == 11 &&
+		    result.usage.output_tokens == 12 &&
+		    result.usage.cache_read == 13 &&
+		    result.usage.cache_write == 14);
+		CHECK(f.checkpoints == 2 && f.generation_next == 1 &&
+		    f.sent_callbacks == 1 && f.accepts == 0 &&
+		    f.nassistants == 0 && message_count(&f.accepted) == 1);
+		msglist_free(&f.accepted);
+	}
+}
+
+static void
+test_cancellation_precedes_tool_failure_classification(void)
+{
+	static const struct generation_usage nested = { 7, 8, 9, 10 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	int			 data_bound, rc;
+
+	for (data_bound = 0; data_bound <= 1; data_bound++) {
+		fixture_init(&f, &adapter, "q");
+		generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+		script_tool_begin(generation, 0, "race", "read");
+		script_tool_input(generation, 0, "{}", 2);
+		script_done(generation, "tool_use");
+		tool = fixture_tool(&f);
+		tool->usage = nested;
+		tool->has_usage = 1;
+		if (data_bound)
+			script_result(tool, 0, NULL, "x", 1, 0);
+		else {
+			tool->io = TURN_IO_ERROR;
+			tool->error = "tool failed";
+		}
+		f.interrupt_checkpoint = 5;
+		options_init(&options, TURN_POLICY_LEAD, 1, 2);
+		if (data_bound)
+			options.data_limit = 3;
+		rc = turn_run(&adapter, &options, &result);
+
+		CHECK(rc == 0 && f.bad == 0 &&
+		    result.status == TURN_STATUS_INTERRUPTED &&
+		    result.failure == TURN_FAILURE_NONE && result.data_used == 3 &&
+		    result.assistant == NULL && result.error[0] == '\0');
+		CHECK(result.usage.input_tokens == 7 &&
+		    result.usage.output_tokens == 8 &&
+		    result.usage.cache_read == 9 &&
+		    result.usage.cache_write == 10 && f.add_usage_rc == 0);
+		CHECK(f.checkpoints == 5 && f.generation_next == 1 &&
+		    f.tool_next == 1 && f.accepts == 1 && f.nassistants == 1 &&
+		    message_count(&f.accepted) == 2);
+		if (data_bound)
+			CHECK(f.nset_result_rc == 1 && f.set_result_rc[0] == -1);
+		msglist_free(&f.accepted);
+	}
+}
+
+static void
+test_cancellation_precedes_conclusion_failure_fallback(void)
+{
+	static const struct generation_usage usage = { 1, 2, 3, 4 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_usage(generation, &usage);
+	script_provider_error(generation, "conclusion down");
+	f.interrupt_checkpoint = 7;
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_INTERRUPTED &&
+	    result.failure == TURN_FAILURE_NONE &&
+	    result.conclusion_failure == TURN_FAILURE_NONE &&
+	    result.data_used == 8 && result.assistant == NULL &&
+	    result.stop_reason[0] == '\0' && result.error[0] == '\0');
+	CHECK(result.usage.input_tokens == 1 &&
+	    result.usage.output_tokens == 2 && result.usage.cache_read == 3 &&
+	    result.usage.cache_write == 4);
+	CHECK(f.checkpoints == 7 && f.generation_next == 2 &&
+	    f.sent_callbacks == 2 && f.accepts == 2 && f.nassistants == 1 &&
+	    f.request_tools[1] == TURN_TOOLS_DISABLED &&
+	    message_count(&f.accepted) == 3);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_subagent_conclusion_failure_preserves_fallback(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	const struct msg		*fallback;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_provider_error(generation, "conclusion down");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	fallback = message_at(&f.accepted, 1);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED &&
+	    result.failure == TURN_FAILURE_NONE &&
+	    result.invalid_cause == GENERATION_INVALID_NONE &&
+	    result.conclusion_failure == TURN_FAILURE_PROVIDER &&
+	    result.conclusion_invalid_cause == GENERATION_INVALID_NONE &&
+	    strcmp(result.conclusion_error, "conclusion down") == 0 &&
+	    result.error[0] == '\0');
+	CHECK(result.assistant == fallback && result.data_used == 8 &&
+	    result.tool_batches == 0 &&
+	    strcmp(result.stop_reason, "max_tokens") == 0);
+	CHECK(f.generation_next == 2 && f.sent_callbacks == 2 &&
+	    f.accepts == 2 && f.nassistants == 1 && f.tool_next == 0 &&
+	    f.request_tools[1] == TURN_TOOLS_DISABLED &&
+	    message_count(&f.accepted) == 3);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_tools_disabled_call_is_a_conclusion_policy_failure(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	const struct msg		*fallback;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_tool_begin(generation, 0, "disallowed", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	fallback = message_at(&f.accepted, 1);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED &&
+	    result.failure == TURN_FAILURE_NONE &&
+	    result.invalid_cause == GENERATION_INVALID_NONE &&
+	    result.conclusion_failure == TURN_FAILURE_GENERATION &&
+	    result.conclusion_invalid_cause == GENERATION_INVALID_NONE &&
+	    strcmp(result.conclusion_error,
+	    "tools-disabled conclusion returned a tool call") == 0);
+	CHECK(result.assistant == fallback && result.data_used == 10 &&
+	    result.tool_batches == 0 &&
+	    strcmp(result.stop_reason, "max_tokens") == 0);
+	CHECK(f.generation_next == 2 && f.sent_callbacks == 2 &&
+	    f.accepts == 2 && f.nassistants == 1 && f.tool_next == 0 &&
+	    f.request_tools[1] == TURN_TOOLS_DISABLED &&
+	    message_count(&f.accepted) == 3);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_fixed_conclusion_guidance_is_outside_hostile_data_bound(void)
+{
+	static const char dangling[] =
+	    "subagent tool call was truncated and was not executed";
+	static const char bound[] = "subagent data bound exceeded";
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	struct msg		*message;
+	struct block		*block;
+	size_t			 exact;
+	int			 rc;
+
+	/* A hostile result may fill the budget; fixed guidance still appends. */
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "at-bound", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	script_result(tool, 0, NULL, "x", 1, 0);
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_provider_error(generation, "no conclusion room");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 1);
+	options.data_limit = 4;
+	rc = turn_run(&adapter, &options, &result);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED && result.data_used == 4 &&
+	    result.conclusion_failure == TURN_FAILURE_PROVIDER &&
+	    f.set_result_rc[0] == 0 && block != NULL &&
+	    block->type == BLOCK_TOOL_RESULT && block->result_len == 1 &&
+	    memcmp(block->result, "x", 1) == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    strstr(block->text, "Conclude now") != NULL &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+
+	/* One hostile result byte beyond that boundary is rejected. */
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "over-bound", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	script_result(tool, 0, NULL, "x", 1, 0);
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 1);
+	options.data_limit = 3;
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
+	    result.failure == TURN_FAILURE_DATA_BOUND && result.data_used == 3 &&
+	    f.set_result_rc[0] == -1 && f.accepts == 1 &&
+	    message_count(&f.accepted) == 2);
+	msglist_free(&f.accepted);
+
+	/* Dangling diagnostics use the full reason when it fits exactly. */
+	exact = 1 + 2 + sizeof(dangling) - 1;
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "dangling", "read");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_provider_error(generation, "no conclusion room");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 1);
+	options.data_limit = exact;
+	rc = turn_run(&adapter, &options, &result);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED &&
+	    result.data_used == exact &&
+	    result.conclusion_failure == TURN_FAILURE_PROVIDER);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    block->result_len == sizeof(dangling) - 1);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    strstr(block->text, "Conclude now") != NULL &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "dangling", "read");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_provider_error(generation, "no conclusion room");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 1);
+	options.data_limit = exact - 1;
+	rc = turn_run(&adapter, &options, &result);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED &&
+	    result.data_used == 3 + sizeof(bound) - 1 &&
+	    result.conclusion_failure == TURN_FAILURE_PROVIDER &&
+	    f.generation_next == 2 && f.accepts == 2);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "dangling") == 0 &&
+	    block->is_error == 1 && block->result_len == sizeof(bound) - 1 &&
+	    memcmp(block->result, bound, sizeof(bound) - 1) == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    strstr(block->text, "Conclude now") != NULL &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+
+	/* At zero room every dangling call still gets an ordered error pair. */
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_tool_begin(generation, 0, "zero-a", "read");
+	script_tool_begin(generation, 1, "zero-b", "grep");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_provider_error(generation, "no conclusion room");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 1);
+	options.data_limit = 5;
+	rc = turn_run(&adapter, &options, &result);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED && result.data_used == 5 &&
+	    result.conclusion_failure == TURN_FAILURE_PROVIDER &&
+	    f.generation_next == 2 && f.accepts == 2);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "zero-a") == 0 && block->is_error == 1 &&
+	    block->result_len == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "zero-b") == 0 && block->is_error == 1 &&
+	    block->result_len == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TEXT &&
+	    strstr(block->text, "Conclude now") != NULL &&
+	    TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_provider_and_adapter_errors_remain_distinct(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_provider_error(generation, "provider down");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
+	    result.failure == TURN_FAILURE_PROVIDER && result.data_used == 8 &&
+	    strcmp(result.error, "provider down") == 0 &&
+	    result.assistant == NULL && f.sent_callbacks == 1 &&
+	    f.accepts == 0 && f.nassistants == 0);
+	msglist_free(&f.accepted);
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	generation->sent = 0;
+	generation->io = TURN_IO_ERROR;
+	generation->error = "request serialization failed";
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
+	    result.failure == TURN_FAILURE_ADAPTER && result.data_used == 1 &&
+	    strcmp(result.error, "request serialization failed") == 0 &&
+	    result.assistant == NULL && result.stop_reason[0] == '\0');
+	CHECK(f.generation_next == 1 && f.sent_callbacks == 0 &&
+	    f.accepts == 0 && f.nassistants == 0 &&
+	    message_count(&f.accepted) == 1);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_out_of_order_results_are_accepted_in_call_order(void)
+{
+	struct fixture	 f;
+	struct turn_adapter adapter;
+	struct turn_options options;
+	struct turn_result result;
+	struct scripted_tool *tool;
+	struct msg	*message;
+	struct block	*block;
+	int		 rc;
+
+	fixture_init(&f, &adapter, "q");
+	script_two_calls(&f);
+	tool = fixture_tool(&f);
+	script_result(tool, 1, NULL, "B", 1, 1);
+	script_result(tool, 0, NULL, "A", 1, 0);
+	options_init(&options, TURN_POLICY_LEAD, 1, 1);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_CAP &&
+	    result.failure == TURN_FAILURE_NONE && result.tool_batches == 1 &&
+	    result.data_used == 7 && f.nset_result_rc == 2 &&
+	    f.set_result_rc[0] == 0 && f.set_result_rc[1] == 0);
+	message = message_at(&f.accepted, 2);
+	block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "call-a") == 0 &&
+	    block->result_len == 1 && memcmp(block->result, "A", 1) == 0 &&
+	    block->is_error == 0);
+	block = block == NULL ? NULL : TAILQ_NEXT(block, entry);
+	CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT &&
+	    strcmp(block->tool_use_id, "call-b") == 0 &&
+	    block->result_len == 1 && memcmp(block->result, "B", 1) == 0 &&
+	    block->is_error == 1 && TAILQ_NEXT(block, entry) == NULL);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_incomplete_or_inconsistent_result_batches_are_rejected(void)
+{
+	enum pairing_case {
+		PAIR_MISSING,
+		PAIR_EXTRA,
+		PAIR_DUPLICATE,
+		PAIR_MISMATCH,
+		PAIR_MISMATCH_IO_ERROR,
+		PAIR_DATA_IO_ERROR
+	};
+	struct fixture	 f;
+	struct turn_adapter adapter;
+	struct turn_options options;
+	struct turn_result result;
+	struct scripted_tool *tool;
+	enum turn_failure want_failure;
+	int rc, pair;
+
+	for (pair = PAIR_MISSING; pair <= PAIR_DATA_IO_ERROR; pair++) {
+		fixture_init(&f, &adapter, "q");
+		script_two_calls(&f);
+		tool = fixture_tool(&f);
+		switch (pair) {
+		case PAIR_MISSING:
+			script_result(tool, 0, NULL, "A", 1, 0);
+			break;
+		case PAIR_EXTRA:
+			script_result(tool, 2, "extra", "X", 1, 0);
+			break;
+		case PAIR_DUPLICATE:
+			script_result(tool, 0, NULL, "A", 1, 0);
+			script_result(tool, 0, NULL, "again", 5, 0);
+			break;
+		case PAIR_MISMATCH:
+			script_result(tool, 0, "wrong-id", "X", 1, 0);
+			break;
+		case PAIR_MISMATCH_IO_ERROR:
+			script_result(tool, 0, "wrong-id", "X", 1, 0);
+			tool->io = TURN_IO_ERROR;
+			tool->error = "setter rejected result";
+			break;
+		case PAIR_DATA_IO_ERROR:
+			script_result(tool, 0, NULL, "X", 1, 0);
+			tool->io = TURN_IO_ERROR;
+			tool->error = "setter rejected result";
+			break;
+		}
+		options_init(&options, TURN_POLICY_LEAD, 1, 2);
+		want_failure = TURN_FAILURE_TOOL_RESULTS;
+		if (pair == PAIR_DATA_IO_ERROR) {
+			options.data_limit = 5;
+			want_failure = TURN_FAILURE_DATA_BOUND;
+		}
+		rc = turn_run(&adapter, &options, &result);
+
+		CHECK(rc == 0 && f.bad == 0 &&
+		    result.status == TURN_STATUS_FAILED &&
+		    result.failure == want_failure &&
+		    result.tool_batches == 0 && result.data_used == 5 &&
+		    result.assistant == NULL && result.error[0] != '\0');
+		CHECK(f.accepts == 1 && f.nassistants == 1 && f.tool_next == 1 &&
+		    f.nbatches == 1 && message_count(&f.accepted) == 2);
+		if (pair == PAIR_MISSING)
+			CHECK(f.nset_result_rc == 1 && f.set_result_rc[0] == 0);
+		else if (pair == PAIR_DUPLICATE)
+			CHECK(f.nset_result_rc == 2 && f.set_result_rc[0] == 0 &&
+			    f.set_result_rc[1] == -1);
+		else
+			CHECK(f.nset_result_rc == 1 && f.set_result_rc[0] == -1);
+		msglist_free(&f.accepted);
+	}
+}
+
+static void
+test_turn_data_and_usage_are_accumulated_once(void)
+{
+	struct generation_usage first = { 1, 2, 3, 4 };
+	struct generation_usage second = { 10, 20, 30, 40 };
+	struct generation_usage nested = { 100, 200, 300, 400 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "seed");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "aa");
+	script_tool_begin(generation, 0, "account", "read");
+	script_tool_input(generation, 0, "{}", 2);
+	script_usage(generation, &first);
+	script_done(generation, "tool_use");
+	tool = fixture_tool(&f);
+	tool->usage = nested;
+	tool->has_usage = 1;
+	script_result(tool, 0, NULL, "rrrr", 4, 0);
+	tool->text = "ss";
+	tool->text_len = 2;
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "bbb");
+	script_usage(generation, &second);
+	script_done(generation, "stop");
+	options_init(&options, TURN_POLICY_LEAD, 5, 2);
+	rc = turn_run(&adapter, &options, &result);
+
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE &&
+	    result.failure == TURN_FAILURE_NONE && result.data_used == 18 &&
+	    result.tool_batches == 1);
+	CHECK(result.usage.input_tokens == 111 &&
+	    result.usage.output_tokens == 222 &&
+	    result.usage.cache_read == 333 &&
+	    result.usage.cache_write == 444);
+	CHECK(f.generation_next == 2 && f.tool_next == 1 && f.accepts == 3 &&
+	    f.sent_callbacks == 2 && f.nassistants == 2 && f.nbatches == 1 &&
+	    f.set_result_rc[0] == 0 && f.add_text_rc == 0 &&
+	    f.add_usage_rc == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
+test_nested_usage_obeys_the_whole_turn_bound(void)
+{
+	struct generation_usage near = { GENERATION_USAGE_MAX - 1, 0, 0, 0 };
+	struct generation_usage full = { GENERATION_USAGE_MAX, 0, 0, 0 };
+	struct generation_usage one = { 1, 0, 0, 0 };
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	struct scripted_tool	*tool;
+	int			 overflow, rc;
+
+	for (overflow = 0; overflow <= 1; overflow++) {
+		fixture_init(&f, &adapter, "q");
+		generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+		script_tool_begin(generation, 0, "usage-bound", "read");
+		script_tool_input(generation, 0, "{}", 2);
+		script_usage(generation, overflow ? &full : &near);
+		script_done(generation, "tool_use");
+		tool = fixture_tool(&f);
+		tool->usage = one;
+		tool->has_usage = 1;
+		script_result(tool, 0, NULL, "ok", 2, 0);
+		options_init(&options, TURN_POLICY_LEAD, 1, 1);
+		rc = turn_run(&adapter, &options, &result);
+
+		CHECK(rc == 0 && f.bad == 0 &&
+		    result.usage.input_tokens == GENERATION_USAGE_MAX &&
+		    result.usage.output_tokens == 0 &&
+		    result.usage.cache_read == 0 &&
+		    result.usage.cache_write == 0);
+		if (overflow) {
+			CHECK(result.status == TURN_STATUS_FAILED &&
+			    result.failure == TURN_FAILURE_USAGE_BOUND &&
+			    result.data_used == 3 && result.tool_batches == 0 &&
+			    f.add_usage_rc == -1 && f.set_result_rc[0] == -1 &&
+			    f.accepts == 1);
+		} else {
+			CHECK(result.status == TURN_STATUS_CAP &&
+			    result.failure == TURN_FAILURE_NONE &&
+			    result.data_used == 5 && result.tool_batches == 1 &&
+			    f.add_usage_rc == 0 && f.set_result_rc[0] == 0 &&
+			    f.accepts == 2);
+		}
+		msglist_free(&f.accepted);
+	}
+}
+
+int
+main(void)
+{
+	test_normal_text_completes();
+	test_subagent_normal_text_completes_without_conclusion();
+	test_tool_results_are_paired_before_next_generation();
+	test_invalid_generations_are_never_accepted();
+	test_no_argument_call_uses_canonical_empty_object();
+	test_lead_length_text_completes_and_calls_continue();
+	test_length_placeholder_is_observed_after_accept();
+	test_subagent_length_pairs_dangling_calls_then_concludes();
+	test_lead_cap_follows_the_final_paired_result();
+	test_subagent_cap_gets_one_tools_disabled_conclusion();
+	test_initial_checkpoint_precedes_first_projection();
+	test_generation_helper_misuse_is_rejected();
+	test_adapter_interrupted_results_are_authoritative();
+	test_terminal_generation_is_checkpointed_before_accept();
+	test_accepted_assistant_is_checkpointed_before_tool_decision();
+	test_accepted_results_are_checkpointed_before_next_decision();
+	test_cancellation_precedes_generation_failure_classification();
+	test_cancellation_precedes_tool_failure_classification();
+	test_cancellation_precedes_conclusion_failure_fallback();
+	test_subagent_conclusion_failure_preserves_fallback();
+	test_tools_disabled_call_is_a_conclusion_policy_failure();
+	test_fixed_conclusion_guidance_is_outside_hostile_data_bound();
+	test_provider_and_adapter_errors_remain_distinct();
+	test_out_of_order_results_are_accepted_in_call_order();
+	test_incomplete_or_inconsistent_result_batches_are_rejected();
+	test_turn_data_and_usage_are_accumulated_once();
+	test_nested_usage_obeys_the_whole_turn_bound();
+	REGRESS_END();
+}
blob - /dev/null
blob + 18f0d987fe56d92ab02525c5e9c8bf451f0f7272 (mode 644)
--- /dev/null
+++ src/common/turn_mechanics.c
@@ -0,0 +1,753 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/queue.h>
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "xmalloc.h"
+#include "buf.h"
+#include "msg.h"
+#include "generation.h"
+#include "turn_mechanics.h"
+
+struct turn_state {
+	const struct turn_adapter	*adapter;
+	const struct turn_options	*options;
+	struct turn_result		*result;
+	size_t				 data_used;
+	size_t				 tool_batches;
+	struct generation_usage		 usage;
+};
+
+struct turn_generation_io {
+	struct turn_state	*turn;
+	struct generation	*generation;
+	struct turn_projection	 projection;
+	int			 sent;
+	int			 terminal;
+	int			 failed;
+	char			 error[TURN_ERROR_MAX];
+};
+
+struct turn_generation_step {
+	struct generation	*generation;
+	struct generation_result result;
+};
+
+struct turn_result_slot {
+	void	*data;
+	size_t	 len;
+	int	 is_error;
+	int	 set;
+};
+
+struct turn_tool_results {
+	struct turn_state		*turn;
+	const struct turn_tool_batch	*batch;
+	struct turn_result_slot		 slots[GENERATION_CALL_MAX];
+	struct buf			 text;
+	struct generation_usage		 pending_usage;
+	size_t				 pending_data;
+	int				 invalid;
+	enum turn_failure		 failure;
+	char				 error[TURN_ERROR_MAX];
+};
+
+static const char turn_subagent_dangling[] =
+    "subagent tool call was truncated and was not executed";
+static const char turn_subagent_bound[] = "subagent data bound exceeded";
+static const char turn_subagent_conclude[] =
+    "You have reached the subagent tool-round bound or another subagent "
+    "execution bound. Do not call tools. "
+    "Conclude now with the most useful concise final report possible, "
+    "including uncertainty and unfinished checks.";
+
+static void
+turn_strerror(char *dst, size_t dstsz, const char *src)
+{
+	size_t len;
+
+	if (dstsz == 0)
+		return;
+	if (src == NULL)
+		src = "";
+	len = strlen(src);
+	if (len >= dstsz)
+		len = dstsz - 1;
+	if (len > 0)
+		memcpy(dst, src, len);
+	dst[len] = '\0';
+}
+
+static void
+turn_result_account(struct turn_state *turn)
+{
+	turn->result->data_used = turn->data_used;
+	turn->result->tool_batches = turn->tool_batches;
+	turn->result->usage = turn->usage;
+}
+
+static void
+turn_fail(struct turn_state *turn, enum turn_failure failure,
+    enum generation_invalid_cause invalid_cause, const char *error)
+{
+	turn->result->status = TURN_STATUS_FAILED;
+	turn->result->failure = failure;
+	turn->result->invalid_cause = invalid_cause;
+	turn->result->assistant = NULL;
+	turn->result->stop_reason[0] = '\0';
+	turn_result_account(turn);
+	turn_strerror(turn->result->error, sizeof(turn->result->error), error);
+}
+
+static void
+turn_interrupt(struct turn_state *turn)
+{
+	turn->result->status = TURN_STATUS_INTERRUPTED;
+	turn->result->failure = TURN_FAILURE_NONE;
+	turn->result->invalid_cause = GENERATION_INVALID_NONE;
+	turn->result->assistant = NULL;
+	turn->result->stop_reason[0] = '\0';
+	turn->result->error[0] = '\0';
+	turn->result->conclusion_failure = TURN_FAILURE_NONE;
+	turn->result->conclusion_invalid_cause = GENERATION_INVALID_NONE;
+	turn->result->conclusion_error[0] = '\0';
+	turn_result_account(turn);
+}
+
+static int
+turn_usage_add(struct generation_usage *total,
+    const struct generation_usage *usage)
+{
+	struct generation_usage sum = *total;
+
+#define ADD_USAGE(field) do {						\
+	if (usage->field < 0 ||						\
+	    usage->field > GENERATION_USAGE_MAX - sum.field)		\
+		return (-1);						\
+	sum.field += usage->field;					\
+} while (0)
+	ADD_USAGE(input_tokens);
+	ADD_USAGE(output_tokens);
+	ADD_USAGE(cache_read);
+	ADD_USAGE(cache_write);
+#undef ADD_USAGE
+	*total = sum;
+	return (0);
+}
+
+static int
+turn_checkpoint(struct turn_state *turn)
+{
+	enum turn_checkpoint checkpoint;
+
+	if (turn->adapter->checkpoint == NULL)
+		return (0);
+	checkpoint = turn->adapter->checkpoint(turn->adapter->arg);
+	if (checkpoint == TURN_CHECKPOINT_CONTINUE)
+		return (0);
+	if (checkpoint == TURN_CHECKPOINT_INTERRUPT) {
+		turn_interrupt(turn);
+		return (1);
+	}
+	turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+	    "invalid Turn checkpoint result");
+	return (-1);
+}
+
+int
+turn_generation_sent(struct turn_generation_io *io)
+{
+	if (io == NULL)
+		return (-1);
+	if (io->sent || io->terminal || io->failed) {
+		io->failed = 1;
+		turn_strerror(io->error, sizeof(io->error),
+		    io->sent ? "Projection marked sent twice" :
+		    "Projection marked sent after Generation events");
+		return (-1);
+	}
+	io->sent = 1;
+	if (io->turn->adapter->projection_sent != NULL)
+		io->turn->adapter->projection_sent(io->turn->adapter->arg,
+		    &io->projection);
+	return (0);
+}
+
+enum generation_feed_status
+turn_generation_feed(struct turn_generation_io *io, enum generation_kind kind,
+    const void *data, size_t len, struct generation_event *event)
+{
+	enum generation_feed_status status;
+	const char *error;
+
+	if (io == NULL || io->failed)
+		return (GENERATION_PROTOCOL);
+	if (!io->sent) {
+		io->failed = 1;
+		turn_strerror(io->error, sizeof(io->error),
+		    "Generation event before Projection was sent");
+		return (GENERATION_PROTOCOL);
+	}
+	status = generation_feed(io->generation, kind, data, len, event);
+	if (status == GENERATION_PROTOCOL) {
+		io->failed = 1;
+		error = generation_error(io->generation);
+		turn_strerror(io->error, sizeof(io->error), error != NULL ? error :
+		    "invalid Generation event");
+	} else if (status == GENERATION_TERMINAL)
+		io->terminal = 1;
+	return (status);
+}
+
+static void
+turn_tool_results_invalidate(struct turn_tool_results *results,
+    enum turn_failure failure, const char *error)
+{
+	if (results->invalid)
+		return;
+	results->invalid = 1;
+	results->failure = failure;
+	turn_strerror(results->error, sizeof(results->error), error);
+}
+
+static int
+turn_tool_results_room(struct turn_tool_results *results, size_t len)
+{
+	size_t room;
+
+	room = results->turn->options->data_limit - results->turn->data_used;
+	if (results->pending_data > room || len > room - results->pending_data) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_DATA_BOUND,
+		    "tool results exceeded the Turn data bound");
+		return (-1);
+	}
+	return (0);
+}
+
+int
+turn_tool_result_set(struct turn_tool_results *results, size_t ordinal,
+    const char *tool_use_id, const void *data, size_t len, int is_error)
+{
+	struct turn_result_slot *slot;
+	const struct turn_tool_call *call;
+
+	if (results == NULL || results->invalid)
+		return (-1);
+	if (ordinal >= results->batch->count) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS,
+		    "Tool Adapter returned an extra result");
+		return (-1);
+	}
+	slot = &results->slots[ordinal];
+	call = &results->batch->calls[ordinal];
+	if (slot->set) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS,
+		    "Tool Adapter returned a duplicate result");
+		return (-1);
+	}
+	if (tool_use_id == NULL || strcmp(tool_use_id, call->id) != 0) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS,
+		    "Tool Adapter returned a mismatched result");
+		return (-1);
+	}
+	if ((len > 0 && data == NULL) || (is_error != 0 && is_error != 1)) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS,
+		    "Tool Adapter returned an invalid result");
+		return (-1);
+	}
+	if (turn_tool_results_room(results, len) == -1)
+		return (-1);
+	if (len > 0) {
+		slot->data = xmalloc(len);
+		memcpy(slot->data, data, len);
+	}
+	slot->len = len;
+	slot->is_error = is_error;
+	slot->set = 1;
+	results->pending_data += len;
+	return (0);
+}
+
+int
+turn_tool_results_add_text(struct turn_tool_results *results,
+    const void *data, size_t len)
+{
+	if (results == NULL || results->invalid)
+		return (-1);
+	if (len > 0 && data == NULL) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS,
+		    "Tool Adapter returned invalid text");
+		return (-1);
+	}
+	if (turn_tool_results_room(results, len) == -1)
+		return (-1);
+	if (len > 0)
+		buf_add(&results->text, data, len);
+	results->pending_data += len;
+	return (0);
+}
+
+int
+turn_tool_results_add_usage(struct turn_tool_results *results,
+    const struct generation_usage *usage)
+{
+	struct generation_usage pending, total;
+
+	if (results == NULL || results->invalid || usage == NULL)
+		return (-1);
+	pending = results->pending_usage;
+	total = results->turn->usage;
+	if (turn_usage_add(&pending, usage) == -1 ||
+	    turn_usage_add(&total, &pending) == -1) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_USAGE_BOUND,
+		    "nested Provider usage exceeded the Turn usage bound");
+		return (-1);
+	}
+	results->pending_usage = pending;
+	return (0);
+}
+
+static void
+turn_tool_results_account_usage(struct turn_tool_results *results)
+{
+	/* add_usage() already checked this sum against the current aggregate. */
+	if (turn_usage_add(&results->turn->usage,
+	    &results->pending_usage) == -1) {
+		turn_tool_results_invalidate(results, TURN_FAILURE_USAGE_BOUND,
+		    "nested Provider usage exceeded the Turn usage bound");
+		return;
+	}
+	memset(&results->pending_usage, 0, sizeof(results->pending_usage));
+	turn_result_account(results->turn);
+}
+
+static void
+turn_generation_dispose(struct turn_generation_step *step)
+{
+	if (step->result.assistant != NULL)
+		msg_free(step->result.assistant);
+	generation_free(step->generation);
+	memset(step, 0, sizeof(*step));
+}
+
+static int
+turn_generate(struct turn_state *turn, enum turn_tools_mode tools,
+    struct turn_generation_step *step)
+{
+	struct turn_generation_request request;
+	struct turn_generation_io io;
+	char error[TURN_ERROR_MAX];
+	enum turn_io status;
+	int data_failed, usage_failed;
+
+	memset(step, 0, sizeof(*step));
+	memset(&request, 0, sizeof(request));
+	turn->adapter->projection(turn->adapter->arg, &request.projection);
+	request.tools = tools;
+	memset(&io, 0, sizeof(io));
+	io.turn = turn;
+	io.projection = request.projection;
+	io.generation = generation_new(turn->options->data_limit -
+	    turn->data_used);
+	step->generation = io.generation;
+	error[0] = '\0';
+	status = turn->adapter->generate(turn->adapter->arg, &request, &io,
+	    error, sizeof(error));
+	error[sizeof(error) - 1] = '\0';
+	if (status == TURN_IO_INTERRUPTED) {
+		turn_interrupt(turn);
+		return (-1);
+	}
+	if (status != TURN_IO_OK || io.failed || !io.sent || !io.terminal) {
+		if (turn_checkpoint(turn) != 0)
+			return (-1);
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    io.error[0] != '\0' ? io.error : error[0] != '\0' ? error :
+		    status == TURN_IO_ERROR ? "Generation Adapter failed" :
+		    "Generation Adapter returned before terminal");
+		return (-1);
+	}
+	if (generation_take(step->generation, &step->result) == -1) {
+		if (turn_checkpoint(turn) != 0)
+			return (-1);
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    "could not take terminal Generation");
+		return (-1);
+	}
+	data_failed = step->result.data_used > turn->options->data_limit -
+	    turn->data_used;
+	if (!data_failed)
+		turn->data_used += step->result.data_used;
+	usage_failed = turn_usage_add(&turn->usage, &step->result.usage) == -1;
+	if (turn_checkpoint(turn) != 0)
+		return (-1);
+	if (data_failed) {
+		turn_fail(turn, TURN_FAILURE_DATA_BOUND,
+		    GENERATION_INVALID_DATA_BOUND,
+		    "Generation exceeded the Turn data bound");
+		return (-1);
+	}
+	if (usage_failed) {
+		turn_fail(turn, TURN_FAILURE_USAGE_BOUND,
+		    GENERATION_INVALID_USAGE_BOUND,
+		    "Generation exceeded the Turn usage bound");
+		return (-1);
+	}
+	if (step->result.outcome == GENERATION_PROVIDER_ERROR) {
+		turn_fail(turn, TURN_FAILURE_PROVIDER, GENERATION_INVALID_NONE,
+		    step->result.reason);
+		return (-1);
+	}
+	if (step->result.outcome == GENERATION_INVALID) {
+		turn_fail(turn, TURN_FAILURE_GENERATION,
+		    step->result.invalid_cause, step->result.reason);
+		return (-1);
+	}
+	if (step->result.outcome != GENERATION_NORMAL &&
+	    step->result.outcome != GENERATION_LENGTH) {
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    "Generation returned an unknown outcome");
+		return (-1);
+	}
+	return (0);
+}
+
+static int
+turn_accept_assistant(struct turn_state *turn,
+    struct turn_generation_step *step)
+{
+	struct turn_assistant_event event;
+
+	if (turn_checkpoint(turn) != 0)
+		return (-1);
+	memset(&event, 0, sizeof(event));
+	event.message = step->result.assistant;
+	event.outcome = step->result.outcome;
+	event.stop_reason = step->result.reason;
+	event.synthesized_text = step->result.synthesized_text;
+	event.tool_calls = step->result.tool_calls;
+	turn->adapter->accept(turn->adapter->arg, step->result.assistant);
+	turn->result->assistant = step->result.assistant;
+	turn_strerror(turn->result->stop_reason,
+	    sizeof(turn->result->stop_reason), step->result.reason);
+	step->result.assistant = NULL;
+	if (turn->adapter->assistant_accepted != NULL)
+		turn->adapter->assistant_accepted(turn->adapter->arg, &event);
+	if (turn_checkpoint(turn) != 0)
+		return (-1);
+	return (0);
+}
+
+static int
+turn_batch_build(const struct msg *assistant, size_t expected,
+    struct turn_tool_batch *batch, struct turn_tool_call *calls)
+{
+	struct block *block;
+	size_t count = 0;
+
+	TAILQ_FOREACH(block, &assistant->blocks, entry) {
+		if (block->type != BLOCK_TOOL_USE)
+			continue;
+		if (count >= GENERATION_CALL_MAX)
+			return (-1);
+		calls[count].id = block->tool_id;
+		calls[count].name = block->tool_name;
+		calls[count].input = block->tool_input;
+		calls[count].input_len = block->tool_input_len;
+		count++;
+	}
+	if (count != expected || count == 0)
+		return (-1);
+	memset(batch, 0, sizeof(*batch));
+	batch->assistant = assistant;
+	batch->calls = calls;
+	batch->count = count;
+	return (0);
+}
+
+static void
+turn_tool_results_free(struct turn_tool_results *results)
+{
+	size_t i;
+
+	for (i = 0; i < results->batch->count; i++)
+		free(results->slots[i].data);
+	buf_free(&results->text);
+}
+
+static int
+turn_execute(struct turn_state *turn, const struct msg *assistant,
+    size_t expected, int conclude)
+{
+	struct turn_tool_call calls[GENERATION_CALL_MAX];
+	struct turn_tool_batch batch;
+	struct turn_tool_results results;
+	struct msg *message;
+	char error[TURN_ERROR_MAX];
+	enum turn_io status;
+	size_t i;
+
+	memset(calls, 0, sizeof(calls));
+	if (turn_batch_build(assistant, expected, &batch, calls) == -1) {
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    "accepted assistant has inconsistent tool calls");
+		return (-1);
+	}
+	if (turn->adapter->execute == NULL) {
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    "Turn has tool calls but no Tool Adapter");
+		return (-1);
+	}
+	memset(&results, 0, sizeof(results));
+	results.turn = turn;
+	results.batch = &batch;
+	results.failure = TURN_FAILURE_TOOL_RESULTS;
+	buf_init(&results.text);
+	error[0] = '\0';
+	status = turn->adapter->execute(turn->adapter->arg, &batch, &results,
+	    error, sizeof(error));
+	error[sizeof(error) - 1] = '\0';
+	turn_tool_results_account_usage(&results);
+	if (status == TURN_IO_INTERRUPTED) {
+		turn_interrupt(turn);
+		turn_tool_results_free(&results);
+		return (-1);
+	}
+	if (turn_checkpoint(turn) != 0) {
+		turn_tool_results_free(&results);
+		return (-1);
+	}
+	if (results.invalid) {
+		turn_fail(turn, results.failure, GENERATION_INVALID_NONE,
+		    results.error);
+		turn_tool_results_free(&results);
+		return (-1);
+	}
+	if (status != TURN_IO_OK) {
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    error[0] != '\0' ? error : "Tool Adapter failed");
+		turn_tool_results_free(&results);
+		return (-1);
+	}
+	for (i = 0; i < batch.count; i++) {
+		if (!results.slots[i].set) {
+			turn_fail(turn, TURN_FAILURE_TOOL_RESULTS,
+			    GENERATION_INVALID_NONE,
+			    "Tool Adapter omitted a result");
+			turn_tool_results_free(&results);
+			return (-1);
+		}
+	}
+	if (turn_checkpoint(turn) != 0) {
+		turn_tool_results_free(&results);
+		return (-1);
+	}
+	message = msg_new(ROLE_USER);
+	for (i = 0; i < batch.count; i++)
+		msg_add_tool_result(message, batch.calls[i].id,
+		    results.slots[i].data, results.slots[i].len,
+		    results.slots[i].is_error);
+	if (results.text.len > 0)
+		msg_add_text(message, (const char *)results.text.data,
+		    results.text.len);
+	/* Fixed Module guidance is outside the hostile-data bound. */
+	if (conclude)
+		msg_add_text(message, turn_subagent_conclude,
+		    sizeof(turn_subagent_conclude) - 1);
+	turn->adapter->accept(turn->adapter->arg, message);
+	turn->data_used += results.pending_data;
+	turn->tool_batches++;
+	turn_result_account(turn);
+	turn_tool_results_free(&results);
+	if (turn_checkpoint(turn) != 0)
+		return (-1);
+	return (0);
+}
+
+static int
+turn_accept_dangling(struct turn_state *turn, const struct msg *assistant,
+    size_t expected)
+{
+	struct turn_tool_call calls[GENERATION_CALL_MAX];
+	struct turn_tool_batch batch;
+	struct msg *message;
+	const char *text[GENERATION_CALL_MAX];
+	size_t len[GENERATION_CALL_MAX];
+	size_t i, data_used = 0, reason_len, bound_len, room;
+
+	memset(calls, 0, sizeof(calls));
+	memset(&batch, 0, sizeof(batch));
+	if (expected > 0 &&
+	    turn_batch_build(assistant, expected, &batch, calls) == -1) {
+		turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE,
+		    "accepted assistant has inconsistent dangling tool calls");
+		return (-1);
+	}
+	reason_len = sizeof(turn_subagent_dangling) - 1;
+	bound_len = sizeof(turn_subagent_bound) - 1;
+	room = turn->options->data_limit - turn->data_used;
+	for (i = 0; i < expected; i++) {
+		if (reason_len <= room) {
+			text[i] = turn_subagent_dangling;
+			len[i] = reason_len;
+		} else {
+			text[i] = turn_subagent_bound;
+			len[i] = bound_len < room ? bound_len : room;
+		}
+		room -= len[i];
+		data_used += len[i];
+	}
+	if (turn_checkpoint(turn) != 0)
+		return (-1);
+	message = msg_new(ROLE_USER);
+	for (i = 0; i < expected; i++)
+		msg_add_tool_result(message, batch.calls[i].id,
+		    text[i], len[i], 1);
+	/* Fixed Module guidance is outside the hostile-data bound. */
+	msg_add_text(message, turn_subagent_conclude,
+	    sizeof(turn_subagent_conclude) - 1);
+	turn->adapter->accept(turn->adapter->arg, message);
+	turn->data_used += data_used;
+	turn_result_account(turn);
+	if (turn_checkpoint(turn) != 0)
+		return (-1);
+	return (0);
+}
+
+static int
+turn_conclude(struct turn_state *turn)
+{
+	struct turn_generation_step step;
+	const struct msg *fallback;
+	char stop_reason[GENERATION_STOP_MAX];
+
+	fallback = turn->result->assistant;
+	turn_strerror(stop_reason, sizeof(stop_reason),
+	    turn->result->stop_reason);
+
+	if (turn_generate(turn, TURN_TOOLS_DISABLED, &step) == -1) {
+		if (turn->result->status != TURN_STATUS_INTERRUPTED)
+			goto failed;
+		turn_generation_dispose(&step);
+		return (-1);
+	}
+	if (step.result.tool_calls != 0) {
+		turn_fail(turn, TURN_FAILURE_GENERATION,
+		    GENERATION_INVALID_NONE,
+		    "tools-disabled conclusion returned a tool call");
+		goto failed;
+	}
+	if (turn_accept_assistant(turn, &step) == -1) {
+		turn_generation_dispose(&step);
+		return (-1);
+	}
+	turn_generation_dispose(&step);
+	turn->result->status = TURN_STATUS_CONCLUDED;
+	turn->result->failure = TURN_FAILURE_NONE;
+	turn_result_account(turn);
+	return (0);
+
+failed:
+	turn->result->conclusion_failure = turn->result->failure;
+	turn->result->conclusion_invalid_cause = turn->result->invalid_cause;
+	turn_strerror(turn->result->conclusion_error,
+	    sizeof(turn->result->conclusion_error), turn->result->error);
+	turn->result->status = TURN_STATUS_CONCLUDED;
+	turn->result->failure = TURN_FAILURE_NONE;
+	turn->result->invalid_cause = GENERATION_INVALID_NONE;
+	turn->result->assistant = fallback;
+	turn_strerror(turn->result->stop_reason,
+	    sizeof(turn->result->stop_reason), stop_reason);
+	turn->result->error[0] = '\0';
+	turn_result_account(turn);
+	turn_generation_dispose(&step);
+	return (0);
+}
+
+int
+turn_run(const struct turn_adapter *adapter, const struct turn_options *options,
+    struct turn_result *result)
+{
+	struct turn_state turn;
+	struct turn_generation_step step;
+	const struct msg *assistant;
+	size_t tool_calls;
+	enum generation_outcome generation_outcome;
+	int conclude;
+
+	if (adapter == NULL || options == NULL || result == NULL ||
+	    adapter->projection == NULL || adapter->accept == NULL ||
+	    adapter->generate == NULL || options->data_used > options->data_limit ||
+	    options->tool_batch_limit == 0 ||
+	    (options->policy != TURN_POLICY_LEAD &&
+	    options->policy != TURN_POLICY_SUBAGENT))
+		return (-1);
+	memset(result, 0, sizeof(*result));
+	memset(&turn, 0, sizeof(turn));
+	turn.adapter = adapter;
+	turn.options = options;
+	turn.result = result;
+	turn.data_used = options->data_used;
+	if (turn_checkpoint(&turn) != 0)
+		return (0);
+
+	for (;;) {
+		if (turn_generate(&turn, TURN_TOOLS_ENABLED, &step) == -1) {
+			turn_generation_dispose(&step);
+			return (0);
+		}
+		tool_calls = step.result.tool_calls;
+		generation_outcome = step.result.outcome;
+		if (turn_accept_assistant(&turn, &step) == -1) {
+			turn_generation_dispose(&step);
+			return (0);
+		}
+		assistant = result->assistant;
+		turn_generation_dispose(&step);
+		if (options->policy == TURN_POLICY_SUBAGENT &&
+		    generation_outcome == GENERATION_LENGTH) {
+			if (turn_accept_dangling(&turn, assistant, tool_calls) == -1)
+				return (0);
+			(void)turn_conclude(&turn);
+			return (0);
+		}
+		if (tool_calls == 0) {
+			result->status = TURN_STATUS_DONE;
+			result->failure = TURN_FAILURE_NONE;
+			turn_result_account(&turn);
+			return (0);
+		}
+		conclude = options->policy == TURN_POLICY_SUBAGENT &&
+		    turn.tool_batches + 1 >= options->tool_batch_limit;
+		if (turn_execute(&turn, assistant, tool_calls, conclude) == -1)
+			return (0);
+		if (turn.tool_batches >= options->tool_batch_limit) {
+			if (options->policy == TURN_POLICY_SUBAGENT) {
+				(void)turn_conclude(&turn);
+				return (0);
+			}
+			result->status = TURN_STATUS_CAP;
+			result->failure = TURN_FAILURE_NONE;
+			turn_result_account(&turn);
+			return (0);
+		}
+	}
+}
blob - /dev/null
blob + 78385af229953d731d1d02cccf6ca08726f3513f (mode 644)
--- /dev/null
+++ src/common/turn_mechanics.h
@@ -0,0 +1,199 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef TURN_MECHANICS_H
+#define TURN_MECHANICS_H
+
+#include <sys/types.h>
+
+#include <stdint.h>
+
+#include "generation.h"
+
+struct msg;
+struct msglist;
+struct turn_generation_io;
+struct turn_tool_results;
+
+#define TURN_ERROR_MAX	GENERATION_ERROR_MAX
+
+enum turn_policy_kind {
+	TURN_POLICY_LEAD,
+	TURN_POLICY_SUBAGENT
+};
+
+enum turn_tools_mode {
+	TURN_TOOLS_ENABLED,
+	TURN_TOOLS_DISABLED
+};
+
+enum turn_io {
+	TURN_IO_OK,
+	TURN_IO_ERROR,
+	TURN_IO_INTERRUPTED
+};
+
+enum turn_checkpoint {
+	TURN_CHECKPOINT_CONTINUE,
+	TURN_CHECKPOINT_INTERRUPT
+};
+
+enum turn_status {
+	TURN_STATUS_DONE,
+	TURN_STATUS_CAP,
+	TURN_STATUS_CONCLUDED,
+	TURN_STATUS_FAILED,
+	TURN_STATUS_INTERRUPTED
+};
+
+enum turn_failure {
+	TURN_FAILURE_NONE,
+	TURN_FAILURE_ADAPTER,
+	TURN_FAILURE_PROVIDER,
+	TURN_FAILURE_GENERATION,
+	TURN_FAILURE_TOOL_RESULTS,
+	TURN_FAILURE_DATA_BOUND,
+	TURN_FAILURE_USAGE_BOUND
+};
+
+/*
+ * A borrowed Projection remains valid for one generate callback.  receipt
+ * identifies that exact view; it advances only when the Adapter marks the
+ * complete request sent through turn_generation_sent().
+ */
+struct turn_projection {
+	const struct msglist	*messages;
+	size_t			 message_count;
+	int			 cache_tail_msg;
+	uint64_t		 receipt;
+};
+
+/*
+ * data_limit bounds hostile Turn bytes: Adapter-supplied initial data,
+ * Provider assistant text/tool input, and emitted tool result/error or added
+ * text, including synthesized dangling errors.  Fixed conclusion guidance is
+ * excluded, as are system prompts and tool definitions.  options.data_used
+ * supplies hostile bytes accepted before turn_run().
+ */
+struct turn_options {
+	enum turn_policy_kind	 policy;
+	size_t			 data_limit;
+	size_t			 data_used;
+	size_t			 tool_batch_limit; /* paired batches, nonzero */
+};
+
+struct turn_generation_request {
+	struct turn_projection	 projection;
+	enum turn_tools_mode	 tools;
+};
+
+/* Every value is borrowed from the accepted assistant for execute(). */
+struct turn_tool_call {
+	const char	*id;
+	const char	*name;
+	const void	*input;
+	size_t		 input_len;
+};
+
+struct turn_tool_batch {
+	const struct msg		*assistant;
+	const struct turn_tool_call *calls;
+	size_t			 count;
+};
+
+/*
+ * Borrowed for assistant_accepted(), after ownership transfer and before the
+ * post-accept checkpoint.  synthesized_text aliases message storage.
+ */
+struct turn_assistant_event {
+	const struct msg		*message;
+	enum generation_outcome outcome;
+	const char		*stop_reason;
+	struct generation_span	 synthesized_text;
+	size_t			 tool_calls;
+};
+
+struct turn_result {
+	enum turn_status		 status;
+	enum turn_failure	 failure;
+	enum generation_invalid_cause invalid_cause;
+	enum turn_failure	 conclusion_failure;
+	enum generation_invalid_cause conclusion_invalid_cause;
+	/* Completed Generations plus nested usage reported by execute(). */
+	struct generation_usage usage;
+	size_t			 data_used;
+	size_t			 tool_batches;
+	const struct msg		*assistant;
+	char			 stop_reason[GENERATION_STOP_MAX];
+	char			 error[TURN_ERROR_MAX];
+	char			 conclusion_error[TURN_ERROR_MAX];
+};
+
+/*
+ * generate() and execute() are blocking Adapter calls.  Their io/result
+ * handles are borrowed only for the duration of the callback.  accept()
+ * takes ownership of its message.  checkpoint() may be NULL.  generate()
+ * marks a completely queued Projection exactly once, then feeds through a
+ * terminal event before returning OK.  execute() sets exactly one matching
+ * result for every borrowed call ordinal; it may fill ordinals in any order.
+ * The first checkpoint precedes the first Projection; further checkpoints
+ * surround blocking callbacks and accepted messages.
+ */
+struct turn_adapter {
+	void	*arg;
+	void	(*projection)(void *, struct turn_projection *);
+	void	(*projection_sent)(void *, const struct turn_projection *);
+	void	(*accept)(void *, struct msg *);
+	void	(*assistant_accepted)(void *, const struct turn_assistant_event *);
+	enum turn_checkpoint (*checkpoint)(void *);
+	enum turn_io (*generate)(void *,
+	    const struct turn_generation_request *, struct turn_generation_io *,
+	    char *, size_t);
+	enum turn_io (*execute)(void *, const struct turn_tool_batch *,
+	    struct turn_tool_results *, char *, size_t);
+};
+
+/*
+ * Returns -1 only for an invalid Interface/configuration.  Otherwise the
+ * tagged result is authoritative.  assistant is borrowed from Adapter-owned
+ * Projection storage and is NULL on FAILED/INTERRUPTED.  data_used includes
+ * options.data_used plus hostile bytes accepted by completed collectors, even
+ * when cancellation prevents Projection acceptance.  A forced Subagent
+ * conclusion remains CONCLUDED when its single final Generation fails;
+ * conclusion_* describes that fallback.
+ */
+int	turn_run(const struct turn_adapter *, const struct turn_options *,
+	    struct turn_result *);
+
+int	turn_generation_sent(struct turn_generation_io *);
+enum generation_feed_status turn_generation_feed(struct turn_generation_io *,
+	    enum generation_kind, const void *, size_t, struct generation_event *);
+
+/* Result bytes and added text are copied; the matching id is NUL-terminated. */
+int	turn_tool_result_set(struct turn_tool_results *, size_t, const char *,
+	    const void *, size_t, int);
+int	turn_tool_results_add_text(struct turn_tool_results *, const void *,
+	    size_t);
+
+/*
+ * Adds nested Provider usage observed during execute().  Accepted usage is
+ * included in the final Turn aggregate even if execute later fails or is
+ * interrupted.
+ */
+int	turn_tool_results_add_usage(struct turn_tool_results *,
+	    const struct generation_usage *);
+
+#endif /* TURN_MECHANICS_H */