commit 0716373ef1fefafca2d72d1ba8a4be8189504a0b from: Isaac Meerleo date: Fri Jul 17 20:52:08 2026 UTC Tighten prompt admission regressions 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);