commit 70b68b95fb01cb2b260ca9a92849088b4ee34fb2 from: Isaac Meerleo date: Wed Jul 15 13:13:33 2026 UTC fix cache tail advancement commit - 98843d2184110b08bc0ab8759ba7aef81f32b600 commit + 70b68b95fb01cb2b260ca9a92849088b4ee34fb2 blob - be40ddf2b0388e1e6dfa6ef65deb57d30eabe0da blob + d397b20feb0f182630b200d95785c71f75775960 --- regress/README +++ regress/README @@ -108,7 +108,10 @@ config precedence (behavior.md 6) conf Behavior Suite(s) -------- -------- provider codec: anthropic anthropic (builder + stream, - prompt-caching breakpoints) + prompt-caching breakpoints); turn + (cache tail advances only from the + Projection actually sent, including + after a committed cap) provider codec: openai openai (builder, adversarial stream, Bearer-auth full worker mesh) tools (behavior.md 5) agentcfg (agent schema and routing); blob - add9be71c01d35d58d42460a4dc06bc27aa4fb9c blob + 8e670dea289657fa382a40a2f9c6d272da945fd6 --- regress/turn/Makefile +++ regress/turn/Makefile @@ -6,12 +6,22 @@ LDADD= -ltls DPADD= ${LIBTLS} REGRESS_TARGETS= run-turn run-agent +CLEANFILES+= reqcheck +REQCHECK_SRCS= ${.CURDIR}/reqcheck.c \ + ${.CURDIR}/../../src/common/json.c \ + ${.CURDIR}/../../src/common/buf.c \ + ${.CURDIR}/../../src/common/log.c \ + ${.CURDIR}/../../src/common/xmalloc.c + +reqcheck: ${REQCHECK_SRCS} + ${CC} ${CFLAGS} -o ${.OBJDIR}/reqcheck ${REQCHECK_SRCS} + # a pty driver to exercise the curses front end end to end (2.1) ptydrive: ${.CURDIR}/ptydrive.c ${CC} ${CFLAGS} -o ${.OBJDIR}/ptydrive ${.CURDIR}/ptydrive.c -lutil -run-turn: stub ptydrive +run-turn: stub ptydrive reqcheck sh ${.CURDIR}/run.sh ${.OBJDIR} ${.CURDIR}/../.. run-agent: stub blob - af472ed1cc518e7f12dd4011291cb0df807503d3 blob + 735c7ebf5464536931e048b8cef4887693489da4 --- regress/turn/run.sh +++ regress/turn/run.sh @@ -409,8 +409,37 @@ n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/d ok "line mode: both turns committed to one journal" $([ "$n" = 2 ]; echo $?) c=$(ls "$sessions"/*.ndjson 2>/dev/null | wc -l) ok "line mode: one session for the whole loop" $([ "$c" -eq 1 ]; echo $?) +# The second request must advance from the Projection actually sent in the +# first request. The checker parses the Provider-visible request: the first +# user message was in that Projection; the first assistant reply was not. +"$obj/reqcheck" "$dir/reqout" +ok "line mode: cache tail advances from the previous request projection" $? stop_stub +# A committed cap also leaves the cache breakpoint at the last Projection +# actually sent. The generated call and result that close the capped Turn +# were not in that Projection, so the following Turn must not advance over +# them. Restoring the post-cap prev_msgs assignment calibrates this check: +# it reports message 100 instead of 98. +rm -rf "$HOME/.fugu" +scenarios='' +i=0 +while [ "$i" -lt 50 ]; do + scenarios="$scenarios TOOLUSE" + i=$((i + 1)) +done +start_stub "$scenarios OK" +write_conf "$stubport" +printf 'loop forever\nafter cap\n/quit\n' > "$dir/lin" +run_fugu_in "$dir/lin" +ok "cache tail after cap: line session continues" \ + $([ "$rc" -eq 0 ]; echo $?) +grep -q '"outcome":"cap"' "$sessions"/*.ndjson 2>/dev/null +ok "cache tail after cap: first Turn committed at the limit" $? +"$obj/reqcheck" "$dir/reqout" 102 98 +ok "cache tail after cap: advances from the last sent Projection" $? +stop_stub + # --- 16: line-mode slash commands ----------------------------------- rm -rf "$HOME/.fugu" start_stub "OK" blob - /dev/null blob + 658109e1f0e4495eced974701742852b170de125 (mode 644) --- /dev/null +++ regress/turn/reqcheck.c @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 Isaac + * + * 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. + */ + +/* Inspect the Provider-visible request without depending on its serializer. */ + +#include + +#include +#include +#include +#include +#include +#include + +#include "buf.h" +#include "json.h" +#include "log.h" + +static void +read_file(struct buf *b, const char *path) +{ + FILE *f; + char chunk[8192]; + size_t n; + + if ((f = fopen(path, "r")) == NULL) + err(1, "%s", path); + while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) + buf_add(b, chunk, n); + if (ferror(f)) + err(1, "read %s", path); + fclose(f); +} + +static int +string_is(const struct json *j, int tok, const char *want) +{ + char *s; + size_t len, wantlen; + int ok; + + if (tok == -1 || !json_is_string(j, tok) || + (s = json_get_str(j, tok, &len)) == NULL) + return (0); + wantlen = strlen(want); + ok = len == wantlen && memcmp(s, want, len) == 0; + free(s); + return (ok); +} + +static const u_char * +request_body(const struct buf *b, size_t *len) +{ + size_t i; + + for (i = 0; i + 4 <= b->len; i++) { + if (memcmp(b->data + i, "\r\n\r\n", 4) != 0) + continue; + *len = b->len - i - 4; + return (b->data + i + 4); + } + return (NULL); +} + +static int +message_is(const struct json *j, int msg, const char *role, const char *text) +{ + int block, content; + + if (!json_is_object(j, msg) || + !string_is(j, json_obj_get(j, msg, "role"), role) || + (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"), "text") || + !string_is(j, json_obj_get(j, block, "text"), text)) + return (0); + return (1); +} + +static int +cache_tail(const struct json *j, int messages) +{ + int block, cache, content, i, k, ncache = 0, tail = -1; + + for (i = 0; i < json_arr_len(j, messages); i++) { + content = json_obj_get(j, json_arr_get(j, messages, i), + "content"); + if (!json_is_array(j, content)) + return (-2); + for (k = 0; k < json_arr_len(j, content); k++) { + block = json_arr_get(j, content, k); + cache = json_obj_get(j, block, "cache_control"); + if (cache == -1) + continue; + if (!json_is_object(j, cache) || + json_obj_len(j, cache) != 1 || + !string_is(j, json_obj_get(j, cache, "type"), + "ephemeral")) + return (-2); + ncache++; + tail = i; + if (k != json_arr_len(j, content) - 1) + return (-2); + } + } + return (ncache == 1 ? tail : -2); +} + +int +main(int argc, char *argv[]) +{ + struct buf b; + struct json j; + const u_char *body; + const char *errstr; + size_t bodylen; + long long expected_messages, expected_tail; + int messages, root, tail; + + if (argc != 2 && argc != 4) + errx(1, "usage: reqcheck request-file [message-count tail]"); + expected_messages = 3; + expected_tail = 0; + if (argc == 4) { + expected_messages = strtonum(argv[2], 0, INT_MAX, &errstr); + if (errstr != NULL) + errx(1, "message-count is %s", errstr); + expected_tail = strtonum(argv[3], 0, INT_MAX, &errstr); + if (errstr != NULL) + errx(1, "tail is %s", errstr); + } + log_init(LOG_TO_STDERR, 0, LOG_USER); + buf_init(&b); + read_file(&b, argv[1]); + if ((body = request_body(&b, &bodylen)) == NULL || + json_parse(&j, body, bodylen, 0) != 0) + errx(1, "%s: invalid JSON request", argv[1]); + root = json_root(&j); + messages = json_obj_get(&j, root, "messages"); + if (!json_is_object(&j, root) || !json_is_array(&j, messages) || + json_arr_len(&j, messages) != expected_messages) + errx(1, "%s: got %d messages, expected %lld", argv[1], + json_arr_len(&j, messages), expected_messages); + if (argc == 2 && + (!message_is(&j, json_arr_get(&j, messages, 0), "user", + "first question") || + !message_is(&j, json_arr_get(&j, messages, 1), "assistant", + "Hello, world.") || + !message_is(&j, json_arr_get(&j, messages, 2), "user", + "second question"))) + errx(1, "%s: unexpected line-mode Projection", argv[1]); + tail = cache_tail(&j, messages); + json_done(&j); + buf_free(&b); + if (tail != expected_tail) + errx(1, "%s: cache tail is message %d, expected %lld", + argv[1], tail, expected_tail); + return (0); +} blob - 34321c239eb0d81a4cc5b0fa3dd327f49797e757 blob + 5b7aab176b5f31d13771bcc71ec0e14336c9c592 --- src/fugu/coord.c +++ src/fugu/coord.c @@ -3415,7 +3415,6 @@ run_turn(struct coord *c, const char *prompt, size_t p /* the model is done: commit and show its text */ if (commit_turn(c, TURN_OK) == -1) return (cancel_turn(c, first)); - c->prev_msgs = msg_count(&c->conv); emit_answer(c); machine_finish(c, 0); return (0); @@ -3458,7 +3457,6 @@ run_turn(struct coord *c, const char *prompt, size_t p if (iter >= TURN_ITER_MAX) { if (commit_turn(c, TURN_CAP) == -1) return (cancel_turn(c, first)); - c->prev_msgs = msg_count(&c->conv); emit_error(c, "fugu: reached the %d-step tool limit " "for one turn", TURN_ITER_MAX); machine_error(c, "tool-loop iteration limit reached");