Commit Diff


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 <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.
+ */
+
+/* Inspect the Provider-visible request without depending on its serializer. */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+
+#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");