Three defects from a review of the agent loop. A session's tool schema is built by create(), and load() then replaced the whole object with the one on disk, so a session saved under SOLO_TIMEOUT=42 went on telling the model the 42 s limit while run() enforced whatever the environment said now. The schema moves into mkschema() and is rebuilt after the load, so the description and the deadline always agree. A tool call whose arguments held no command, which a flaky router or a truncated stream can produce, was passed to the shell as an empty string. The shell ran it as [exit 0], the agent asked again, and a peer that always did this pinned the loop at full speed: a mock measuring 685 requests in five seconds of the same call. Such a call is now answered with an error instead of a shell run, command is declared required in the schema, and three rounds in a row that run nothing end the turn. The same mock now stops after three requests. dispatch() read a line without trimming it, so "/help " was taken as the generic parameter editor and wrote a help key into the session, "/quit " did not quit, and "/temperature 0.5 " stored trailing spaces. A line that starts with / or ! is right-trimmed before it is routed; a task keeps its exact text, leading space included, so the model still sees what was typed and a leading space still keeps the line out of the history. Eleven offline regression tests cover the three, including a mock endpoint that sends nothing but empty tool calls. make test: 42 passed, 0 failed.
370 lines
17 KiB
Bash
Executable File
370 lines
17 KiB
Bash
Executable File
#!/bin/sh
|
|
# Offline regression tests for Solo-C. No API key and no network: every line
|
|
# handed to the agent starts with / or !, so nothing is ever sent to the model.
|
|
#
|
|
# sh ./test.sh [binary] # default ./solo
|
|
#
|
|
# Each test runs the agent in its own directory, because the session file
|
|
# (.solo_sess.json) lives in the current directory. Exits non-zero if any test
|
|
# failed, with the reason on stderr.
|
|
set -u
|
|
|
|
SOLO=${1:-./solo}
|
|
# Canonical, like /proc/self/exe inside the agent, which names its own path to the
|
|
# model so a subagent can be re-launched.
|
|
SOLO=$(perl -MCwd -e 'print Cwd::abs_path($ARGV[0]) // $ARGV[0]' "$SOLO" 2>/dev/null) || SOLO=${1:-./solo}
|
|
[ -x "$SOLO" ] || { echo "test: no such executable: $SOLO" >&2; exit 1; }
|
|
command -v perl >/dev/null 2>&1 || { echo "test: perl is required" >&2; exit 1; }
|
|
|
|
WORK=$(mktemp -d)
|
|
# Long enough that the environment block lands where the allocator dislikes it,
|
|
# short enough to stay a legal path component.
|
|
LONGDIR=/tmp/$(perl -e 'print "d" x 200')
|
|
trap 'rm -rf "$WORK" "$LONGDIR"' EXIT INT TERM
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
# ok EXIT-CODE NAME [DETAIL] -- prints pass, or the detail on stderr.
|
|
ok() {
|
|
if [ "$1" = 0 ]; then
|
|
PASS=$((PASS + 1))
|
|
printf 'PASS %s\n' "$2"
|
|
else
|
|
FAIL=$((FAIL + 1))
|
|
printf 'FAIL %s: %s\n' "$2" "${3:-}" >&2
|
|
fi
|
|
}
|
|
|
|
# solo_in DIR ENV... < lines
|
|
solo_in() {
|
|
_dir=$1; shift
|
|
mkdir -p "$_dir"
|
|
( cd "$_dir" && env "$@" "$SOLO" )
|
|
}
|
|
|
|
# solo_args DIR ARG... -- the one-shot mode: no stdin, no REPL
|
|
solo_args() {
|
|
_dir=$1; shift
|
|
mkdir -p "$_dir"
|
|
( cd "$_dir" && "$SOLO" "$@" < /dev/null )
|
|
}
|
|
|
|
echo "# $SOLO"
|
|
|
|
# --- a setting taken from the environment must not be freed by the agent -------
|
|
# These slots used to point straight at getenv memory, so setstr() called free()
|
|
# on it and the process died inside the slash command that changed it.
|
|
printf '/endpoint http://x.example\n/quit\n' | \
|
|
solo_in "$WORK/envcrash" SOLO_ENDPOINT="http://$(perl -e 'print "a" x 900').example.com/api" \
|
|
>/dev/null 2>&1
|
|
ok $? 'SOLO_ENDPOINT then /endpoint does not crash'
|
|
|
|
printf '/model other/model\n/quit\n' | \
|
|
solo_in "$WORK/envmodel" SOLO_MODEL="vendor/$(perl -e 'print "m" x 500')" >/dev/null 2>&1
|
|
ok $? 'SOLO_MODEL then /model does not crash'
|
|
|
|
printf '/reasoning_effort low\n/quit\n' | \
|
|
solo_in "$WORK/envreason" SOLO_REASON="$(perl -e 'print "r" x 700')" >/dev/null 2>&1
|
|
ok $? 'SOLO_REASON then /reasoning_effort does not crash'
|
|
|
|
# TMP is freed at exit, so this one used to die without a slash command at all.
|
|
printf '/quit\n' | solo_in "$WORK/envtmp" SOLO_TMPDIR="$LONGDIR" >/dev/null 2>&1
|
|
ok $? 'SOLO_TMPDIR then /quit does not crash'
|
|
|
|
# --- an integer parameter must stay an integer in the request ------------------
|
|
# Every number went through json_object_new_double, so /max_tokens 4096 reached
|
|
# the endpoint as 4096.0.
|
|
D=$WORK/params
|
|
printf '/max_tokens 4096\n/seed 3\n/top_p 0.95\n/quit\n' | solo_in "$D" >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/"max_tokens":4096,/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? '/max_tokens 4096 is sent as an integer'
|
|
perl -0777 -ne 'exit(/"seed":3,/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? '/seed 3 is sent as an integer'
|
|
perl -0777 -ne 'exit(/"top_p":0\.9/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? '/top_p 0.95 is sent as a number'
|
|
perl -0777 -ne 'exit(/"foo"/ ? 1 : 0)' "$D/.solo_sess.json"
|
|
ok $? 'a string parameter is still a string'
|
|
|
|
# --- the timeout is the timeout, not one second less --------------------------
|
|
# The old deadline was time(0) + TO, and time() counts whole seconds, so a
|
|
# command that happened to start late in a second was killed up to a second
|
|
# early. Stagger the start and require that no run dies before the limit.
|
|
D=$WORK/timing
|
|
MIN=99
|
|
for frac in 0 0.25 0.5 0.75; do
|
|
out=$( { sleep "$frac"
|
|
printf '!sh -c "date +%%s.%%N; sleep 6"\n!date +%%s.%%N\n/quit\n'; } | \
|
|
solo_in "$D" SOLO_TIMEOUT=3 2>/dev/null )
|
|
a=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | head -1)
|
|
b=$(printf '%s\n' "$out" | grep -o '^[0-9][0-9]*\.[0-9]*' | tail -1)
|
|
d=$(perl -e 'printf "%.2f", $ARGV[1] - $ARGV[0]' "$a" "$b" 2>/dev/null || echo 0)
|
|
MIN=$(perl -e 'printf "%.2f", $ARGV[1] < $ARGV[0] ? $ARGV[1] : $ARGV[0]' "$MIN" "$d")
|
|
done
|
|
[ "$(perl -e 'print $ARGV[0] >= 2.8 ? 1 : 0' "$MIN")" = 1 ]
|
|
ok $? "SOLO_TIMEOUT=3 never fires early" "a run died after ${MIN}s"
|
|
|
|
# --- SOLO_TIMEOUT below 1 means no limit, not "kill instantly" ----------------
|
|
printf '!echo hi\n/quit\n' | solo_in "$WORK/nolimit" SOLO_TIMEOUT=0 2>&1 | grep -q '^hi$'
|
|
ok $? 'SOLO_TIMEOUT=0 runs commands instead of killing them at once'
|
|
printf '!echo hi\n/quit\n' | solo_in "$WORK/neglimit" SOLO_TIMEOUT=-5 2>&1 | grep -q '^hi$'
|
|
ok $? 'a negative SOLO_TIMEOUT is not an instant timeout'
|
|
|
|
# --- a timed out command leaves nothing behind --------------------------------
|
|
# The child calls setpgid(0,0) and the parent kills the group, so the whole
|
|
# group has to be gone once the turn ends.
|
|
D=$WORK/group
|
|
printf '!sh -c "sleep 41 & sleep 41"\n/quit\n' | solo_in "$D" SOLO_TIMEOUT=1 >/dev/null 2>&1
|
|
sleep 1
|
|
LEFT=$(ps -o args= 2>/dev/null | grep -c '^sleep 41$')
|
|
[ "$LEFT" -eq 0 ]
|
|
ok $? 'a timed out command takes its process group with it' "$LEFT process(es) left"
|
|
|
|
# --- Ctrl-C must not eat the next line --------------------------------
|
|
# readline only redraws the prompt on Ctrl-C: the half-typed text
|
|
# stays in its buffer and the interrupt flag outlives the prompt.
|
|
# Both used to swallow the next command silently. This needs a real
|
|
# tty, so the agent runs on a pty driven by a small helper.
|
|
D=$WORK/ctrlc
|
|
cat >"$WORK/ptyrun.c" <<'PTY_EOF'
|
|
/* Drive PROG on a pty from a script on stdin. A line "INT" sends
|
|
SIGINT, a line "RAW text" writes text without a newline, and any
|
|
other line is written with a newline appended. A short pause
|
|
follows every step. Everything PROG prints is relayed to stdout. */
|
|
#define _GNU_SOURCE
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
#include <signal.h>
|
|
#include <fcntl.h>
|
|
#include <sys/select.h>
|
|
#include <sys/wait.h>
|
|
#include <pty.h>
|
|
|
|
int main(int argc, char **argv) {
|
|
int m, st; pid_t k; char buf[4096], line[4096]; struct timeval tv; fd_set fs;
|
|
if(argc < 2) { fprintf(stderr, "usage: ptyrun PROG [ARG...] < script\n"); return 2; }
|
|
k = forkpty(&m, 0, 0, 0);
|
|
if(k < 0) { perror("forkpty"); return 2; }
|
|
if(!k) { execvp(argv[1], argv + 1); _exit(127); }
|
|
fcntl(m, F_SETFL, O_NONBLOCK);
|
|
while(fgets(line, sizeof line, stdin)) {
|
|
size_t l = strlen(line);
|
|
while(l && (line[l - 1] == '\n' || line[l - 1] == '\r')) line[--l] = 0;
|
|
usleep(400000);
|
|
if(!strcmp(line, "INT")) { kill(k, SIGINT); continue; }
|
|
if(!strncmp(line, "RAW ", 4)) { if(write(m, line + 4, strlen(line + 4)) < 0) break; continue; }
|
|
if(write(m, line, strlen(line)) < 0 || write(m, "\n", 1) < 0) break;
|
|
}
|
|
for(;;) {
|
|
FD_ZERO(&fs); FD_SET(m, &fs); tv.tv_sec = 10; tv.tv_usec = 0;
|
|
if(select(m + 1, &fs, 0, 0, &tv) <= 0) break;
|
|
st = read(m, buf, sizeof buf);
|
|
if(st <= 0) break;
|
|
if(write(1, buf, st) < 0) break;
|
|
}
|
|
kill(k, SIGKILL);
|
|
waitpid(k, &st, 0);
|
|
return 0;
|
|
}
|
|
PTY_EOF
|
|
# forkpty lives in libc since glibc 2.34; older systems need -lutil.
|
|
cc -Wall -Wextra -O2 -std=c99 -o "$WORK/ptyrun" "$WORK/ptyrun.c" 2>/dev/null \
|
|
|| cc -Wall -Wextra -O2 -std=c99 -o "$WORK/ptyrun" "$WORK/ptyrun.c" -lutil 2>/dev/null
|
|
[ -x "$WORK/ptyrun" ] || { echo "test: the pty helper did not build" >&2; exit 1; }
|
|
mkdir -p "$D"
|
|
( cd "$D" && printf 'INT\n!echo PTY_CI\n/quit\n' | "$WORK/ptyrun" "$SOLO" ) \
|
|
>"$WORK/ctrlc.out" 2>&1
|
|
grep -q -- '--- \$ echo PTY_CI' "$WORK/ctrlc.out"
|
|
ok $? 'a command typed after Ctrl-C at the prompt still runs'
|
|
( cd "$D" && printf 'RAW echo partial\nINT\n!echo PTY_FRESH\n/quit\n' | "$WORK/ptyrun" "$SOLO" ) \
|
|
>"$WORK/ctrlc2.out" 2>&1
|
|
grep -q -- '--- \$ echo PTY_FRESH' "$WORK/ctrlc2.out"
|
|
ok $? 'a command typed after Ctrl-C mid-line still runs'
|
|
if grep -q 'partialPTY_FRESH\|partial!echo' "$WORK/ctrlc2.out"; then rc=1; else rc=0; fi
|
|
ok "$rc" 'Ctrl-C mid-line clears the half-typed text'
|
|
|
|
# --- the tool description must quote the real timeout -------------------------
|
|
D=$WORK/desc
|
|
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/after 42s is killed/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'the bash tool description quotes SOLO_TIMEOUT'
|
|
|
|
# --- a prompt as an argument runs one task and exits --------------------------
|
|
# The banner moved to stderr, so stdout holds the answer alone and a one-shot run
|
|
# can be piped into another program.
|
|
D=$WORK/oneshot
|
|
solo_args "$D" '!echo hello' >"$WORK/out" 2>"$WORK/err"
|
|
ok $? 'a prompt argument runs the task and exits'
|
|
grep -q '^hello$' "$WORK/out"
|
|
ok $? 'the answer arrives on stdout'
|
|
grep -q 'Agent Solo ready' "$WORK/err"
|
|
ok $? 'the ready banner goes to stderr in one-shot mode'
|
|
if grep -q 'Agent Solo ready' "$WORK/out"; then rc=1; else rc=0; fi
|
|
ok "$rc" 'stdout carries no banner in one-shot mode'
|
|
# Anything left on stdin is ignored: the one-shot run never opens the REPL.
|
|
( cd "$D" && printf '!echo from-stdin\n/quit\n' | "$SOLO" '!echo hello' ) >>"$WORK/out" 2>>"$WORK/err"
|
|
if grep -q 'from-stdin' "$WORK/out"; then rc=1; else rc=0; fi
|
|
ok "$rc" 'a one-shot run never reads stdin'
|
|
|
|
solo_args "$WORK/onestatus" '!sh -c "exit 7"' >/dev/null 2>&1
|
|
[ $? -eq 7 ]
|
|
ok $? 'a ! task hands its own exit status back'
|
|
|
|
solo_args "$WORK/joined" '!echo' one two >"$WORK/joined.out" 2>/dev/null
|
|
grep -q '^one two$' "$WORK/joined.out"
|
|
ok $? 'every word becomes part of one task, quoting optional'
|
|
|
|
# An empty or whitespace-only argument is no task at all, so the REPL opens.
|
|
mkdir -p "$WORK/blank"
|
|
( cd "$WORK/blank" && printf '/quit\n' | "$SOLO" ' ' ) >"$WORK/blank.out" 2>/dev/null
|
|
grep -q 'Agent Solo ready' "$WORK/blank.out"
|
|
ok $? 'a whitespace-only argument still opens the REPL'
|
|
|
|
# --- the usage line belongs to the last turn, not to every turn ----------------
|
|
# The token stats used to be printed after every turn, so they landed in the
|
|
# middle of a tool run and chopped up whatever was piped out of a one-shot run.
|
|
cat >"$WORK/mock.pl" <<'MOCK_EOF'
|
|
# A chat endpoint on the loopback: the first turn asks for a tool, the second one
|
|
# answers. Prints the port it got, then serves until it is killed.
|
|
use IO::Socket::INET;
|
|
my $srv = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0,
|
|
Listen => 5, ReuseAddr => 1) or exit 1;
|
|
$| = 1;
|
|
print $srv->sockport, "\n";
|
|
my $n = 0;
|
|
while(my $c = $srv->accept) {
|
|
$c->autoflush(1);
|
|
my $req = '';
|
|
while(my $l = <$c>) { last if $l =~ /^\r?\n$/; $req .= $l; }
|
|
print $c "HTTP/1.0 100 Continue\r\n\r\n" if $req =~ /^Expect:/mi;
|
|
my ($len) = $req =~ /Content-Length:\s*(\d+)/i;
|
|
my $body = '';
|
|
read($c, $body, $len) if $len;
|
|
$n++;
|
|
my $call = '{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",'
|
|
. '"function":{"name":"bash","arguments":"{\"command\":\"echo tool-ran\"}"}}]}';
|
|
my $msg = $n == 1 ? $call : '{"role":"assistant","content":"The answer is 42."}';
|
|
my $out = '{"model":"mock/free","usage":{"total_tokens":7,"prompt_tokens":5,"completion_tokens":2},'
|
|
. '"choices":[{"message":' . $msg . '}]}';
|
|
print $c "HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: "
|
|
. length($out) . "\r\n\r\n" . $out;
|
|
close $c;
|
|
}
|
|
MOCK_EOF
|
|
perl "$WORK/mock.pl" >"$WORK/port" 2>/dev/null &
|
|
MOCK=$!
|
|
i=0
|
|
while [ ! -s "$WORK/port" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i + 1)); done
|
|
PORT=$(cat "$WORK/port" 2>/dev/null)
|
|
mkdir -p "$WORK/tokens"
|
|
( cd "$WORK/tokens" && SOLO_ENDPOINT="http://127.0.0.1:$PORT/api" "$SOLO" 'do it' ) \
|
|
>"$WORK/tokens.out" 2>"$WORK/tokens.err"
|
|
kill "$MOCK" 2>/dev/null
|
|
[ "$(grep -c 'tokens:' "$WORK/tokens.out")" -eq 1 ]
|
|
ok $? 'the token stats are printed once, on the last turn' \
|
|
"$(grep -c 'tokens:' "$WORK/tokens.out") line(s)"
|
|
awk '/tool-ran/{t=NR} /tokens:/{s=NR} END{exit !(t && s && s > t)}' "$WORK/tokens.out"
|
|
ok $? 'the tool run is not chopped up by stats' "$(tail -1 "$WORK/tokens.out")"
|
|
grep -q 'The answer is 42\.$' "$WORK/tokens.out"
|
|
ok $? 'the answer of the last turn reaches stdout'
|
|
tail -1 "$WORK/tokens.out" | grep -q '^--- \[mock/free\] tokens:'
|
|
ok $? 'the stats line is the last thing on stdout'
|
|
|
|
# --- the model has to know how to delegate to a subagent ----------------------
|
|
D=$WORK/subagent
|
|
printf '/quit\n' | solo_in "$D" SOLO_ENDPOINT=http://e.example/api/// >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/split into subagents/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'the system prompt tells the model it can spawn subagents'
|
|
tr -d '\\' <"$D/.solo_sess.json" >"$D/plain" # json-c escapes every slash
|
|
grep -qF "SOLO_ENDPOINT=http://e.example/api/ $SOLO 'the subtask, stated in full'" "$D/plain"
|
|
ok $? 'the subagent line names the endpoint and Solo own path'
|
|
if grep -q 'api///' "$D/.solo_sess.json"; then rc=1; else rc=0; fi
|
|
ok "$rc" 'a trailing slash on SOLO_ENDPOINT is stripped'
|
|
|
|
# --- a command line keeps its meaning when it ends in spaces -------------------
|
|
# dispatch() right-trims before it looks at the line, so "/help " and "/quit "
|
|
# stay commands instead of being read as a parameter named help or quit, and a
|
|
# value such as "/temperature 0.5 " does not gain trailing spaces. A task, on
|
|
# the other hand, keeps every character, because the model should see what was
|
|
# typed and a leading space still keeps the line out of the history.
|
|
D=$WORK/trim
|
|
printf '/help \n/quit \n' | solo_in "$D" 2>&1 | grep -q '^Commands:'
|
|
ok $? '/help with a trailing space prints help'
|
|
if grep -qE '"(help|quit)"' "$D/.solo_sess.json"; then rc=1; else rc=0; fi
|
|
ok "$rc" '"/help " and "/quit " do not become session parameters'
|
|
if printf '/quit \n!echo SURVIVED\n' | solo_in "$D" 2>&1 | grep -q SURVIVED; then rc=1; else rc=0; fi
|
|
ok "$rc" '/quit with a trailing space exits instead of being ignored'
|
|
D=$WORK/trimval
|
|
printf '/temperature 0.5 \n/quit\n' | solo_in "$D" >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/"temperature":0\.5,/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'a trailing space does not land in a parameter value'
|
|
|
|
# --- a resumed session must not keep the old timeout in the tool description ---
|
|
# create() builds the schema, then load() replaced the whole object with the one
|
|
# on disk, so a session saved under SOLO_TIMEOUT=42 went on describing 42 s while
|
|
# run() enforced whatever the environment said now. The schema is rebuilt after
|
|
# the load, so the two always agree.
|
|
D=$WORK/timeoutdesc
|
|
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=42 >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/after 42s is killed/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'SOLO_TIMEOUT=42 reaches the tool description'
|
|
printf '/quit\n' | solo_in "$D" SOLO_TIMEOUT=99 >/dev/null 2>&1
|
|
perl -0777 -ne 'exit(/after 99s is killed/ ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'a rerun with another SOLO_TIMEOUT rebuilds the description'
|
|
if grep -q 'after 42s' "$D/.solo_sess.json"; then rc=1; else rc=0; fi
|
|
ok "$rc" 'the stale timeout is gone from the resumed session'
|
|
perl -0777 -ne 'exit(/"required":\s*\[\s*"command"/s ? 0 : 1)' "$D/.solo_sess.json"
|
|
ok $? 'the bash tool declares command as required'
|
|
|
|
# --- an empty tool call must not spin the agent -------------------------------
|
|
# A peer that answers with a function call whose arguments hold no command used
|
|
# to be fed an empty string, which the shell ran as [exit 0]: the agent then
|
|
# asked again, forever. Such a call is now answered with an error, and three
|
|
# rounds in a row with nothing to run end the turn instead of the loop.
|
|
cat >"$WORK/emptycmd.pl" <<'EMPTY_EOF'
|
|
use IO::Socket::INET;
|
|
my $srv = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0,
|
|
Listen => 5, ReuseAddr => 1) or exit 1;
|
|
$| = 1;
|
|
print $srv->sockport, "\n";
|
|
my $n = 0;
|
|
while(my $c = $srv->accept) {
|
|
$c->autoflush(1);
|
|
my $req = '';
|
|
while(my $l = <$c>) { last if $l =~ /^\r?\n$/; $req .= $l; }
|
|
print $c "HTTP/1.0 100 Continue\r\n\r\n" if $req =~ /^Expect:/mi;
|
|
my ($len) = $req =~ /Content-Length:\s*(\d+)/i;
|
|
my $body = ''; read($c, $body, $len) if $len;
|
|
$n++;
|
|
open(my $f, '>>', $ARGV[0]); print $f "1\n"; close $f;
|
|
my $msg = '{"role":"assistant","content":"c","tool_calls":[{"id":"a","type":"function",'
|
|
. '"function":{"name":"bash","arguments":"not json"}}]}';
|
|
my $out = '{"model":"mock/free","choices":[{"message":' . $msg . '}]}';
|
|
print $c "HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: "
|
|
. length($out) . "\r\n\r\n" . $out;
|
|
close $c;
|
|
}
|
|
EMPTY_EOF
|
|
COUNT=$WORK/emptycmd.count
|
|
: >"$COUNT"
|
|
perl "$WORK/emptycmd.pl" "$COUNT" >"$WORK/emptycmd.port" 2>/dev/null &
|
|
EMPTY=$!
|
|
i=0
|
|
while [ ! -s "$WORK/emptycmd.port" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i + 1)); done
|
|
EPORT=$(cat "$WORK/emptycmd.port" 2>/dev/null)
|
|
mkdir -p "$WORK/emptycmd"
|
|
( cd "$WORK/emptycmd" && SOLO_ENDPOINT="http://127.0.0.1:$EPORT/api" "$SOLO" 'do it' ) \
|
|
>"$WORK/emptycmd.out" 2>"$WORK/emptycmd.err"
|
|
ERC=$?
|
|
kill "$EMPTY" 2>/dev/null
|
|
[ "$ERC" -ne 124 ]
|
|
ok $? 'a peer sending only empty tool calls does not hang the turn' "exit $ERC"
|
|
[ "$(wc -l <"$COUNT")" -le 4 ]
|
|
ok $? 'the empty-call loop is cut off after a few rounds' "$(wc -l <"$COUNT") request(s)"
|
|
grep -q 'carried no usable "command" argument' "$WORK/emptycmd.out"
|
|
ok $? 'the empty tool call is answered with an error, not a shell run'
|
|
|
|
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
|
[ "$FAIL" -eq 0 ]
|