Extensions: skills, plugins, MCP, sub-agents; configurable keys
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s

- 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:
WHC
2026-10-05 14:55:31 +08:00
parent c81df42f0b
commit 7d3ec9fcb9
38 changed files with 8356 additions and 399 deletions
+126
View File
@@ -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;
}
+297
View File
@@ -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;
}
+219
View File
@@ -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
View File
@@ -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");
+116
View File
@@ -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;
}
+193
View File
@@ -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;
}
+484
View File
@@ -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
View File
@@ -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",