Extensions: skills, plugins, MCP, sub-agents; configurable keys
- Skills (SKILL.md discovery, skill tool, /skills /skill) - plugin.json exec tools with permission prompts, timeouts and workspace trust (/plugins) - MCP stdio client: JSON-RPC initialize/tools/list/tools/call, mcp__server__tool, /mcp and sidebar status - task tool with general/explore/plan and .agent/agents/*.md roles, parallel read-only runs, cancel propagation, /agents - Keymap with Ctrl+X leader, keybinds.json overrides and /keys panel - Collapsed thinking, throttled streaming Markdown, leaner tool output, parallel read-only tools - Spawn: close-on-exec pipes and a process-wide fork lock - Tests for keymap, skills, plugins, MCP, sub-agents, shell compaction; docs/plugins.md
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#include "cst_keymap.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define CHECK(c) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
|
||||
exit(1); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int lookup1(const cst_keymap_t *km, int k, int *a) {
|
||||
int seq[1] = {k};
|
||||
return cst_keymap_lookup(km, seq, 1, a);
|
||||
}
|
||||
|
||||
static int lookup2(const cst_keymap_t *km, int k1, int k2, int *a) {
|
||||
int seq[2] = {k1, k2};
|
||||
return cst_keymap_lookup(km, seq, 2, a);
|
||||
}
|
||||
|
||||
static void test_parse(void) {
|
||||
char b[32];
|
||||
CHECK(cst_key_parse("ctrl+p") == 16);
|
||||
CHECK(cst_key_parse("Ctrl+X") == 24);
|
||||
CHECK(cst_key_parse("tab") == 9);
|
||||
CHECK(cst_key_parse("shift+tab") == CST_K_BTAB);
|
||||
CHECK(cst_key_parse("alt+enter") == (13 | CST_KEY_ALT));
|
||||
CHECK(cst_key_parse("alt+up") == CST_K_ALT_UP);
|
||||
CHECK(cst_key_parse("ctrl+home") == CST_K_CTRL_HOME);
|
||||
CHECK(cst_key_parse("f1") == CST_K_F1);
|
||||
CHECK(cst_key_parse("f12") == CST_K_F1 + 11);
|
||||
CHECK(cst_key_parse("pgdn") == CST_K_PGDN);
|
||||
CHECK(cst_key_parse("n") == 'n');
|
||||
CHECK(cst_key_parse("é") == 0xE9);
|
||||
CHECK(cst_key_parse("ctrl+") == -1);
|
||||
CHECK(cst_key_parse("hyper+x") == -1);
|
||||
CHECK(cst_key_parse("f13") == -1);
|
||||
CHECK(cst_key_parse("ctrl+f1") == -1);
|
||||
CHECK(strcmp(cst_key_name(24, b, sizeof(b)), "ctrl+x") == 0);
|
||||
CHECK(strcmp(cst_key_name(CST_K_BTAB, b, sizeof(b)), "shift+tab") == 0);
|
||||
CHECK(strcmp(cst_key_name(13 | CST_KEY_ALT, b, sizeof(b)), "alt+enter") ==
|
||||
0);
|
||||
CHECK(strcmp(cst_key_name(CST_K_F1 + 1, b, sizeof(b)), "f2") == 0);
|
||||
for (int a = 1; a < CST_KA_COUNT; a++) {
|
||||
const cst_key_action_info_t *in = cst_key_action_info(a);
|
||||
CHECK(in && in->name && in->group && in->desc);
|
||||
CHECK(cst_key_action_find(in->name) == a);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_defaults(void) {
|
||||
cst_keymap_t km;
|
||||
cst_keymap_defaults(&km);
|
||||
int a = 0;
|
||||
CHECK(lookup1(&km, 16, &a) == CST_KM_MATCH && a == CST_KA_PALETTE);
|
||||
CHECK(lookup1(&km, 24, &a) == CST_KM_PREFIX);
|
||||
CHECK(lookup2(&km, 24, 'n', &a) == CST_KM_MATCH && a == CST_KA_NEW_SESSION);
|
||||
CHECK(lookup2(&km, 24, 'z', &a) == CST_KM_NONE);
|
||||
CHECK(lookup1(&km, 'q', &a) == CST_KM_NONE);
|
||||
CHECK(lookup1(&km, CST_K_F1, &a) == CST_KM_MATCH && a == CST_KA_KEYS);
|
||||
char d[64];
|
||||
cst_keymap_describe(&km, CST_KA_KEYS, d, sizeof(d));
|
||||
CHECK(strcmp(d, "ctrl+x h, f1") == 0);
|
||||
char conf[256];
|
||||
CHECK(cst_keymap_conflicts(&km, conf, sizeof(conf)) == 0);
|
||||
}
|
||||
|
||||
static void test_json(void) {
|
||||
cst_keymap_t km;
|
||||
cst_keymap_defaults(&km);
|
||||
char err[128];
|
||||
CHECK(cst_keymap_apply_json(&km,
|
||||
"{\"leader\":\"ctrl+g\",\"palette\":\"f2\","
|
||||
"\"todo\":\"none\",\"model\":[\"<leader> M\","
|
||||
"\"alt+m\"]}",
|
||||
err, sizeof(err)) == 0);
|
||||
int a = 0;
|
||||
CHECK(lookup1(&km, 16, &a) == CST_KM_NONE);
|
||||
CHECK(lookup1(&km, CST_K_F1 + 1, &a) == CST_KM_MATCH && a == CST_KA_PALETTE);
|
||||
CHECK(lookup1(&km, 20, &a) == CST_KM_NONE); /* ctrl+t unbound */
|
||||
CHECK(lookup1(&km, 24, &a) == CST_KM_NONE); /* ctrl+x no longer leader */
|
||||
CHECK(lookup2(&km, 7, 'n', &a) == CST_KM_MATCH && a == CST_KA_NEW_SESSION);
|
||||
CHECK(lookup2(&km, 7, 'M', &a) == CST_KM_MATCH && a == CST_KA_MODEL);
|
||||
CHECK(lookup2(&km, 7, 'm', &a) == CST_KM_NONE);
|
||||
CHECK(lookup1(&km, 'm' | CST_KEY_ALT, &a) == CST_KM_MATCH &&
|
||||
a == CST_KA_MODEL);
|
||||
int user = 0;
|
||||
for (int i = 0; i < km.n; i++)
|
||||
if (km.b[i].action == CST_KA_MODEL)
|
||||
user += km.b[i].user;
|
||||
CHECK(user == 2);
|
||||
|
||||
/* bad entries are reported, the rest still applies */
|
||||
cst_keymap_defaults(&km);
|
||||
CHECK(cst_keymap_apply_json(&km,
|
||||
"{\"nope\":\"f3\",\"undo\":\"ctrl+\","
|
||||
"\"retry\":\"f4\"}",
|
||||
err, sizeof(err)) == -1);
|
||||
CHECK(strstr(err, "nope") != NULL);
|
||||
CHECK(lookup1(&km, CST_K_F1 + 3, &a) == CST_KM_MATCH && a == CST_KA_RETRY);
|
||||
CHECK(cst_keymap_apply_json(&km, "[1]", err, sizeof(err)) == -1);
|
||||
|
||||
/* conflicts: same key twice, and a single key shadowing a prefix */
|
||||
cst_keymap_defaults(&km);
|
||||
CHECK(cst_keymap_apply_json(&km, "{\"undo\":\"ctrl+o\",\"redo_none\":1}",
|
||||
err, sizeof(err)) == -1);
|
||||
char conf[256];
|
||||
CHECK(cst_keymap_conflicts(&km, conf, sizeof(conf)) == 1);
|
||||
CHECK(strstr(conf, "ctrl+o") && strstr(conf, "undo"));
|
||||
cst_keymap_defaults(&km);
|
||||
CHECK(cst_keymap_apply_json(&km, "{\"copy\":\"ctrl+x\"}", err,
|
||||
sizeof(err)) == 0);
|
||||
CHECK(cst_keymap_conflicts(&km, conf, sizeof(conf)) > 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_parse();
|
||||
test_defaults();
|
||||
test_json();
|
||||
puts("test_keymap: ok");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/* MCP client: the test binary doubles as a stdio MCP server (--server).
|
||||
* Covers the handshake, paginated tools/list, registration, tools/call
|
||||
* content mapping, isError, server->client requests, stdout noise,
|
||||
* cancellation, a crashing server and bad configs. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_mcp.h"
|
||||
#include "cst_tools.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* ---------- fake server ---------- */
|
||||
|
||||
static void reply(const cJSON *id, cJSON *result) {
|
||||
cJSON *m = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(m, "jsonrpc", "2.0");
|
||||
cJSON_AddItemToObject(m, "id", cJSON_Duplicate(id, 1));
|
||||
cJSON_AddItemToObject(m, "result", result);
|
||||
char *s = cJSON_PrintUnformatted(m);
|
||||
printf("%s\n", s);
|
||||
fflush(stdout);
|
||||
free(s);
|
||||
cJSON_Delete(m);
|
||||
}
|
||||
|
||||
static cJSON *tool(const char *name, const char *desc) {
|
||||
cJSON *t = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(t, "name", name);
|
||||
cJSON_AddStringToObject(t, "description", desc);
|
||||
cJSON *s = cJSON_AddObjectToObject(t, "inputSchema");
|
||||
cJSON_AddStringToObject(s, "type", "object");
|
||||
cJSON *p = cJSON_AddObjectToObject(s, "properties");
|
||||
cJSON *n = cJSON_AddObjectToObject(p, "n");
|
||||
cJSON_AddStringToObject(n, "type", "integer");
|
||||
cJSON *x = cJSON_AddObjectToObject(p, "text");
|
||||
cJSON_AddStringToObject(x, "type", "string");
|
||||
return t;
|
||||
}
|
||||
|
||||
static cJSON *text_result(const char *text, int is_error) {
|
||||
cJSON *r = cJSON_CreateObject();
|
||||
cJSON *c = cJSON_AddArrayToObject(r, "content");
|
||||
cJSON *it = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(it, "type", "text");
|
||||
cJSON_AddStringToObject(it, "text", text);
|
||||
cJSON_AddItemToArray(c, it);
|
||||
if (is_error)
|
||||
cJSON_AddTrueToObject(r, "isError");
|
||||
return r;
|
||||
}
|
||||
|
||||
static int server(void) {
|
||||
fprintf(stderr, "fake server starting\n");
|
||||
printf("this line is not JSON-RPC\n");
|
||||
fflush(stdout);
|
||||
char line[65536];
|
||||
while (fgets(line, sizeof(line), stdin)) {
|
||||
cJSON *m = cJSON_Parse(line);
|
||||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(m, "id");
|
||||
const cJSON *method = cJSON_GetObjectItemCaseSensitive(m, "method");
|
||||
const char *mt = cJSON_IsString(method) ? method->valuestring : "";
|
||||
const cJSON *params = cJSON_GetObjectItemCaseSensitive(m, "params");
|
||||
if (strcmp(mt, "initialize") == 0) {
|
||||
cJSON *r = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(r, "protocolVersion", "2025-06-18");
|
||||
cJSON_AddObjectToObject(cJSON_AddObjectToObject(r, "capabilities"),
|
||||
"tools");
|
||||
cJSON *si = cJSON_AddObjectToObject(r, "serverInfo");
|
||||
cJSON_AddStringToObject(si, "name", "fake");
|
||||
reply(id, r);
|
||||
} else if (strcmp(mt, "tools/list") == 0) {
|
||||
const cJSON *cur = cJSON_GetObjectItemCaseSensitive(params, "cursor");
|
||||
cJSON *r = cJSON_CreateObject();
|
||||
cJSON *tools = cJSON_AddArrayToObject(r, "tools");
|
||||
if (!cur) {
|
||||
cJSON_AddItemToArray(tools, tool("echo", "Echo the arguments."));
|
||||
cJSON_AddItemToArray(tools, tool("odd.name/x", "Sanitized name."));
|
||||
cJSON_AddStringToObject(r, "nextCursor", "page2");
|
||||
} else {
|
||||
cJSON_AddItemToArray(tools, tool("fail", "Always fails."));
|
||||
cJSON_AddItemToArray(tools, tool("mixed", "Mixed content."));
|
||||
cJSON_AddItemToArray(tools, tool("hang", "Never answers."));
|
||||
cJSON_AddItemToArray(tools, tool("crash", "Exits."));
|
||||
}
|
||||
reply(id, r);
|
||||
} else if (strcmp(mt, "tools/call") == 0) {
|
||||
const char *name = cJSON_GetObjectItemCaseSensitive(params, "name")
|
||||
->valuestring;
|
||||
const cJSON *args = cJSON_GetObjectItemCaseSensitive(params, "arguments");
|
||||
if (strcmp(name, "echo") == 0) {
|
||||
/* a server->client request first; the client must answer it */
|
||||
printf("{\"jsonrpc\":\"2.0\",\"id\":\"s1\",\"method\":\"ping\"}\n");
|
||||
fflush(stdout);
|
||||
char pong[4096];
|
||||
int ok = fgets(pong, sizeof(pong), stdin) && strstr(pong, "\"s1\"") &&
|
||||
strstr(pong, "\"result\"");
|
||||
char *a = cJSON_PrintUnformatted(args);
|
||||
char out[8192];
|
||||
const char *env = getenv("FAKE_ENV");
|
||||
snprintf(out, sizeof(out), "args=%s ping=%s env=%s", a,
|
||||
ok ? "ok" : "missing", env ? env : "");
|
||||
free(a);
|
||||
reply(id, text_result(out, 0));
|
||||
} else if (strcmp(name, "fail") == 0) {
|
||||
reply(id, text_result("bad input", 1));
|
||||
} else if (strcmp(name, "mixed") == 0) {
|
||||
cJSON *r = text_result("first", 0);
|
||||
cJSON *c = cJSON_GetObjectItemCaseSensitive(r, "content");
|
||||
cJSON *img = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(img, "type", "image");
|
||||
cJSON_AddStringToObject(img, "mimeType", "image/png");
|
||||
cJSON_AddStringToObject(img, "data", "AAAA");
|
||||
cJSON_AddItemToArray(c, img);
|
||||
cJSON *res = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(res, "type", "resource");
|
||||
cJSON *rr = cJSON_AddObjectToObject(res, "resource");
|
||||
cJSON_AddStringToObject(rr, "uri", "file:///x");
|
||||
cJSON_AddStringToObject(rr, "text", "resource text");
|
||||
cJSON_AddItemToArray(c, res);
|
||||
reply(id, r);
|
||||
} else if (strcmp(name, "crash") == 0) {
|
||||
fprintf(stderr, "fatal: fake crash\n");
|
||||
exit(3);
|
||||
}
|
||||
/* hang: no reply */
|
||||
}
|
||||
cJSON_Delete(m);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- client ---------- */
|
||||
|
||||
static int call(const char *tool, const char *k, const char *v,
|
||||
cst_tool_result_t *r) {
|
||||
ctl_tool_call_t c;
|
||||
memset(&c, 0, sizeof(c));
|
||||
snprintf(c.name, sizeof(c.name), "%s", tool);
|
||||
if (k) {
|
||||
snprintf(c.args[0].key, sizeof(c.args[0].key), "%s", k);
|
||||
c.args[0].value = (char *)v;
|
||||
c.args[0].value_len = strlen(v);
|
||||
c.nargs = 1;
|
||||
}
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = "/tmp";
|
||||
memset(r, 0, sizeof(*r));
|
||||
return cst_tool_run(&ctx, &c, r);
|
||||
}
|
||||
|
||||
static const cst_mcp_info_t *info_of(const char *name, cst_mcp_info_t *buf,
|
||||
int n) {
|
||||
for (int i = 0; i < n; i++)
|
||||
if (strcmp(buf[i].name, name) == 0)
|
||||
return &buf[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc > 1 && strcmp(argv[1], "--server") == 0)
|
||||
return server();
|
||||
|
||||
char self[OS_PATH_MAX];
|
||||
if (os_realpath(argv[0], self, sizeof(self)) != 0) {
|
||||
fprintf(stderr, "cannot resolve %s\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
setenv("CST_TEST_SECRET", "s3cret", 1);
|
||||
char cfg[OS_PATH_MAX + 128];
|
||||
snprintf(cfg, sizeof(cfg),
|
||||
"{\"command\": \"%s\", \"args\": [\"--server\"], "
|
||||
"\"env\": {\"FAKE_ENV\": \"v=${CST_TEST_SECRET}\"}, "
|
||||
"\"autoApprove\": [\"echo\"]}",
|
||||
self);
|
||||
CHECK(cst_mcp_add_config_json("fake", cfg, NULL, "user") == 0, "add fake");
|
||||
CHECK(cst_mcp_add_config_json("fake", cfg, NULL, "user") != 0,
|
||||
"duplicate name rejected");
|
||||
CHECK(cst_mcp_add_config_json("remote", "{\"url\": \"https://x\"}", NULL,
|
||||
"user") == 0,
|
||||
"url server recorded");
|
||||
CHECK(cst_mcp_add_config_json("ghost",
|
||||
"{\"command\": \"/nonexistent/cst-mcp\"}",
|
||||
NULL, "user") == 0,
|
||||
"ghost server recorded");
|
||||
CHECK(cst_mcp_add_config_json("off", "{\"command\": \"true\", "
|
||||
"\"disabled\": true}",
|
||||
NULL, "user") == 0,
|
||||
"disabled server recorded");
|
||||
int ready = -1, total = -1;
|
||||
cst_mcp_counts(&ready, &total);
|
||||
CHECK(ready == 0 && total == 3, "counts before start (disabled excluded)");
|
||||
|
||||
cst_mcp_start_all("/tmp");
|
||||
cst_mcp_wait_ready(10000);
|
||||
|
||||
cst_mcp_info_t mi[CST_MCP_MAX];
|
||||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||||
const cst_mcp_info_t *f = info_of("fake", mi, n);
|
||||
CHECK(f && f->state == CST_MCP_READY && f->ntools == 6,
|
||||
"fake ready with 6 tools over two pages");
|
||||
const cst_mcp_info_t *rm = info_of("remote", mi, n);
|
||||
CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"),
|
||||
"url servers unsupported");
|
||||
const cst_mcp_info_t *gh = info_of("ghost", mi, n);
|
||||
CHECK(gh && gh->state != CST_MCP_READY && gh->error[0],
|
||||
"missing command fails");
|
||||
const cst_mcp_info_t *of = info_of("off", mi, n);
|
||||
CHECK(of && of->state == CST_MCP_OFF, "disabled stays off");
|
||||
cst_mcp_counts(&ready, &total);
|
||||
CHECK(ready == 1 && total == 3, "counts after start");
|
||||
|
||||
const cst_tool_def_t *d = cst_tool_find("mcp__fake__echo");
|
||||
CHECK(d && d->perm == CST_TP_READ && d->schema_json &&
|
||||
strstr(d->schema_json, "\"integer\""),
|
||||
"echo registered, autoApprove -> read, schema kept");
|
||||
d = cst_tool_find("mcp__fake__fail");
|
||||
CHECK(d && d->perm == CST_TP_NET, "others need approval (net)");
|
||||
CHECK(cst_tool_find("mcp__fake__odd_name_x"), "tool name sanitized");
|
||||
|
||||
cst_tool_result_t r;
|
||||
call("mcp__fake__echo", "n", "42", &r);
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "args={\"n\":42}"),
|
||||
"typed arguments");
|
||||
CHECK(r.out && strstr(r.out, "ping=ok"), "server ping answered");
|
||||
CHECK(r.out && strstr(r.out, "env=v=s3cret"), "env with ${VAR} expansion");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
call("mcp__fake__fail", NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 1 && r.out && strstr(r.out, "bad input"),
|
||||
"isError -> exit 1");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
call("mcp__fake__mixed", NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "first\n[image image/png") &&
|
||||
strstr(r.out, "resource text"),
|
||||
"content items mapped to text");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
volatile int cancel = 1;
|
||||
os_set_cancel_flag(&cancel);
|
||||
double t0 = os_now_ms();
|
||||
call("mcp__fake__hang", NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 1 && r.out && strstr(r.out, "cancelled") &&
|
||||
os_now_ms() - t0 < 5000,
|
||||
"cancel stops waiting");
|
||||
cst_tool_result_free(&r);
|
||||
os_set_cancel_flag(NULL);
|
||||
|
||||
call("mcp__fake__crash", NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 1 && r.out && strstr(r.out, "fake crash"),
|
||||
"crash reported with the stderr tail");
|
||||
cst_tool_result_free(&r);
|
||||
n = cst_mcp_info(mi, CST_MCP_MAX);
|
||||
f = info_of("fake", mi, n);
|
||||
CHECK(f && f->state == CST_MCP_EXITED, "crashed server marked exited");
|
||||
call("mcp__fake__echo", "n", "1", &r);
|
||||
CHECK(r.exit_code == 1 && r.out && strstr(r.out, "not running"),
|
||||
"calls after a crash fail fast");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
cst_mcp_stop_all();
|
||||
CHECK(!cst_tool_find("mcp__fake__echo"), "stop unregisters tools");
|
||||
cst_mcp_counts(&ready, &total);
|
||||
CHECK(total == 0, "stop forgets configs");
|
||||
|
||||
/* a live server stops cleanly too */
|
||||
CHECK(cst_mcp_add_config_json("fake2", cfg, NULL, "user") == 0, "re-add");
|
||||
cst_mcp_start_all("/tmp");
|
||||
cst_mcp_wait_ready(10000);
|
||||
CHECK(cst_tool_find("mcp__fake2__echo"), "second server ready");
|
||||
t0 = os_now_ms();
|
||||
cst_mcp_stop_all();
|
||||
CHECK(os_now_ms() - t0 < 3000, "stop is prompt");
|
||||
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_mcp: %d failure(s)\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_mcp: OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* Plugins: plugin.json exec tools (stdin JSON, env, timeout, perm), skills
|
||||
* and MCP entries from plugins, and workspace trust. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_ext.h"
|
||||
#include "cst_mcp.h"
|
||||
#include "cst_plugins.h"
|
||||
#include "cst_skills.h"
|
||||
#include "cst_tools.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static char g_root[256];
|
||||
|
||||
static void put(const char *rel, const char *text) {
|
||||
char p[512];
|
||||
snprintf(p, sizeof(p), "%s/%s", g_root, rel);
|
||||
for (char *q = p + strlen(g_root) + 1; *q; q++) {
|
||||
if (*q == '/') {
|
||||
*q = 0;
|
||||
mkdir(p, 0755);
|
||||
*q = '/';
|
||||
}
|
||||
}
|
||||
FILE *fp = fopen(p, "w");
|
||||
if (fp) {
|
||||
fputs(text, fp);
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
static void run(const char *ws, const char *tool, const char *k1,
|
||||
const char *v1, const char *k2, const char *v2,
|
||||
cst_tool_result_t *r) {
|
||||
ctl_tool_call_t c;
|
||||
memset(&c, 0, sizeof(c));
|
||||
snprintf(c.name, sizeof(c.name), "%s", tool);
|
||||
if (k1) {
|
||||
snprintf(c.args[c.nargs].key, sizeof(c.args[0].key), "%s", k1);
|
||||
c.args[c.nargs].value = (char *)v1;
|
||||
c.args[c.nargs].value_len = strlen(v1);
|
||||
c.nargs++;
|
||||
}
|
||||
if (k2) {
|
||||
snprintf(c.args[c.nargs].key, sizeof(c.args[0].key), "%s", k2);
|
||||
c.args[c.nargs].value = (char *)v2;
|
||||
c.args[c.nargs].value_len = strlen(v2);
|
||||
c.nargs++;
|
||||
}
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = ws;
|
||||
memset(r, 0, sizeof(*r));
|
||||
cst_tool_run(&ctx, &c, r);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
snprintf(g_root, sizeof(g_root), "/tmp/cst-plugins-%d", (int)getpid());
|
||||
mkdir(g_root, 0755);
|
||||
char ws[300], cfgdir[300];
|
||||
snprintf(ws, sizeof(ws), "%s/ws", g_root);
|
||||
snprintf(cfgdir, sizeof(cfgdir), "%s/cfg", g_root);
|
||||
setenv("XDG_CONFIG_HOME", cfgdir, 1);
|
||||
|
||||
put("cfg/cst-agent/plugins/echo/plugin.json",
|
||||
"{\"name\": \"echo\", \"description\": \"Test plugin.\",\n"
|
||||
" \"tools\": [\n"
|
||||
" {\"name\": \"echo_args\", \"description\": \"Echo stdin.\",\n"
|
||||
" \"command\": [\"sh\", \"run.sh\", \"--flag\"], \"perm\": \"read\",\n"
|
||||
" \"parameters\": {\"type\": \"object\", \"properties\": {\n"
|
||||
" \"name\": {\"type\": \"string\"}, \"count\": {\"type\": "
|
||||
"\"integer\"},\n"
|
||||
" \"tags\": {\"type\": \"array\"}}, \"required\": [\"name\"]}},\n"
|
||||
" {\"name\": \"slow\", \"command\": \"sleep 5\", \"timeout\": 1},\n"
|
||||
" {\"name\": \"missing\", \"command\": [\"cst-no-such-cmd-xyz\"], "
|
||||
"\"perm\": \"write\"},\n"
|
||||
" {\"name\": \"piped\", \"command\": \"tr a-z A-Z | tr -d '{}'\", "
|
||||
"\"perm\": \"net\"},\n"
|
||||
" {\"name\": \"bad\"}\n"
|
||||
" ],\n"
|
||||
" \"mcpServers\": {\"plug-srv\": {\"command\": \"true\"}}}\n");
|
||||
put("cfg/cst-agent/plugins/echo/run.sh",
|
||||
"cat\necho\necho \"arg=$1 dir=$(basename \"$CST_PLUGIN_DIR\") "
|
||||
"tool=$CST_TOOL ws=$(basename \"$CST_WORKSPACE\")\"\n");
|
||||
put("cfg/cst-agent/plugins/echo/skills/plug-skill/SKILL.md",
|
||||
"---\nname: plug-skill\ndescription: Shipped by a plugin.\n---\nP\n");
|
||||
put("cfg/cst-agent/plugins/notaplugin/readme.txt", "no plugin.json\n");
|
||||
put("ws/.agent/plugins/local/plugin.json",
|
||||
"{\"tools\": [{\"name\": \"local_tool\", \"command\": \"echo local\"}]}");
|
||||
put("ws/keep.txt", "x\n");
|
||||
|
||||
int untrusted = -1;
|
||||
int n = cst_plugins_load(ws, cst_settings_trusted(ws), &untrusted);
|
||||
CHECK(n == 1, "one user plugin (workspace not trusted)");
|
||||
CHECK(untrusted == 1, "workspace plugin counted as untrusted");
|
||||
CHECK(!cst_tool_find("local_tool"), "untrusted workspace plugin not loaded");
|
||||
|
||||
const cst_tool_def_t *d = cst_tool_find("echo_args");
|
||||
CHECK(d && d->perm == CST_TP_READ, "perm read");
|
||||
d = cst_tool_find("slow");
|
||||
CHECK(d && d->perm == CST_TP_SHELL, "perm defaults to shell");
|
||||
d = cst_tool_find("missing");
|
||||
CHECK(d && d->perm == CST_TP_WRITE, "perm write");
|
||||
d = cst_tool_find("piped");
|
||||
CHECK(d && d->perm == CST_TP_NET, "perm net");
|
||||
CHECK(!cst_tool_find("bad"), "tool without command skipped");
|
||||
|
||||
cst_plugin_info_t info[CST_PLUGINS_MAX];
|
||||
int ni = cst_plugins_info(info, CST_PLUGINS_MAX);
|
||||
CHECK(ni == 1 && strcmp(info[0].name, "echo") == 0 && info[0].ntools == 4 &&
|
||||
info[0].nmcp == 1 && info[0].nskills_root == 1 &&
|
||||
strstr(info[0].error, "bad"),
|
||||
"plugin info");
|
||||
|
||||
cst_tool_result_t r;
|
||||
run(ws, "echo_args", "count", "3", "name", "x y", &r);
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "\"count\":3") &&
|
||||
strstr(r.out, "\"name\":\"x y\""),
|
||||
"typed JSON on stdin");
|
||||
CHECK(r.out && strstr(r.out, "arg=--flag dir=echo tool=echo_args ws=ws"),
|
||||
"argv and CST_* environment");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
run(ws, "echo_args", "count", "3", NULL, NULL, &r);
|
||||
CHECK(r.exit_code != 0 && r.out && strstr(r.out, "name"),
|
||||
"schema required enforced");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
run(ws, "piped", "name", "abc", NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "\"NAME\":\"ABC\""),
|
||||
"string command: whole pipeline reads stdin");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
run(ws, "slow", NULL, NULL, NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 124 && r.out && strstr(r.out, "timed out after 1 s"),
|
||||
"per-tool timeout");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
run(ws, "missing", NULL, NULL, NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 127 && r.out && strstr(r.out, "command not found"),
|
||||
"missing command hint");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
cst_skill_t sv[CST_SKILLS_MAX];
|
||||
int ns = cst_skills_list(ws, sv, CST_SKILLS_MAX);
|
||||
CHECK(ns == 1 && strcmp(sv[0].name, "plug-skill") == 0 &&
|
||||
strcmp(sv[0].source, "plugin:echo") == 0,
|
||||
"plugin skills root");
|
||||
|
||||
cst_mcp_info_t mi[CST_MCP_MAX];
|
||||
int nm = cst_mcp_info(mi, CST_MCP_MAX);
|
||||
CHECK(nm == 1 && strcmp(mi[0].name, "plug-srv") == 0 &&
|
||||
strcmp(mi[0].source, "plugin:echo") == 0,
|
||||
"plugin MCP server config");
|
||||
cst_mcp_stop_all();
|
||||
|
||||
cst_plugins_unload();
|
||||
CHECK(!cst_tool_find("echo_args"), "unload drops tools");
|
||||
CHECK(cst_skills_list(ws, sv, CST_SKILLS_MAX) == 0, "unload drops skills");
|
||||
|
||||
/* trust the workspace -> its plugin loads too */
|
||||
CHECK(cst_settings_trust(ws, 1) == 0 && cst_settings_trusted(ws),
|
||||
"trust saved");
|
||||
n = cst_plugins_load(ws, cst_settings_trusted(ws), &untrusted);
|
||||
CHECK(n == 2 && untrusted == 0 && cst_tool_find("local_tool"),
|
||||
"trusted workspace plugin loads");
|
||||
run(ws, "local_tool", NULL, NULL, NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "local"), "local tool");
|
||||
cst_tool_result_free(&r);
|
||||
cst_plugins_unload();
|
||||
cst_mcp_stop_all();
|
||||
CHECK(cst_settings_trust(ws, 0) == 0 && !cst_settings_trusted(ws),
|
||||
"untrust saved");
|
||||
|
||||
/* extensions: the minimal profile loads nothing */
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
|
||||
cfg.tool_profile = CST_TOOLS_MINIMAL;
|
||||
char rep[256];
|
||||
cst_extensions_load(&cfg, rep, sizeof(rep));
|
||||
CHECK(!rep[0] && !cst_tool_find("echo_args"), "minimal: no extensions");
|
||||
cfg.tool_profile = CST_TOOLS_DEFAULT;
|
||||
cst_extensions_load(&cfg, rep, sizeof(rep));
|
||||
CHECK(strstr(rep, "1 skill") && strstr(rep, "1 plugin (4 tools)") &&
|
||||
strstr(rep, "untrusted"),
|
||||
"extensions report");
|
||||
CHECK(cst_tool_find("skill") && cst_tool_find("echo_args"),
|
||||
"extensions register tools");
|
||||
cst_extensions_unload();
|
||||
CHECK(!cst_tool_find("skill") && !cst_tool_find("echo_args"),
|
||||
"extensions unload");
|
||||
|
||||
char cmd[400];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_root);
|
||||
if (system(cmd) != 0)
|
||||
fprintf(stderr, "note: cleanup failed\n");
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_plugins: %d failure(s)\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_plugins: OK\n");
|
||||
return 0;
|
||||
}
|
||||
+96
-1
@@ -28,7 +28,102 @@ static int schema_has(cJSON *arr, const char *name) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int echo_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
const cst_tool_def_t *self = ud;
|
||||
char *j = cst_tool_args_json(self, c);
|
||||
r->out = j;
|
||||
r->out_len = j ? strlen(j) : 0;
|
||||
r->exit_code = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void dynamic_tools(void) {
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
int base = cst_tool_count();
|
||||
cst_tool_def_t d;
|
||||
memset(&d, 0, sizeof(d));
|
||||
d.name = "dyn_echo";
|
||||
d.desc = "Echo the arguments.";
|
||||
d.schema_json = "{\"type\":\"object\",\"properties\":{\"text\":{"
|
||||
"\"type\":\"string\"},\"n\":{\"type\":\"integer\"},"
|
||||
"\"opts\":{\"type\":\"object\"}},\"required\":[\"text\"]}";
|
||||
d.perm = CST_TP_READ;
|
||||
d.run_ex = echo_run;
|
||||
d.owner = "test:echo";
|
||||
EXPECT(cst_tool_register(&d) == 0, "register");
|
||||
EXPECT(cst_tool_register(&d) != 0, "duplicate name refused");
|
||||
EXPECT(cst_tool_count() == base + 1, "count grows");
|
||||
const cst_tool_def_t *t = cst_tool_find("dyn_echo");
|
||||
EXPECT(t && t != &d && strcmp(t->owner, "test:echo") == 0, "copied def");
|
||||
/* the runner gets its own def as ud for typing */
|
||||
((cst_tool_def_t *)t)->ud = (void *)t;
|
||||
|
||||
cJSON *sc = cst_tools_schema_cfg(&cfg);
|
||||
char *ss = cJSON_PrintUnformatted(sc);
|
||||
EXPECT(ss && strstr(ss, "\"name\":\"dyn_echo\"") &&
|
||||
strstr(ss, "\"required\":[\"text\"]"),
|
||||
"schema passes the JSON Schema through");
|
||||
free(ss);
|
||||
cJSON_Delete(sc);
|
||||
char *pr = cst_tools_prompt(&cfg);
|
||||
EXPECT(pr && strstr(pr, "- dyn_echo(text, n?, opts?): Echo the arguments."),
|
||||
"text prompt lists schema params");
|
||||
free(pr);
|
||||
|
||||
ctl_tool_call_t c;
|
||||
memset(&c, 0, sizeof(c));
|
||||
snprintf(c.name, sizeof(c.name), "dyn_echo");
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
cst_tool_result_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
cst_tool_run(&ctx, &c, &r);
|
||||
EXPECT(r.exit_code == 1 && r.out && strstr(r.out, "needs argument \"text\""),
|
||||
"schema required checked");
|
||||
cst_tool_result_free(&r);
|
||||
c.nargs = 3;
|
||||
snprintf(c.args[0].key, sizeof(c.args[0].key), "text");
|
||||
c.args[0].value = "42";
|
||||
c.args[0].value_len = 2;
|
||||
snprintf(c.args[1].key, sizeof(c.args[1].key), "n");
|
||||
c.args[1].value = "7";
|
||||
c.args[1].value_len = 1;
|
||||
snprintf(c.args[2].key, sizeof(c.args[2].key), "opts");
|
||||
c.args[2].value = "{\"a\":true}";
|
||||
c.args[2].value_len = strlen(c.args[2].value);
|
||||
memset(&r, 0, sizeof(r));
|
||||
cst_tool_run(&ctx, &c, &r);
|
||||
EXPECT(r.exit_code == 0 && r.out &&
|
||||
strcmp(r.out, "{\"text\":\"42\",\"n\":7,\"opts\":{\"a\":true}}") == 0,
|
||||
"args typed by schema");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
cst_tool_set_disabled(&cfg, "dyn_echo", 1);
|
||||
cst_tool_set_disabled(&cfg, "grep", 1);
|
||||
EXPECT(!cst_tool_active(&cfg, t) &&
|
||||
!cst_tool_active(&cfg, cst_tool_find("grep")),
|
||||
"disable by name");
|
||||
cst_tool_set_disabled(&cfg, "dyn_echo", 0);
|
||||
EXPECT(cst_tool_active(&cfg, t) &&
|
||||
!cst_tool_active(&cfg, cst_tool_find("grep")),
|
||||
"re-enable one name");
|
||||
cst_tool_set_disabled(&cfg, "grep", 0);
|
||||
EXPECT(cfg.tools_off[0] == 0, "set empties");
|
||||
|
||||
char nm[64];
|
||||
cst_tool_sanitize_name("mcp__my server__do.it", nm, sizeof(nm));
|
||||
EXPECT(strcmp(nm, "mcp__my_server__do_it") == 0, "sanitize");
|
||||
|
||||
EXPECT(cst_tool_unregister_owner("test:echo") == 1 &&
|
||||
!cst_tool_find("dyn_echo") && cst_tool_count() == base,
|
||||
"unregister by owner");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
dynamic_tools();
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
|
||||
@@ -85,7 +180,7 @@ int main(void) {
|
||||
free(pdef);
|
||||
|
||||
/* /tools off: grep disappears everywhere */
|
||||
cfg.tools_off = 1ULL << cst_tool_index("grep");
|
||||
cst_tool_set_disabled(&cfg, "grep", 1);
|
||||
EXPECT(!cst_tool_active(&cfg, cst_tool_find("grep")), "grep off");
|
||||
cJSON *off = cst_tools_schema_cfg(&cfg);
|
||||
EXPECT(!schema_has(off, "grep") && schema_has(off, "glob"), "schema off");
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* cst_shell_compact: ANSI stripping, \r progress folding, repeated-line
|
||||
* collapse and head/tail cutting of run_shell output. */
|
||||
#include "cst_tools.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static char *compact(const char *s) {
|
||||
size_t n = 0;
|
||||
return cst_shell_compact(s, strlen(s), &n);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char *o;
|
||||
|
||||
o = compact("plain\noutput\n");
|
||||
CHECK(o && strcmp(o, "plain\noutput\n") == 0, "clean output unchanged");
|
||||
free(o);
|
||||
|
||||
o = compact("no newline");
|
||||
CHECK(o && strcmp(o, "no newline") == 0, "missing final newline kept");
|
||||
free(o);
|
||||
|
||||
o = compact("");
|
||||
CHECK(o && strcmp(o, "") == 0, "empty");
|
||||
free(o);
|
||||
|
||||
o = compact("a\n\nb\n");
|
||||
CHECK(o && strcmp(o, "a\n\nb\n") == 0, "blank lines kept");
|
||||
free(o);
|
||||
|
||||
o = compact("\x1b[1;31merror\x1b[0m: bad\n\x1b]0;title\x07ok\n");
|
||||
CHECK(o && strcmp(o, "error: bad\nok\n") == 0, "ANSI CSI and OSC removed");
|
||||
free(o);
|
||||
|
||||
o = compact(" 0%\r 50%\r100%\ndone\r\n");
|
||||
CHECK(o && strcmp(o, "100%\ndone\n") == 0, "\\r progress folded, CRLF ok");
|
||||
free(o);
|
||||
|
||||
o = compact("ab\bc\n");
|
||||
CHECK(o && strcmp(o, "ac\n") == 0, "backspace applied");
|
||||
free(o);
|
||||
|
||||
o = compact("x\nx\ny\n");
|
||||
CHECK(o && strcmp(o, "x\nx\ny\n") == 0, "a pair is kept as is");
|
||||
free(o);
|
||||
|
||||
o = compact("start\nwarn\nwarn\nwarn\nwarn\nend\n");
|
||||
CHECK(o && strcmp(o, "start\nwarn\n… (previous line repeated 3 more "
|
||||
"times)\nend\n") == 0,
|
||||
"runs collapsed");
|
||||
free(o);
|
||||
|
||||
{
|
||||
char *big = malloc(1000 * 16);
|
||||
size_t used = 0;
|
||||
for (int i = 1; i <= 1000; i++)
|
||||
used += (size_t)sprintf(big + used, "line %d\n", i);
|
||||
o = compact(big);
|
||||
CHECK(o && strstr(o, "line 1\n") && strstr(o, "line 80\n") &&
|
||||
!strstr(o, "line 81\n") && strstr(o, "… 800 lines omitted …\n") &&
|
||||
strstr(o, "line 881\n") && strstr(o, "line 1000\n"),
|
||||
"head + tail with omitted count");
|
||||
free(o);
|
||||
free(big);
|
||||
}
|
||||
|
||||
{
|
||||
char *wide = malloc(5000 + 2);
|
||||
memset(wide, 'w', 5000);
|
||||
wide[5000] = '\n';
|
||||
wide[5001] = 0;
|
||||
o = compact(wide);
|
||||
CHECK(o && strstr(o, " … [+3000 bytes]\n") && strlen(o) < 2100,
|
||||
"long line cut");
|
||||
free(o);
|
||||
free(wide);
|
||||
}
|
||||
|
||||
{
|
||||
/* 200 lines of 1 KB: over the byte budget, the tail wins */
|
||||
char *fat = malloc(200 * 1030);
|
||||
size_t used = 0;
|
||||
for (int i = 1; i <= 200; i++) {
|
||||
used += (size_t)sprintf(fat + used, "%04d ", i);
|
||||
memset(fat + used, 'f', 1000);
|
||||
used += 1000;
|
||||
fat[used++] = '\n';
|
||||
}
|
||||
fat[used] = 0;
|
||||
o = compact(fat);
|
||||
CHECK(o && strlen(o) <= 42 * 1024 && strstr(o, "0200 ") &&
|
||||
strstr(o, "lines omitted"),
|
||||
"byte budget keeps the tail");
|
||||
free(o);
|
||||
free(fat);
|
||||
}
|
||||
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_shell_compact: %d failure(s)\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_shell_compact: OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Skills: frontmatter, discovery order, prompt index, the skill tool and
|
||||
* path containment. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_skills.h"
|
||||
#include "cst_tools.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static char g_root[256];
|
||||
|
||||
static void put(const char *rel, const char *text) {
|
||||
char p[512];
|
||||
snprintf(p, sizeof(p), "%s/%s", g_root, rel);
|
||||
for (char *q = p + strlen(g_root) + 1; *q; q++) {
|
||||
if (*q == '/') {
|
||||
*q = 0;
|
||||
mkdir(p, 0755);
|
||||
*q = '/';
|
||||
}
|
||||
}
|
||||
FILE *fp = fopen(p, "w");
|
||||
if (fp) {
|
||||
fputs(text, fp);
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
static void frontmatter(void) {
|
||||
char v[256];
|
||||
const char *t = "---\nname: \"pdf-tools\"\ndescription: >\n Fill PDF\n "
|
||||
"forms.\ntools: [read_file, grep ]\nmodel: deepseek-pro\n"
|
||||
"list:\n - a\n - b\nlit: |\n one\n two\n---\n\n# Body\n";
|
||||
CHECK(cst_fm_get(t, "name", v, sizeof(v)) && strcmp(v, "pdf-tools") == 0,
|
||||
"quoted value");
|
||||
CHECK(cst_fm_get(t, "description", v, sizeof(v)) &&
|
||||
strcmp(v, "Fill PDF forms.") == 0,
|
||||
"folded value");
|
||||
CHECK(cst_fm_get(t, "tools", v, sizeof(v)) &&
|
||||
strcmp(v, "read_file,grep") == 0,
|
||||
"flow list");
|
||||
CHECK(cst_fm_get(t, "list", v, sizeof(v)) && strcmp(v, "a,b") == 0,
|
||||
"block list");
|
||||
CHECK(cst_fm_get(t, "lit", v, sizeof(v)) && strcmp(v, "one\ntwo") == 0,
|
||||
"literal block");
|
||||
CHECK(!cst_fm_get(t, "nope", v, sizeof(v)), "missing key");
|
||||
CHECK(strcmp(cst_fm_body(t), "# Body\n") == 0, "body after fence");
|
||||
CHECK(strcmp(cst_fm_body("no frontmatter\n"), "no frontmatter\n") == 0,
|
||||
"no frontmatter: whole text");
|
||||
CHECK(!cst_fm_get("---\nname: x\n", "name", v, sizeof(v)),
|
||||
"unclosed frontmatter ignored");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
frontmatter();
|
||||
|
||||
snprintf(g_root, sizeof(g_root), "/tmp/cst-skills-%d", (int)getpid());
|
||||
mkdir(g_root, 0755);
|
||||
char ws[300], cfgdir[300];
|
||||
snprintf(ws, sizeof(ws), "%s/ws", g_root);
|
||||
snprintf(cfgdir, sizeof(cfgdir), "%s/cfg", g_root);
|
||||
setenv("XDG_CONFIG_HOME", cfgdir, 1);
|
||||
|
||||
put("ws/.agent/skills/zeta/SKILL.md",
|
||||
"---\nname: zeta\ndescription: Last one alphabetically.\n---\nDo Z.\n");
|
||||
put("ws/.agent/skills/forms/SKILL.md",
|
||||
"---\nname: pdf-forms\ndescription: Fill PDF forms with the bundled "
|
||||
"script.\n---\n# PDF forms\n\nRun scripts/fill.py.\n");
|
||||
put("ws/.agent/skills/forms/scripts/fill.py", "print('fill')\n");
|
||||
put("ws/.agent/skills/forms/reference.md", "Field names...\n");
|
||||
put("ws/.claude/skills/plain/SKILL.md", "# Plain\n\nA skill without "
|
||||
"frontmatter.\n");
|
||||
put("cfg/cst-agent/skills/forms2/SKILL.md",
|
||||
"---\nname: pdf-forms\ndescription: shadowed by the workspace\n---\n");
|
||||
put("cfg/cst-agent/skills/userskill/SKILL.md",
|
||||
"---\nname: user-skill\ndescription: From the config dir.\n---\nU\n");
|
||||
put("ws/secret.txt", "top secret\n");
|
||||
{
|
||||
char link[512], target[512];
|
||||
snprintf(link, sizeof(link), "%s/ws/.agent/skills/forms/leak", g_root);
|
||||
snprintf(target, sizeof(target), "%s/ws/secret.txt", g_root);
|
||||
if (symlink(target, link) != 0)
|
||||
perror("symlink");
|
||||
}
|
||||
|
||||
cst_skill_t v[CST_SKILLS_MAX];
|
||||
int n = cst_skills_list(ws, v, CST_SKILLS_MAX);
|
||||
CHECK(n == 4, "four distinct skills");
|
||||
CHECK(n == 4 && strcmp(v[0].name, "pdf-forms") == 0 &&
|
||||
strcmp(v[1].name, "plain") == 0 &&
|
||||
strcmp(v[2].name, "user-skill") == 0 &&
|
||||
strcmp(v[3].name, "zeta") == 0,
|
||||
"sorted by name");
|
||||
CHECK(n == 4 && strcmp(v[0].source, "workspace") == 0 &&
|
||||
strstr(v[0].desc, "bundled script"),
|
||||
"workspace wins over the config dir");
|
||||
CHECK(n == 4 && strcmp(v[1].source, "claude") == 0 &&
|
||||
strcmp(v[1].desc, "A skill without frontmatter.") == 0,
|
||||
".claude skill, description from the body");
|
||||
|
||||
char *ix = cst_skills_index(ws);
|
||||
CHECK(ix && strstr(ix, "- pdf-forms: Fill PDF forms") &&
|
||||
strstr(ix, "- zeta: Last one"),
|
||||
"index lists name: description");
|
||||
free(ix);
|
||||
|
||||
int code = -1;
|
||||
char *out = cst_skill_read(ws, "pdf-forms", NULL, &code);
|
||||
CHECK(code == 0 && out && strstr(out, "# PDF forms\n\nRun scripts/fill.py.") &&
|
||||
!strstr(out, "description:"),
|
||||
"SKILL.md body without frontmatter");
|
||||
CHECK(out && strstr(out, "- reference.md\n") &&
|
||||
strstr(out, "- scripts/fill.py\n"),
|
||||
"file list");
|
||||
free(out);
|
||||
out = cst_skill_read(ws, "pdf-forms", "scripts/fill.py", &code);
|
||||
CHECK(code == 0 && out && strstr(out, "print('fill')"), "read a skill file");
|
||||
free(out);
|
||||
out = cst_skill_read(ws, "pdf-forms", "../../../secret.txt", &code);
|
||||
CHECK(code == 1 && out && !strstr(out, "top secret"), "no ../ escape");
|
||||
free(out);
|
||||
out = cst_skill_read(ws, "pdf-forms", "leak", &code);
|
||||
CHECK(code == 1 && out && !strstr(out, "top secret"), "no symlink escape");
|
||||
free(out);
|
||||
out = cst_skill_read(ws, "missing", NULL, &code);
|
||||
CHECK(code == 1 && out && strstr(out, "available: pdf-forms, plain"),
|
||||
"unknown skill lists names");
|
||||
free(out);
|
||||
|
||||
char *pr = cst_skill_prompt(ws, "zeta", "do the thing");
|
||||
CHECK(pr && strstr(pr, "<skill>") && strstr(pr, "Do Z.") &&
|
||||
strstr(pr, "do the thing"),
|
||||
"/skill prompt inlines the instructions");
|
||||
free(pr);
|
||||
|
||||
/* tool + system prompt */
|
||||
CHECK(cst_skills_register_tool() == 0 && cst_tool_find("skill"),
|
||||
"skill tool registered");
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
|
||||
ctl_tool_call_t c;
|
||||
memset(&c, 0, sizeof(c));
|
||||
snprintf(c.name, sizeof(c.name), "skill");
|
||||
c.nargs = 1;
|
||||
snprintf(c.args[0].key, sizeof(c.args[0].key), "name");
|
||||
c.args[0].value = "user-skill";
|
||||
c.args[0].value_len = strlen(c.args[0].value);
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = ws;
|
||||
cst_tool_result_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
cst_tool_run(&ctx, &c, &r);
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "skill user-skill (user)"),
|
||||
"skill tool runs");
|
||||
cst_tool_result_free(&r);
|
||||
char *sp = cst_agent_build_prompt(&cfg, CST_PROTO_NATIVE_EOS, NULL);
|
||||
CHECK(sp && strstr(sp, "Skills (call skill(name)") &&
|
||||
strstr(sp, "- user-skill: From the config dir."),
|
||||
"system prompt carries the index");
|
||||
free(sp);
|
||||
cfg.tool_profile = CST_TOOLS_MINIMAL;
|
||||
sp = cst_agent_build_prompt(&cfg, CST_PROTO_NATIVE_EOS, NULL);
|
||||
CHECK(sp && !strstr(sp, "Skills ("), "minimal prompt unchanged");
|
||||
free(sp);
|
||||
|
||||
char cmd[400];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_root);
|
||||
if (system(cmd) != 0)
|
||||
fprintf(stderr, "note: cleanup failed\n");
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_skills: %d failure(s)\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_skills: OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
/* Sub-agents end to end against a local fake OpenAI-style SSE server: the
|
||||
* parent calls task twice (explore, in parallel), each child reads a file
|
||||
* and reports, the parent sees both reports. Also: tool restrictions in the
|
||||
* child, progress hooks, transcripts, roles from .agent/agents, depth
|
||||
* limit, unknown roles and cancellation. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "cst_subagent.h"
|
||||
#include "cst_tools.h"
|
||||
#include "cst_util.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <curl/curl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* ---------- fake server ---------- */
|
||||
|
||||
static atomic_int g_child_active, g_child_max, g_child_reqs;
|
||||
static atomic_int g_child_saw_task, g_child_saw_write, g_child_role_prompt;
|
||||
static atomic_int g_parent_saw_task;
|
||||
|
||||
static void send_all(int fd, const char *s, size_t n) {
|
||||
while (n > 0) {
|
||||
ssize_t w = send(fd, s, n, MSG_NOSIGNAL);
|
||||
if (w <= 0)
|
||||
return;
|
||||
s += w;
|
||||
n -= (size_t)w;
|
||||
}
|
||||
}
|
||||
|
||||
static void sse(int fd, cJSON *delta, const char *finish) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON *ch = cJSON_AddArrayToObject(o, "choices");
|
||||
cJSON *c0 = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(c0, "index", 0);
|
||||
cJSON_AddItemToObject(c0, "delta", delta);
|
||||
if (finish)
|
||||
cJSON_AddStringToObject(c0, "finish_reason", finish);
|
||||
else
|
||||
cJSON_AddNullToObject(c0, "finish_reason");
|
||||
cJSON_AddItemToArray(ch, c0);
|
||||
if (finish) {
|
||||
cJSON *u = cJSON_AddObjectToObject(o, "usage");
|
||||
cJSON_AddNumberToObject(u, "prompt_tokens", 1200);
|
||||
cJSON_AddNumberToObject(u, "completion_tokens", 20);
|
||||
}
|
||||
char *txt = cJSON_PrintUnformatted(o);
|
||||
char *line = malloc(strlen(txt) + 16);
|
||||
int n = sprintf(line, "data: %s\n\n", txt);
|
||||
send_all(fd, line, (size_t)n);
|
||||
free(line);
|
||||
free(txt);
|
||||
cJSON_Delete(o);
|
||||
}
|
||||
|
||||
static void reply_text(int fd, const char *text) {
|
||||
cJSON *d = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(d, "role", "assistant");
|
||||
cJSON_AddStringToObject(d, "content", text);
|
||||
sse(fd, d, NULL);
|
||||
sse(fd, cJSON_CreateObject(), "stop");
|
||||
send_all(fd, "data: [DONE]\n\n", 14);
|
||||
}
|
||||
|
||||
/* calls: {name, args_json} pairs */
|
||||
static void reply_calls(int fd, const char *const *calls, int n) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *d = cJSON_CreateObject();
|
||||
cJSON *tc = cJSON_AddArrayToObject(d, "tool_calls");
|
||||
cJSON *c = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(c, "index", i);
|
||||
char id[16];
|
||||
snprintf(id, sizeof(id), "call_%d", i);
|
||||
cJSON_AddStringToObject(c, "id", id);
|
||||
cJSON_AddStringToObject(c, "type", "function");
|
||||
cJSON *f = cJSON_AddObjectToObject(c, "function");
|
||||
cJSON_AddStringToObject(f, "name", calls[2 * i]);
|
||||
cJSON_AddStringToObject(f, "arguments", calls[2 * i + 1]);
|
||||
cJSON_AddItemToArray(tc, c);
|
||||
sse(fd, d, NULL);
|
||||
}
|
||||
sse(fd, cJSON_CreateObject(), "tool_calls");
|
||||
send_all(fd, "data: [DONE]\n\n", 14);
|
||||
}
|
||||
|
||||
static int has_tool(const cJSON *body, const char *name) {
|
||||
const cJSON *t = NULL;
|
||||
cJSON_ArrayForEach(t, cJSON_GetObjectItemCaseSensitive(body, "tools")) {
|
||||
const cJSON *f = cJSON_GetObjectItemCaseSensitive(t, "function");
|
||||
const cJSON *n = cJSON_GetObjectItemCaseSensitive(f, "name");
|
||||
if (cJSON_IsString(n) && strcmp(n->valuestring, name) == 0)
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void handle(int fd, const char *body_txt) {
|
||||
cJSON *body = cJSON_Parse(body_txt);
|
||||
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(body, "messages");
|
||||
int nm = cJSON_GetArraySize(msgs);
|
||||
const cJSON *sys = cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetArrayItem(msgs, 0), "content");
|
||||
const cJSON *last = cJSON_GetArrayItem(msgs, nm - 1);
|
||||
const char *last_role =
|
||||
cJSON_GetObjectItemCaseSensitive(last, "role")->valuestring;
|
||||
const char *user = "";
|
||||
for (int i = 0; i < nm; i++) {
|
||||
const cJSON *m = cJSON_GetArrayItem(msgs, i);
|
||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(m, "role");
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
|
||||
if (strcmp(r->valuestring, "user") == 0 && cJSON_IsString(c))
|
||||
user = c->valuestring;
|
||||
}
|
||||
const char *hdr = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n"
|
||||
"Connection: close\r\n\r\n";
|
||||
send_all(fd, hdr, strlen(hdr));
|
||||
int child = cJSON_IsString(sys) && strstr(sys->valuestring, "SUB-AGENT");
|
||||
if (child) {
|
||||
atomic_fetch_add(&g_child_reqs, 1);
|
||||
int now = atomic_fetch_add(&g_child_active, 1) + 1;
|
||||
int mx = atomic_load(&g_child_max);
|
||||
while (now > mx && !atomic_compare_exchange_weak(&g_child_max, &mx, now))
|
||||
;
|
||||
if (has_tool(body, "task"))
|
||||
atomic_store(&g_child_saw_task, 1);
|
||||
if (has_tool(body, "write_file") || has_tool(body, "run_shell"))
|
||||
atomic_store(&g_child_saw_write, 1);
|
||||
if (strstr(sys->valuestring, "Role instructions:\nYou only read."))
|
||||
atomic_store(&g_child_role_prompt, 1);
|
||||
if (strstr(user, "Slow")) {
|
||||
usleep(4000 * 1000); /* cancelled before this ends */
|
||||
} else {
|
||||
usleep(300 * 1000);
|
||||
}
|
||||
if (strcmp(last_role, "user") == 0) {
|
||||
const char *calls[] = {"read_file", "{\"path\":\"a.txt\"}"};
|
||||
reply_calls(fd, calls, 1);
|
||||
} else {
|
||||
char out[256];
|
||||
snprintf(out, sizeof(out), "child report %s", user + strlen(user) - 1);
|
||||
reply_text(fd, out);
|
||||
}
|
||||
atomic_fetch_sub(&g_child_active, 1);
|
||||
} else {
|
||||
if (has_tool(body, "task"))
|
||||
atomic_store(&g_parent_saw_task, 1);
|
||||
if (strcmp(last_role, "user") == 0 && strstr(user, "cancel test")) {
|
||||
const char *calls[] = {
|
||||
"task", "{\"description\":\"slow one\",\"prompt\":\"Slow\","
|
||||
"\"agent\":\"explore\"}"};
|
||||
reply_calls(fd, calls, 1);
|
||||
} else if (strcmp(last_role, "user") == 0) {
|
||||
const char *calls[] = {
|
||||
"task",
|
||||
"{\"description\":\"find A\",\"prompt\":\"Find A\","
|
||||
"\"agent\":\"explore\"}",
|
||||
"task",
|
||||
"{\"description\":\"find B\",\"prompt\":\"Find B\","
|
||||
"\"agent\":\"explore\"}"};
|
||||
reply_calls(fd, calls, 2);
|
||||
} else {
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_puts(&b, "parent done:");
|
||||
for (int i = 0; i < nm; i++) {
|
||||
const cJSON *m = cJSON_GetArrayItem(msgs, i);
|
||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(m, "role");
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
|
||||
const char *rep = cJSON_IsString(c) ? strstr(c->valuestring,
|
||||
"child report ")
|
||||
: NULL;
|
||||
if (strcmp(r->valuestring, "tool") == 0 && rep)
|
||||
cst_buf_printf(&b, " [%.14s]", rep);
|
||||
}
|
||||
reply_text(fd, b.buf);
|
||||
cst_buf_free(&b);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(body);
|
||||
}
|
||||
|
||||
static void *conn_main(void *p) {
|
||||
int fd = (int)(intptr_t)p;
|
||||
cst_buf_t req = {0};
|
||||
char tmp[8192];
|
||||
long need = -1;
|
||||
size_t hdr_end = 0;
|
||||
for (;;) {
|
||||
ssize_t n = recv(fd, tmp, sizeof(tmp), 0);
|
||||
if (n <= 0)
|
||||
break;
|
||||
cst_buf_append(&req, tmp, (size_t)n);
|
||||
if (!hdr_end) {
|
||||
char *e = strstr(req.buf, "\r\n\r\n");
|
||||
if (e) {
|
||||
hdr_end = (size_t)(e - req.buf) + 4;
|
||||
char *cl = strcasestr(req.buf, "Content-Length:");
|
||||
need = cl ? atol(cl + 15) : 0;
|
||||
}
|
||||
}
|
||||
if (hdr_end && (long)(req.len - hdr_end) >= need)
|
||||
break;
|
||||
}
|
||||
if (hdr_end)
|
||||
handle(fd, req.buf + hdr_end);
|
||||
cst_buf_free(&req);
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int g_lfd;
|
||||
|
||||
static void *server_main(void *p) {
|
||||
(void)p;
|
||||
for (;;) {
|
||||
int fd = accept(g_lfd, NULL, NULL);
|
||||
if (fd < 0)
|
||||
return NULL;
|
||||
pthread_t th;
|
||||
if (pthread_create(&th, NULL, conn_main, (void *)(intptr_t)fd) == 0)
|
||||
pthread_detach(th);
|
||||
else
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- client ---------- */
|
||||
|
||||
static atomic_int g_progress;
|
||||
static char g_progress_last[256];
|
||||
|
||||
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
|
||||
const char *detail, void *ud) {
|
||||
(void)call;
|
||||
(void)ud;
|
||||
if (strcmp(phase, "progress") == 0 && detail) {
|
||||
atomic_fetch_add(&g_progress, 1);
|
||||
if (strstr(detail, "read_file"))
|
||||
snprintf(g_progress_last, sizeof(g_progress_last), "%s", detail);
|
||||
}
|
||||
}
|
||||
|
||||
static const char *last_assistant(cst_agent_t *a) {
|
||||
cst_session_t *s = cst_agent_session(a);
|
||||
for (int i = cst_session_count(s) - 1; i >= 0; i--) {
|
||||
const cst_message_t *m = cst_session_get(s, i);
|
||||
if (m->role == CST_ROLE_ASSISTANT && m->content && m->content[0])
|
||||
return m->content;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static void put(const char *root, const char *rel, const char *text) {
|
||||
char p[1024];
|
||||
snprintf(p, sizeof(p), "%s/%s", root, rel);
|
||||
for (char *q = p + strlen(root) + 1; *q; q++)
|
||||
if (*q == '/') {
|
||||
*q = 0;
|
||||
mkdir(p, 0755);
|
||||
*q = '/';
|
||||
}
|
||||
FILE *fp = fopen(p, "w");
|
||||
if (fp) {
|
||||
fputs(text, fp);
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
static void *cancel_later(void *p) {
|
||||
usleep(800 * 1000);
|
||||
cst_agent_request_cancel(p);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
g_lfd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
socklen_t al = sizeof(addr);
|
||||
if (bind(g_lfd, (struct sockaddr *)&addr, sizeof(addr)) != 0 ||
|
||||
listen(g_lfd, 16) != 0 ||
|
||||
getsockname(g_lfd, (struct sockaddr *)&addr, &al) != 0) {
|
||||
perror("listen");
|
||||
return 1;
|
||||
}
|
||||
pthread_t srv;
|
||||
pthread_create(&srv, NULL, server_main, NULL);
|
||||
|
||||
char root[256], ws[300];
|
||||
snprintf(root, sizeof(root), "/tmp/cst-sub-%d", (int)getpid());
|
||||
snprintf(ws, sizeof(ws), "%s/ws", root);
|
||||
mkdir(root, 0755);
|
||||
setenv("XDG_CONFIG_HOME", root, 1);
|
||||
put(root, "ws/a.txt", "alpha\n");
|
||||
put(root, "ws/.agent/agents/reviewer.md",
|
||||
"---\nname: reviewer\ndescription: Reviews diffs.\ntools: read_file, "
|
||||
"grep\nreadonly: true\nmax_steps: 4\n---\nReview carefully.\n");
|
||||
put(root, "ws/.agent/agents/explore.md",
|
||||
"---\ndescription: Custom explorer.\nreadonly: yes\n---\nYou only "
|
||||
"read. Custom.\n");
|
||||
put(root, "ws/.agent/sessions/.keep", "");
|
||||
|
||||
/* roles */
|
||||
cst_agent_role_t roles[CST_ROLES_MAX];
|
||||
int nr = cst_roles_list(ws, roles, CST_ROLES_MAX);
|
||||
CHECK(nr == 4, "three built-ins + reviewer (explore overridden)");
|
||||
CHECK(nr == 4 && strcmp(roles[0].name, "general") == 0 &&
|
||||
strcmp(roles[3].name, "reviewer") == 0 &&
|
||||
strcmp(roles[3].tools, ",read_file,grep,") == 0 &&
|
||||
roles[3].read_only && roles[3].max_steps == 4 &&
|
||||
strcmp(roles[3].prompt, "Review carefully.\n") == 0,
|
||||
"custom role parsed");
|
||||
CHECK(nr == 4 && strcmp(roles[1].name, "explore") == 0 &&
|
||||
strcmp(roles[1].source, "workspace") == 0 &&
|
||||
strcmp(roles[1].desc, "Custom explorer.") == 0,
|
||||
"workspace overrides a built-in");
|
||||
cst_roles_free(roles, nr);
|
||||
char *ix = cst_subagent_index(ws);
|
||||
CHECK(ix && strstr(ix, "- reviewer (read-only): Reviews diffs.") &&
|
||||
strstr(ix, "- general: "),
|
||||
"roles index");
|
||||
free(ix);
|
||||
|
||||
CHECK(cst_subagent_register_tool(ws) == 0 && cst_tool_find("task"),
|
||||
"task registered");
|
||||
const cst_tool_def_t *td = cst_tool_find("task");
|
||||
ctl_tool_call_t pc;
|
||||
memset(&pc, 0, sizeof(pc));
|
||||
snprintf(pc.name, sizeof(pc.name), "task");
|
||||
pc.nargs = 1;
|
||||
snprintf(pc.args[0].key, sizeof(pc.args[0].key), "agent");
|
||||
pc.args[0].value = "explore";
|
||||
CHECK(td->parallel && td->parallel(&pc, td->ud), "explore runs in parallel");
|
||||
pc.args[0].value = "general";
|
||||
CHECK(!td->parallel(&pc, td->ud), "general does not");
|
||||
pc.args[0].value = "reviewer";
|
||||
CHECK(td->parallel(&pc, td->ud), "read-only custom role does");
|
||||
|
||||
/* parent agent */
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path),
|
||||
"%s/.agent/sessions/parent1.jsonl", ws);
|
||||
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://127.0.0.1:%d",
|
||||
ntohs(addr.sin_port));
|
||||
snprintf(cfg.model, sizeof(cfg.model), "m");
|
||||
cfg.protocol = CST_PROTO_NATIVE_EOS;
|
||||
cfg.max_steps = 6;
|
||||
cfg.auto_approve = 1;
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
CHECK(a != NULL, "parent created");
|
||||
cst_agent_hooks_t h;
|
||||
memset(&h, 0, sizeof(h));
|
||||
h.on_tool_event = on_tool_event;
|
||||
char err[512];
|
||||
double t0 = os_now_ms();
|
||||
int rc = cst_agent_run_turn_ex(a, "go", &h, err, sizeof(err));
|
||||
double ms = os_now_ms() - t0;
|
||||
CHECK(rc == 0, err);
|
||||
const char *fin = last_assistant(a);
|
||||
CHECK(strstr(fin, "[child report A") && strstr(fin, "[child report B"),
|
||||
"parent got both reports");
|
||||
CHECK(atomic_load(&g_parent_saw_task), "parent offered task");
|
||||
CHECK(!atomic_load(&g_child_saw_task), "child not offered task");
|
||||
CHECK(!atomic_load(&g_child_saw_write), "explore child has no write/shell");
|
||||
CHECK(atomic_load(&g_child_role_prompt), "role instructions in the prompt");
|
||||
CHECK(atomic_load(&g_child_max) == 2, "two children ran at once");
|
||||
CHECK(atomic_load(&g_child_reqs) == 4, "two model calls per child");
|
||||
CHECK(ms < 2400, "parallel children overlap in time");
|
||||
CHECK(atomic_load(&g_progress) >= 4 &&
|
||||
strstr(g_progress_last, "explore · read_file a.txt"),
|
||||
"progress events");
|
||||
|
||||
cst_subagent_rec_t recs[8];
|
||||
int nrec = cst_subagent_list(ws, "parent1", recs, 8);
|
||||
CHECK(nrec == 2 && strcmp(recs[0].file, "sub-2") == 0 &&
|
||||
strcmp(recs[0].status, "done") == 0 && recs[0].steps == 2 &&
|
||||
recs[0].tools == 1 && strcmp(recs[0].role, "explore") == 0,
|
||||
"transcript records");
|
||||
char tp[1024];
|
||||
CHECK(cst_subagent_path(ws, "parent1", "sub-1", tp, sizeof(tp)) == 0 &&
|
||||
access(tp, F_OK) == 0,
|
||||
"transcript file");
|
||||
char ids[8][128];
|
||||
int nids = cst_agent_store_list(ws, ids, 8);
|
||||
CHECK(nids == 1 && strcmp(ids[0], "parent1") == 0,
|
||||
"sub-agent transcripts are not sessions");
|
||||
|
||||
/* direct calls: unknown role, depth limit */
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = ws;
|
||||
ctx.agent = a;
|
||||
ctl_tool_call_t c;
|
||||
memset(&c, 0, sizeof(c));
|
||||
snprintf(c.name, sizeof(c.name), "task");
|
||||
c.nargs = 3;
|
||||
snprintf(c.args[0].key, sizeof(c.args[0].key), "description");
|
||||
c.args[0].value = "x";
|
||||
snprintf(c.args[1].key, sizeof(c.args[1].key), "prompt");
|
||||
c.args[1].value = "y";
|
||||
snprintf(c.args[2].key, sizeof(c.args[2].key), "agent");
|
||||
c.args[2].value = "nobody";
|
||||
for (int i = 0; i < 3; i++)
|
||||
c.args[i].value_len = strlen(c.args[i].value);
|
||||
cst_tool_result_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
cst_tool_run(&ctx, &c, &r);
|
||||
CHECK(r.exit_code == 1 && r.out &&
|
||||
strstr(r.out, "available: general, explore, plan, reviewer"),
|
||||
"unknown role lists the roles");
|
||||
cst_tool_result_free(&r);
|
||||
cst_agent_config_mut(a)->depth = 1;
|
||||
c.args[2].value = "explore";
|
||||
c.args[2].value_len = 7;
|
||||
memset(&r, 0, sizeof(r));
|
||||
cst_tool_run(&ctx, &c, &r);
|
||||
CHECK(r.exit_code == 1 && r.out && strstr(r.out, "cannot start sub-agents"),
|
||||
"depth limit");
|
||||
cst_tool_result_free(&r);
|
||||
CHECK(!cst_tool_active(cst_agent_config(a), td), "task hidden at depth 1");
|
||||
cst_agent_config_mut(a)->depth = 0;
|
||||
|
||||
/* cancelling the parent stops the child */
|
||||
pthread_t cth;
|
||||
pthread_create(&cth, NULL, cancel_later, a);
|
||||
t0 = os_now_ms();
|
||||
rc = cst_agent_run_turn_ex(a, "cancel test", &h, err, sizeof(err));
|
||||
ms = os_now_ms() - t0;
|
||||
pthread_join(cth, NULL);
|
||||
CHECK(rc != 0 && ms < 3500, "cancel reaches the child");
|
||||
nrec = cst_subagent_list(ws, "parent1", recs, 8);
|
||||
CHECK(nrec == 3 && strcmp(recs[0].status, "cancelled") == 0,
|
||||
"cancelled run recorded");
|
||||
|
||||
cst_agent_destroy(a);
|
||||
cst_tool_unregister_owner("builtin:task");
|
||||
CHECK(cst_agent_store_delete(ws, "parent1") == 0, "delete session");
|
||||
CHECK(cst_subagent_list(ws, "parent1", recs, 8) == 0,
|
||||
"delete removes sub-agent transcripts");
|
||||
|
||||
char cmd[400];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s", root);
|
||||
if (system(cmd) != 0)
|
||||
fprintf(stderr, "note: cleanup failed\n");
|
||||
close(g_lfd);
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_subagent: %d failure(s)\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_subagent: OK\n");
|
||||
return 0;
|
||||
}
|
||||
+55
-4
@@ -167,6 +167,45 @@ int main(void) {
|
||||
"missing required arg");
|
||||
reset(&r);
|
||||
|
||||
/* compact read_file (default profile) */
|
||||
ctx.compact = 1;
|
||||
EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", NULL), &r) == 0 &&
|
||||
strcmp(r.out, "src/notes.txt · 1 line\n1|TODO: x\n") == 0,
|
||||
"compact read: header + numbers");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "10",
|
||||
"limit", "3", NULL),
|
||||
&r) == 0 &&
|
||||
strstr(r.out, "lines.txt · lines 10-12 of 100\n10|line 10\n") ==
|
||||
r.out &&
|
||||
strstr(r.out, "… 88 more lines; continue with offset=13"),
|
||||
"compact slice header and continuation");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", "line_numbers",
|
||||
"false", NULL),
|
||||
&r) == 0 &&
|
||||
strstr(r.out, "\nTODO: x\n") && !strstr(r.out, "1|"),
|
||||
"compact read without numbers");
|
||||
reset(&r);
|
||||
{
|
||||
char big[5000];
|
||||
memset(big, 'a', sizeof(big) - 2);
|
||||
big[sizeof(big) - 2] = '\n';
|
||||
big[sizeof(big) - 1] = 0;
|
||||
wfile("long.txt", big);
|
||||
EXPECT(run(&ctx, mk("read_file", "path", "long.txt", NULL), &r) == 0 &&
|
||||
strstr(r.out, "[line cut, +2998 bytes]") &&
|
||||
strstr(r.out, "1 long line cut"),
|
||||
"compact read cuts long lines");
|
||||
reset(&r);
|
||||
}
|
||||
wfile("empty.txt", "");
|
||||
EXPECT(run(&ctx, mk("read_file", "path", "empty.txt", NULL), &r) == 0 &&
|
||||
strstr(r.out, "empty file"),
|
||||
"compact read of an empty file");
|
||||
reset(&r);
|
||||
ctx.compact = 0;
|
||||
|
||||
/* list_dir */
|
||||
EXPECT(run(&ctx, mk("list_dir", NULL), &r) == 0, "list_dir ok");
|
||||
EXPECT(r.out && strstr(r.out, "src/") && strstr(r.out, " main.c"),
|
||||
@@ -180,6 +219,8 @@ int main(void) {
|
||||
EXPECT(run(&ctx, mk("list_dir", "path", "src", "depth", "1", NULL), &r) == 0 &&
|
||||
strstr(r.out, "deep/") && !strstr(r.out, "util.c"),
|
||||
"list_dir depth");
|
||||
EXPECT(r.out && strstr(r.out, "deep/ (1 entry)"),
|
||||
"list_dir counts entries at the depth limit");
|
||||
reset(&r);
|
||||
|
||||
/* glob */
|
||||
@@ -205,20 +246,22 @@ int main(void) {
|
||||
|
||||
/* grep */
|
||||
EXPECT(run(&ctx, mk("grep", "pattern", "int add", NULL), &r) == 0, "grep ok");
|
||||
EXPECT(r.out && strstr(r.out, "src/deep/util.c:1:int add"), "grep hit");
|
||||
EXPECT(r.out && strstr(r.out, "src/deep/util.c\n1:int add"), "grep hit grouped");
|
||||
EXPECT(r.out && strncmp(r.out, "1 match in 1 file\n", 18) == 0,
|
||||
"grep summary first");
|
||||
EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "run.log"),
|
||||
"grep skips node_modules and ignored files");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("grep", "pattern", "x = \\d+", "glob", "*.c", NULL),
|
||||
&r) == 0 &&
|
||||
strstr(r.out, "src/main.c:3:"),
|
||||
strstr(r.out, "src/main.c\n3:"),
|
||||
"grep translates \\d");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("grep", "pattern", "RETURN", "ignore_case", "true",
|
||||
"path", "src/main.c", "context", "1", NULL),
|
||||
&r) == 0 &&
|
||||
strstr(r.out, "src/main.c:5: return 0;") &&
|
||||
strstr(r.out, "src/main.c-4-") && strstr(r.out, "src/main.c-6-"),
|
||||
strstr(r.out, "\n5: return 0;") && strstr(r.out, "\n4-") &&
|
||||
strstr(r.out, "\n6-"),
|
||||
"grep single file, icase, context");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("grep", "pattern", "(", NULL), &r) == 1 &&
|
||||
@@ -241,11 +284,19 @@ int main(void) {
|
||||
strstr(r.out, "not found"),
|
||||
"edit not found");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string",
|
||||
"int main(void) {\n int x = 7;", "new_string", "y",
|
||||
NULL),
|
||||
&r) == 1 &&
|
||||
strstr(r.out, "appears (whitespace aside) at line 2"),
|
||||
"edit not found points at the near line");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string",
|
||||
"int a, int b", "new_string", "long a, long b", NULL),
|
||||
&r) == 1 &&
|
||||
strstr(r.out, "matches 2 places"),
|
||||
"edit ambiguous");
|
||||
EXPECT(r.out && strstr(r.out, "(lines 1, 2)"), "edit ambiguous lines");
|
||||
reset(&r);
|
||||
EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string",
|
||||
"int a, int b", "new_string", "long a, long b",
|
||||
|
||||
Reference in New Issue
Block a user