Commit Diff


commit - dcf6fb79009f4db25858a81e627d201087852dfd
commit + 0716373ef1fefafca2d72d1ba8a4be8189504a0b
blob - 4cb427fd13a4a04ec24f3c601d4fa615d91a5669
blob + 558868ffb6292c86f70e4ad0fa0c6184be41d09c
--- handoff/behavior.md
+++ handoff/behavior.md
@@ -223,14 +223,15 @@ Queue and Steer handling crosses five distinct boundar
 
 One ordinary entry completes its whole Turn, including every
 Generation and Tool, before another `UI_END` can remove the next
-entry. Complete Steers received before the same safe Tool-result
-boundary are combined in Owner-selection order and appended as text
-to that boundary's one canonical User message before the next
-Provider dispatch. If a successful final Generation leaves no such
-boundary, a complete Steer already received by the coordinator at
-the commit gate starts the immediate next Turn before `UI_END` can
-dispatch ordinary Queue work. A failed or abandoned Turn discards
-both its delivered and buffered Steers.
+entry. Complete Steers accepted within the existing aggregate input
+bound before the same safe Tool-result boundary are combined in
+Owner-selection order and appended as text to that boundary's one
+canonical User message before the next Provider dispatch; an entry
+that would exceed the bound is dropped with a notice. If a successful
+final Generation leaves no such boundary, a complete Steer already
+received by the coordinator at the commit gate starts the immediate
+next Turn before `UI_END` can dispatch ordinary Queue work. A failed
+or abandoned Turn discards both its delivered and buffered Steers.
 
 Ordinary Queue entries may remain after an ordinary Turn error while
 the same front end survives. Queue state is not durable: quit, crash,
@@ -672,7 +673,7 @@ its semantics are pinned:
   crash, and session-switch re-exec do not preserve it.
 - Print-mode turns (`-p`) append under the same rules; `fugu -r id
   -p "..."` continues a saved session headlessly.
-- A turn ended by the iteration cap (§5) is a *completed* turn: its
+- A turn ended by the Tool-batch cap (§5) is a *completed* turn: its
   exchanges up to the cap are journaled context and survive resume;
   the cap error ends the turn, it does not unwind it. Failure and
   abandonment (which do unwind) are the only discarding ends.
blob - bf39c9d0a7be7fbc92d4ba48153547a3a3ad1e03
blob + 5f9df1209a5f60dd4c83b60661ca45f545ced8d7
--- handoff/verification.md
+++ handoff/verification.md
@@ -132,16 +132,18 @@ states the property, not just examples:
   acceptance, Provider dispatch, and Turn commit or abandonment. It
   asserts one ordered canonical User message for multiple Steers at a
   Tool boundary and exact committed replay; one FIFO removal per
-  `UI_END`, including a first queued Turn that continues through a
-  Tool; interleaved Steer priority without FIFO reordering; and a
-  final-response Steer running before ordinary Queue work. Provider
-  failure, cancellation, session-switch re-exec, and resume fixtures
-  assert that delivered or buffered Steers do not leak and that
-  undispatched Queue text reaches neither a Provider request nor a
-  committed Journal Projection. `regress/turn_mechanics` sets a
-  two-batch limit, adds one and several Steers to the first result
-  batch, and asserts `TURN_STATUS_CAP` after exactly two batches with
-  no allowance reset.
+  `UI_END` when the Queue is nonempty, including a first queued Turn
+  that continues through a Tool; and interleaved Steer priority
+  without FIFO reordering. It also proves that a complete
+  final-response Steer received by the coordinator at the commit gate
+  runs before ordinary Queue work. Provider-failure, cancellation,
+  session-switch re-exec, and resume fixtures assert that Steers
+  associated with the abandoned Turn reach neither a later Projection
+  nor resume. Undispatched Queue text reaches no Provider request or
+  Journal record in those loss paths and is absent from the resumed
+  Projection. `regress/turn_mechanics` sets a two-batch limit, adds one
+  and several Steers to the first result batch, and asserts
+  `TURN_STATUS_CAP` after exactly two batches with no allowance reset.
 - **Freshness guard:** modifying a file between `read` and
   `edit`/`write`/`multi_edit` yields the re-read refusal; the guard
   resets across resume.
blob - 7da7e7c0e2d7f00481bb03c2be583418686d49d7
blob + 262a0930c5ca8ea077a200efe4dcabb185a30b21
--- regress/turn/reqcheck.c
+++ regress/turn/reqcheck.c
@@ -101,6 +101,27 @@ message_role_is(const struct json *j, int msg, const c
 }
 
 static int
+sleep_tool_message_is(const struct json *j, int msg)
+{
+	int	 block, content, input;
+
+	if (!message_role_is(j, msg, "assistant") ||
+	    (content = json_obj_get(j, msg, "content")) == -1 ||
+	    !json_is_array(j, content) || json_arr_len(j, content) != 1 ||
+	    (block = json_arr_get(j, content, 0)) == -1 ||
+	    !json_is_object(j, block) ||
+	    !string_is(j, json_obj_get(j, block, "type"), "tool_use") ||
+	    !string_is(j, json_obj_get(j, block, "id"), "toolu_zz") ||
+	    !string_is(j, json_obj_get(j, block, "name"), "shell") ||
+	    (input = json_obj_get(j, block, "input")) == -1 ||
+	    !json_is_object(j, input) ||
+	    !string_is(j, json_obj_get(j, input, "command"),
+	    "sleep 2; echo slept-ok"))
+		return (0);
+	return (1);
+}
+
+static int
 tool_text_message_is(const struct json *j, int msg, const char *tool_id,
     const char *text)
 {
@@ -113,6 +134,7 @@ tool_text_message_is(const struct json *j, int msg, co
 	    !json_is_object(j, block) ||
 	    !string_is(j, json_obj_get(j, block, "type"), "tool_result") ||
 	    !string_is(j, json_obj_get(j, block, "tool_use_id"), tool_id) ||
+	    !string_is(j, json_obj_get(j, block, "content"), "slept-ok\n") ||
 	    (block = json_arr_get(j, content, 1)) == -1 ||
 	    !json_is_object(j, block) ||
 	    !string_is(j, json_obj_get(j, block, "type"), "text") ||
@@ -148,7 +170,7 @@ tool_continuation_is(const struct json *j, int message
 	n = json_arr_len(j, messages);
 	if (n < 3 || !message_is(j, json_arr_get(j, messages, n - 3),
 	    "user", prompt) ||
-	    !message_role_is(j, json_arr_get(j, messages, n - 2), "assistant"))
+	    !sleep_tool_message_is(j, json_arr_get(j, messages, n - 2)))
 		return (0);
 	last = json_arr_get(j, messages, n - 1);
 	if (!message_role_is(j, last, "user") ||
@@ -157,7 +179,8 @@ tool_continuation_is(const struct json *j, int message
 	    (block = json_arr_get(j, content, 0)) == -1 ||
 	    !json_is_object(j, block) ||
 	    !string_is(j, json_obj_get(j, block, "type"), "tool_result") ||
-	    !string_is(j, json_obj_get(j, block, "tool_use_id"), "toolu_zz"))
+	    !string_is(j, json_obj_get(j, block, "tool_use_id"), "toolu_zz") ||
+	    !string_is(j, json_obj_get(j, block, "content"), "slept-ok\n"))
 		return (0);
 	return (1);
 }
@@ -305,8 +328,8 @@ main(int argc, char *argv[])
 		if (json_arr_len(&j, messages) != 3 ||
 		    !message_is(&j, json_arr_get(&j, messages, 0), "user",
 		    "ACTIVE-TURN") ||
-		    !message_role_is(&j, json_arr_get(&j, messages, 1),
-		    "assistant") ||
+		    !sleep_tool_message_is(&j,
+		    json_arr_get(&j, messages, 1)) ||
 		    !steer_batch_message_is(&j,
 		    json_arr_get(&j, messages, 2)) ||
 		    steer_batch_count(&j, messages) != 1)
@@ -317,6 +340,8 @@ main(int argc, char *argv[])
 	}
 	if (steer_replay) {
 		if (json_arr_len(&j, messages) != 11 ||
+		    !sleep_tool_message_is(&j,
+		    json_arr_get(&j, messages, 1)) ||
 		    !steer_batch_message_is(&j,
 		    json_arr_get(&j, messages, 2)) ||
 		    steer_batch_count(&j, messages) != 1 ||
blob - e61c8ce18bbf46cb70e92ec7ef44b4bc5bb7d0fc
blob + 3aafeed64c4a0d64a3cfe44ea0bac160be1d9bed
--- regress/turn_mechanics/turn_mechanics_test.c
+++ regress/turn_mechanics/turn_mechanics_test.c
@@ -976,9 +976,11 @@ test_lead_steers_do_not_reset_tool_batch_limit(void)
 	static const struct {
 		const char	*text;
 		size_t		 len;
+		size_t		 round;
 	} cases[] = {
-		{ "steer-a", sizeof("steer-a") - 1 },
-		{ "steer-a\n\nsteer-b", sizeof("steer-a\n\nsteer-b") - 1 }
+		{ "steer-a", sizeof("steer-a") - 1, 0 },
+		{ "steer-a\n\nsteer-b", sizeof("steer-a\n\nsteer-b") - 1, 0 },
+		{ "steer-b", sizeof("steer-b") - 1, 1 }
 	};
 	struct fixture	 f;
 	struct turn_adapter adapter;
@@ -986,15 +988,16 @@ test_lead_steers_do_not_reset_tool_batch_limit(void)
 	struct turn_result result;
 	struct msg	*message;
 	struct block	*block;
-	size_t		 i;
+	const char	*other_id, *text_id;
+	size_t		 i, other_message_index, text_message_index;
 	int		 rc;
 
 	for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
 		fixture_init(&f, &adapter, "q");
 		script_tool_round(&f, "round-a");
-		f.tools[0].text = cases[i].text;
-		f.tools[0].text_len = cases[i].len;
 		script_tool_round(&f, "round-b");
+		f.tools[cases[i].round].text = cases[i].text;
+		f.tools[cases[i].round].text_len = cases[i].len;
 		options_init(&options, TURN_POLICY_LEAD, 1, 2);
 		rc = turn_run(&adapter, &options, &result);
 
@@ -1008,22 +1011,26 @@ test_lead_steers_do_not_reset_tool_batch_limit(void)
 		    f.sent_callbacks == 2 && message_count(&f.accepted) == 5 &&
 		    f.projection_count[0] == 1 && f.projection_count[1] == 3 &&
 		    f.add_text_rc == 0);
-		message = message_at(&f.accepted, 2);
+		text_message_index = cases[i].round == 0 ? 2 : 4;
+		other_message_index = cases[i].round == 0 ? 4 : 2;
+		text_id = cases[i].round == 0 ? "round-a" : "round-b";
+		other_id = cases[i].round == 0 ? "round-b" : "round-a";
+		message = message_at(&f.accepted, text_message_index);
 		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-a") == 0 &&
+		    strcmp(block->tool_use_id, text_id) == 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 &&
 		    block->textlen == cases[i].len &&
 		    memcmp(block->text, cases[i].text, cases[i].len) == 0 &&
 		    TAILQ_NEXT(block, entry) == NULL);
-		message = message_at(&f.accepted, 4);
+		message = message_at(&f.accepted, other_message_index);
 		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 &&
+		    strcmp(block->tool_use_id, other_id) == 0 &&
 		    block->result_len == 2 && memcmp(block->result, "ok", 2) == 0 &&
 		    TAILQ_NEXT(block, entry) == NULL);
 		msglist_free(&f.accepted);