commit - 98843d2184110b08bc0ab8759ba7aef81f32b600
commit + 70b68b95fb01cb2b260ca9a92849088b4ee34fb2
blob - be40ddf2b0388e1e6dfa6ef65deb57d30eabe0da
blob + d397b20feb0f182630b200d95785c71f75775960
--- regress/README
+++ regress/README
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
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
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
+/*
+ * 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
/* 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);
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");