Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in <config>/credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in <config>/settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
205 lines
7.2 KiB
C
205 lines
7.2 KiB
C
#include "ctl.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* Heredoc args: `arg <key> <<TAG` ... a line equal to TAG.
|
|
* The body is confirmed only by the terminator line, so no prefix of the
|
|
* stream may ever produce CALL_READY. */
|
|
|
|
static int nfail;
|
|
|
|
#define EXPECT(cond, msg) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
|
nfail++; \
|
|
} \
|
|
} while (0)
|
|
|
|
typedef struct {
|
|
int ready;
|
|
int nargs;
|
|
char keys[CTL_MAX_ARGS][CTL_MAX_KEY];
|
|
char *vals[CTL_MAX_ARGS];
|
|
size_t lens[CTL_MAX_ARGS];
|
|
char text[4096];
|
|
size_t text_len;
|
|
} cap_t;
|
|
|
|
static void on_ev(const ctl_event_t *ev, void *ud) {
|
|
cap_t *c = ud;
|
|
if (ev->type == CTL_EV_TEXT && c->text_len + ev->text_len < sizeof(c->text)) {
|
|
memcpy(c->text + c->text_len, ev->text, ev->text_len);
|
|
c->text_len += ev->text_len;
|
|
}
|
|
if (ev->type != CTL_EV_CALL_READY)
|
|
return;
|
|
c->ready++;
|
|
c->nargs = ev->call->nargs;
|
|
for (int i = 0; i < ev->call->nargs; i++) {
|
|
snprintf(c->keys[i], sizeof(c->keys[i]), "%s", ev->call->args[i].key);
|
|
free(c->vals[i]);
|
|
c->vals[i] = malloc(ev->call->args[i].value_len + 1);
|
|
memcpy(c->vals[i], ev->call->args[i].value, ev->call->args[i].value_len);
|
|
c->vals[i][ev->call->args[i].value_len] = 0;
|
|
c->lens[i] = ev->call->args[i].value_len;
|
|
}
|
|
}
|
|
|
|
static void cap_free(cap_t *c) {
|
|
for (int i = 0; i < CTL_MAX_ARGS; i++)
|
|
free(c->vals[i]);
|
|
memset(c, 0, sizeof(*c));
|
|
}
|
|
|
|
/* feed in `chunk`-byte pieces; finish if asked */
|
|
static ctl_status_t run(const char *s, size_t n, size_t chunk, int finish,
|
|
cap_t *c, ctl_parser_stats_t *st) {
|
|
memset(c, 0, sizeof(*c));
|
|
ctl_parser_t *p = ctl_parser_create(on_ev, c);
|
|
ctl_status_t rc = CTL_OK;
|
|
for (size_t i = 0; i < n && rc == CTL_OK; i += chunk) {
|
|
size_t k = n - i < chunk ? n - i : chunk;
|
|
rc = ctl_parser_feed(p, s + i, k);
|
|
}
|
|
if (rc == CTL_OK && finish)
|
|
rc = ctl_parser_finish(p);
|
|
if (st)
|
|
ctl_parser_stats(p, st);
|
|
ctl_parser_destroy(p);
|
|
return rc;
|
|
}
|
|
|
|
static const char *BODY = "all:\n"
|
|
"\tgcc -o app main.c\n"
|
|
"\n"
|
|
"end\n"
|
|
"call x y\n"
|
|
"arg path 3\n"
|
|
"```\n"
|
|
" EOF\n"
|
|
"last line\n";
|
|
|
|
static const char *STREAM = "Writing the Makefile.\n"
|
|
"call w1 write_file\n"
|
|
"arg path Makefile\n"
|
|
"arg content <<EOF\n"
|
|
"all:\n"
|
|
"\tgcc -o app main.c\n"
|
|
"\n"
|
|
"end\n"
|
|
"call x y\n"
|
|
"arg path 3\n"
|
|
"```\n"
|
|
" EOF\n"
|
|
"last line\n"
|
|
"EOF\n"
|
|
"end\n";
|
|
|
|
static void test_body_verbatim(void) {
|
|
size_t chunks[] = {1, 2, 5, 64, 100000};
|
|
for (size_t k = 0; k < sizeof(chunks) / sizeof(chunks[0]); k++) {
|
|
cap_t c;
|
|
ctl_parser_stats_t st;
|
|
ctl_status_t rc = run(STREAM, strlen(STREAM), chunks[k], 1, &c, &st);
|
|
EXPECT(rc == CTL_OK, "heredoc stream ok");
|
|
EXPECT(c.ready == 1, "one call ready");
|
|
EXPECT(c.nargs == 2, "two args");
|
|
EXPECT(strcmp(c.keys[0], "path") == 0 && strcmp(c.vals[0], "Makefile") == 0,
|
|
"inline path");
|
|
EXPECT(strcmp(c.keys[1], "content") == 0, "content key");
|
|
EXPECT(c.vals[1] && strcmp(c.vals[1], BODY) == 0,
|
|
"body verbatim: tabs, blank lines, end/call/arg/fence lines, "
|
|
"indented tag");
|
|
EXPECT(st.args_heredoc == 1 && st.args_inline == 1, "stats per arg form");
|
|
EXPECT(c.text_len == strlen("Writing the Makefile.\n"), "prose is text");
|
|
cap_free(&c);
|
|
}
|
|
}
|
|
|
|
static void test_never_premature(void) {
|
|
size_t n = strlen(STREAM);
|
|
int premature = 0;
|
|
for (size_t cut = 0; cut < n; cut++) {
|
|
cap_t c;
|
|
run(STREAM, cut, 7, 0, &c, NULL);
|
|
if (c.ready)
|
|
premature++;
|
|
cap_free(&c);
|
|
}
|
|
EXPECT(premature == 0, "no prefix of a heredoc call is executable");
|
|
|
|
/* EOF inside the body: error, nothing executed */
|
|
const char *mid = strstr(STREAM, "\tgcc");
|
|
cap_t c;
|
|
ctl_parser_stats_t st;
|
|
ctl_status_t rc = run(STREAM, (size_t)(mid - STREAM), 3, 1, &c, &st);
|
|
EXPECT(rc == CTL_ERR_CONTRACT && c.ready == 0, "EOF in heredoc rejected");
|
|
EXPECT(st.error_kind == CTL_EK_HEREDOC_EOF, "error kind heredoc_eof");
|
|
cap_free(&c);
|
|
}
|
|
|
|
static void test_tag_forms(void) {
|
|
const char *quoted = "call q1 write_file\narg path a.txt\n"
|
|
"arg content <<'BODY_1'\nx\nBODY_1\nend\n";
|
|
cap_t c;
|
|
EXPECT(run(quoted, strlen(quoted), 1, 1, &c, NULL) == CTL_OK && c.ready == 1,
|
|
"single-quoted tag");
|
|
EXPECT(c.vals[1] && strcmp(c.vals[1], "x\n") == 0, "quoted tag body");
|
|
cap_free(&c);
|
|
|
|
const char *dq = "call q2 write_file\narg path a.txt\n"
|
|
"arg content << \"T\"\n\nT\nend\n";
|
|
EXPECT(run(dq, strlen(dq), 4, 1, &c, NULL) == CTL_OK && c.ready == 1,
|
|
"double-quoted tag with space");
|
|
EXPECT(c.vals[1] && strcmp(c.vals[1], "\n") == 0, "single blank line body");
|
|
cap_free(&c);
|
|
|
|
const char *empty = "call q3 write_file\narg path a.txt\n"
|
|
"arg content <<E\nE\nend\n";
|
|
EXPECT(run(empty, strlen(empty), 3, 1, &c, NULL) == CTL_OK && c.ready == 1 &&
|
|
c.lens[1] == 0,
|
|
"empty body");
|
|
cap_free(&c);
|
|
|
|
const char *bad = "call q4 write_file\narg content <<a-b\nx\na-b\nend\n";
|
|
ctl_parser_stats_t st;
|
|
EXPECT(run(bad, strlen(bad), 1, 1, &c, &st) == CTL_ERR_CONTRACT &&
|
|
st.error_kind == CTL_EK_BAD_ARG && c.ready == 0,
|
|
"invalid tag rejected");
|
|
cap_free(&c);
|
|
|
|
/* terminator on the last line without newline, implicit end at EOF */
|
|
const char *eof = "call q5 write_file\narg path a.txt\narg content <<EOF\n"
|
|
"body\nEOF";
|
|
EXPECT(run(eof, strlen(eof), 2, 1, &c, NULL) == CTL_OK && c.ready == 1,
|
|
"tag at EOF accepted");
|
|
EXPECT(c.vals[1] && strcmp(c.vals[1], "body\n") == 0, "tag at EOF body");
|
|
cap_free(&c);
|
|
|
|
/* mixing all three arg forms in one call */
|
|
const char *mix = "call m1 apply_patch\narg note hello world\narg patch 4\n"
|
|
"ab\ncarg extra <<X\nq\nX\nend\n";
|
|
EXPECT(run(mix, strlen(mix), 1, 1, &c, &st) == CTL_OK && c.ready == 1 &&
|
|
c.nargs == 3,
|
|
"inline + nbytes + heredoc in one call");
|
|
EXPECT(c.vals[1] && strcmp(c.vals[1], "ab\nc") == 0, "nbytes body exact");
|
|
EXPECT(st.args_inline == 1 && st.args_nbytes == 1 && st.args_heredoc == 1,
|
|
"stats count each form");
|
|
cap_free(&c);
|
|
}
|
|
|
|
int main(void) {
|
|
test_body_verbatim();
|
|
test_never_premature();
|
|
test_tag_forms();
|
|
if (nfail) {
|
|
fprintf(stderr, "test_ctl_heredoc: %d failed\n", nfail);
|
|
return 1;
|
|
}
|
|
printf("test_ctl_heredoc: OK\n");
|
|
return 0;
|
|
}
|