summaryrefslogtreecommitdiffstats
path: root/tests/test_dotagents.c
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_dotagents.c')
-rw-r--r--tests/test_dotagents.c618
1 files changed, 618 insertions, 0 deletions
diff --git a/tests/test_dotagents.c b/tests/test_dotagents.c
new file mode 100644
index 0000000..af51506
--- /dev/null
+++ b/tests/test_dotagents.c
@@ -0,0 +1,618 @@
+/* test_dotagents.c — standalone test suite for dotagents.h (the .agents
+ * protocol layer). Zero dependencies: compiles to a single TU with no
+ * linked libraries (frontmatter.h provides everything it needs). */
+#include "dotagents.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+static char *tmpdir(const char *tag) {
+ char path[256];
+ snprintf(path, sizeof path, "/tmp/llshc-da-%s-%d", tag, (int)getpid());
+ /* rm -rf */
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "rm -rf '%s'", path);
+ if (system(cmd) != 0) {}
+ /* mkdir -p */
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path);
+ if (system(cmd) != 0)
+ exit(1);
+ return fm_strdup(path);
+}
+
+static void wfile_bin(const char *dir, const char *rel, const char *content) {
+ char path[512];
+ snprintf(path, sizeof path, "%s/%s", dir, rel);
+ char *slash = strrchr(path, '/');
+ if (slash) {
+ char dirpart[512];
+ size_t n = (size_t)(slash - path);
+ memcpy(dirpart, path, n);
+ dirpart[n] = '\0';
+ char cmd[600];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart);
+ if (system(cmd) != 0)
+ exit(1);
+ }
+ FILE *f = fopen(path, "w");
+ if (!f)
+ exit(1);
+ fputs(content, f);
+ fclose(f);
+}
+
+static int count_warns(const dotagents *d, const char *needle) {
+ int n = 0;
+ for (size_t i = 0; i < d->warnings.len; i++)
+ if (strstr(d->warnings.items[i], needle))
+ n++;
+ return n;
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer tests */
+/* ------------------------------------------------------------------ */
+
+static void test_loads_global_only(void) {
+ char *ws = tmpdir("ws1");
+ char *global = tmpdir("g1");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: global skill\n---\ndo g1");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.found_count == 1, "one layer found");
+ CHECK(d.found_layers[0] == LAYER_GLOBAL, "layer is global");
+ CHECK(d.agents_md && strcmp(d.agents_md, "global guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 1, "one skill");
+ CHECK(strcmp(d.skills[0].id, "g1") == 0, "skill id");
+ CHECK(strcmp(d.skills[0].body, "do g1") == 0, "skill body");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_md_filenames_case_insensitive(void) {
+ char *ws = tmpdir("ws-cs");
+ wfile_bin(ws, "AGENTS.MD", "workspace guidelines");
+ wfile_bin(ws, "System-Prompt.md", "the system prompt");
+ wfile_bin(ws, "skills/UPPER/SKILL.md",
+ "---\nid: upper\nname: Upper\ndescription: caps skill\n---\ndo "
+ "upper");
+ wfile_bin(ws, "agents/REVIEWER/Agent.md",
+ "---\nid: reviewer\nname: Reviewer\n---\nYou review.");
+ wfile_bin(ws, "tasks/daily/Task.MD",
+ "---\nid: daily\nname: Daily\nenabled: true\n---\nRun daily.");
+ wfile_bin(ws, "memories/note.MD",
+ "---\nid: note\ntitle: Note\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "workspace found");
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "AGENTS.MD");
+ CHECK(d.system_prompt && strcmp(d.system_prompt, "the system prompt") == 0,
+ "System-Prompt.md");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "upper") == 0,
+ "SKILL.md");
+ CHECK(d.sub_agent_count == 1 &&
+ strcmp(d.sub_agents[0].id, "reviewer") == 0,
+ "Agent.md");
+ CHECK(d.task_count == 1 && strcmp(d.tasks[0].id, "daily") == 0,
+ "Task.MD");
+ CHECK(d.memory_count == 1 && strcmp(d.memories[0].id, "note") == 0,
+ "note.MD");
+ CHECK(d.warnings.len == 0, "no case warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_workspace_wins(void) {
+ char *ws = tmpdir("ws2");
+ char *global = tmpdir("g2");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/review/skill.md",
+ "---\nid: review\nname: Review G\ndescription: global\n---\n"
+ "global body");
+ wfile_bin(global, "memories/m.md",
+ "---\nid: m\ntitle: M Global\nimportance: low\ntags: a\n---\ng");
+ wfile_bin(ws, "agents.md", "workspace guidelines");
+ wfile_bin(ws, "skills/review/skill.md",
+ "---\nid: review\nname: Review W\ndescription: workspace\n---\n"
+ "workspace body");
+ wfile_bin(ws, "skills/extra/skill.md",
+ "---\nid: extra\nname: Extra\n---\nextra body");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "workspace agents.md wins");
+ const skill *review = dotagents_find_skill(&d, "review");
+ CHECK(review && review->layer == LAYER_WORKSPACE, "review layer");
+ CHECK(review && strcmp(review->body, "workspace body") == 0,
+ "review body");
+ CHECK(d.skill_count == 2, "review + extra");
+ CHECK(d.memory_count == 1, "only global memory");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_json_layers_merge_by_key(void) {
+ char *ws = tmpdir("ws3");
+ char *global = tmpdir("g3");
+ wfile_bin(global, "models.json",
+ "{\"default\":\"ga\",\"presets\":{\"ga\":{\"model\":\"ga-"
+ "model\"},\"shared\":{\"model\":\"s\"}}}");
+ wfile_bin(global, "mcp.json",
+ "{\"mcpServers\":{\"fs\":{\"command\":\"npx\"},"
+ "\"git\":{\"url\":\"g\"}}}");
+ wfile_bin(ws, "models.json",
+ "{\"default\":\"wb\",\"presets\":{\"wb\":{\"provider\":\""
+ "ollama\"},\"shared\":{\"model\":\"ws\"}}}");
+ wfile_bin(ws, "mcp.json", "{\"mcpServers\":{\"git\":{\"url\":\"w\"}}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.has_models, "models present");
+ CHECK(d.models.default_name &&
+ strcmp(d.models.default_name, "wb") == 0,
+ "default=wb");
+ CHECK(d.models.preset_count == 3, "3 presets");
+ const model_preset *ga = models_file_get(&d.models, "ga");
+ CHECK(ga && ga->model && strcmp(ga->model, "ga-model") == 0, "ga.model");
+ const model_preset *sh = models_file_get(&d.models, "shared");
+ CHECK(sh && sh->model && strcmp(sh->model, "ws") == 0, "shared.model");
+ CHECK(d.mcp_count == 2, "2 mcp servers");
+ const char *git = dotagents_mcp_config(&d, "git");
+ CHECK(git && strstr(git, "\"url\":\"w\""), "git.url=w");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_malformed_files_warn_and_skip(void) {
+ char *ws = tmpdir("ws4");
+ wfile_bin(ws, "models.json", "{not json");
+ wfile_bin(ws, "mcp.json", "{\"nope\":true}");
+ wfile_bin(ws, "skills/broken/skill.md", "no frontmatter");
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s/skills/empty'", ws);
+ if (system(cmd) != 0)
+ exit(1);
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(!d.has_models, "no models");
+ CHECK(d.mcp_count == 0, "no mcp");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "broken") == 0,
+ "broken skill kept");
+ CHECK(d.skills[0].body &&
+ strstr(d.skills[0].body, "no frontmatter"),
+ "broken skill body");
+ CHECK(count_warns(&d, "models.json") > 0, "models.json warning");
+ CHECK(count_warns(&d, "mcp.json") > 0, "mcp.json warning");
+ CHECK(count_warns(&d, "skills/empty") > 0, "skills/empty warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_missing_layers_empty(void) {
+ char *ws = tmpdir("ws5");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 0, "no layers");
+ CHECK(d.skill_count == 0, "no skills");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_tasks_and_sub_agents(void) {
+ char *ws = tmpdir("ws6");
+ wfile_bin(ws, "tasks/daily/task.md",
+ "---\nkind: task\nid: daily\nname: Daily Review\nintervalMinutes"
+ ": 60\nenabled: true\nrunOnStartup: true\n---\nReview open "
+ "PRs.");
+ wfile_bin(ws, "agents/reviewer/agent.md",
+ "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n"
+ "role: delegation-target\nenabled: true\n---\nYou are a "
+ "reviewer.");
+ wfile_bin(ws, "agents/reviewer/config.json",
+ "{\"modelConfig\":{\"mcpToolsOpenaiModel\":\"gpt-4o\"}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].has_interval && d.tasks[0].interval_minutes == 60,
+ "interval");
+ CHECK(d.tasks[0].run_on_startup, "run_on_startup");
+ CHECK(d.tasks[0].enabled, "enabled");
+ CHECK(strcmp(d.tasks[0].body, "Review open PRs.") == 0, "task body");
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(strcmp(d.sub_agents[0].role, "delegation-target") == 0, "role");
+ CHECK(strcmp(d.sub_agents[0].body, "You are a reviewer.") == 0,
+ "sub agent body");
+ CHECK(d.sub_agents[0].config != NULL, "config present");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_malformed_sub_agent_config(void) {
+ char *ws = tmpdir("ws9");
+ wfile_bin(ws, "agents/broken/agent.md",
+ "---\nid: broken\nname: Broken\ndescription: has a bad config\n"
+ "---\nYou are broken.");
+ wfile_bin(ws, "agents/broken/config.json", "{not json");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(d.sub_agents[0].config == NULL, "config dropped");
+ CHECK(count_warns(&d, "agents/broken/config.json") > 0,
+ "config warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_memories_rank(void) {
+ memory low, high;
+ memset(&low, 0, sizeof low);
+ low.importance = fm_strdup("low");
+ memset(&high, 0, sizeof high);
+ high.importance = fm_strdup("high");
+ CHECK(memory_importance_rank(&high) < memory_importance_rank(&low),
+ "high ranks before low");
+ free(low.importance);
+ free(high.importance);
+}
+
+static void test_project_sections(void) {
+ char *ws = tmpdir("ws7");
+ wfile_bin(ws, "agents.md", "# Guidelines\n- be nice");
+ wfile_bin(ws, "skills/cr/skill.md",
+ "---\nid: cr\nname: CR\ndescription: code review\n---\nreview "
+ "steps");
+ wfile_bin(ws, "memories/p.md",
+ "---\nid: p\ntitle: P\nimportance: high\ntags: x\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- be nice"), "guidelines");
+ CHECK(strstr(sections, "- cr: code review"), "skill line");
+ CHECK(strstr(sections, "use_skill"), "use_skill mention");
+ CHECK(strstr(sections, "search_memories"), "search_memories mention");
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "BASE") && strstr(sections, "be nice") &&
+ memcmp(pre, "BASE", 4) == 0,
+ "preamble = base + sections");
+ free(sections);
+ free(pre);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_skill_index_cap(void) {
+ char *ws = tmpdir("ws10");
+ const char *ids[5] = {"a", "b", "c", "d", "e"};
+ for (int i = 0; i < 5; i++) {
+ char rel[64];
+ char content[128];
+ snprintf(rel, sizeof rel, "skills/%s/skill.md", ids[i]);
+ snprintf(content, sizeof content,
+ "---\nid: %s\nname: %s\ndescription: skill %d\n---\nbody",
+ ids[i], ids[i], i);
+ wfile_bin(ws, rel, content);
+ }
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- a: skill 0"), "a listed");
+ CHECK(strstr(sections, "- b: skill 1"), "b listed");
+ CHECK(!strstr(sections, "- c: skill 2"), "c not listed");
+ CHECK(strstr(sections, "and 3 more"), "…and 3 more");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_disabled_skills_excluded(void) {
+ char *ws = tmpdir("ws8");
+ wfile_bin(ws, "skills/off/skill.md",
+ "---\nid: off\ndescription: hidden\nenabled: false\n---\nx");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.skill_count == 1, "skill kept");
+ CHECK(!d.skills[0].enabled, "disabled");
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(!strstr(sections, "off"), "not in sections");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* ------------------------------------------------------------------ */
+/* Spec-gap conformance */
+/* ------------------------------------------------------------------ */
+
+static void test_task_profile_id(void) {
+ char *ws = tmpdir("ws-pid");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nkind: task\nid: nightly\nname: Nightly\nprofileId: "
+ "prof-9\nenabled: true\n---\nRun nightly.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "prof-9") == 0,
+ "profile_id parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_task_profile_id_workspace_override(void) {
+ char *ws = tmpdir("ws-pid2");
+ char *global = tmpdir("g-pid");
+ wfile_bin(global, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: g-prof\n---\ng");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: w-prof\n---\nw");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "w-prof") == 0,
+ "workspace profileId wins");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_agent_connection_type(void) {
+ char *ws = tmpdir("ws-ct");
+ wfile_bin(ws, "agents/remote/agent.md",
+ "---\nid: remote\nname: Remote\nrole: delegation-target\n"
+ "connection-type: internal\nenabled: true\n---\nYou are "
+ "remote.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one agent");
+ CHECK(d.sub_agents[0].connection_type &&
+ strcmp(d.sub_agents[0].connection_type, "internal") == 0,
+ "connection-type parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_settings_layers_merge(void) {
+ char *ws = tmpdir("ws-set");
+ char *global = tmpdir("g-set");
+ wfile_bin(global, "speakmcp-settings.json",
+ "{\"theme\":\"dark\",\"telemetry\":true,\"shared\":\"g\"}");
+ wfile_bin(ws, "speakmcp-settings.json",
+ "{\"shared\":\"w\",\"volume\":2}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.settings_json != NULL, "settings present");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"shared\":\"w\""),
+ "shared=w");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"theme\":\"dark\""),
+ "theme kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"volume\":2"),
+ "volume kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"telemetry\":true"),
+ "telemetry kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_invalid_warns(void) {
+ char *ws = tmpdir("ws-setbad");
+ wfile_bin(ws, "speakmcp-settings.json", "{nope");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.settings_json == NULL, "no settings");
+ CHECK(count_warns(&d, "speakmcp-settings.json") > 0, "settings warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_ui_merge(void) {
+ char *ws = tmpdir("ws-lay");
+ char *global = tmpdir("g-lay");
+ wfile_bin(global, "layouts/ui.json", "{\"panes\":2,\"theme\":\"dark\"}");
+ wfile_bin(ws, "layouts/ui.json", "{\"theme\":\"light\"}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"theme\":\"light\""),
+ "theme=w");
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"panes\":2"),
+ "panes kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-setonly");
+ wfile_bin(ws, "speakmcp-settings.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.settings_json != NULL, "settings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-layonly");
+ wfile_bin(ws, "layouts/ui.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.layouts_json != NULL, "layouts");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_backups_ignored(void) {
+ char *ws = tmpdir("ws-bk");
+ wfile_bin(ws, "agents.md", "guidelines");
+ wfile_bin(ws, ".backups/skills/ghost/skill.md",
+ "---\nid: ghost\nname: Ghost\n---\nbody");
+ wfile_bin(ws, ".backups/memories/ghost.md",
+ "---\nid: ghost\ntitle: G\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.agents_md && strcmp(d.agents_md, "guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 0, "no ghost skill");
+ CHECK(d.memory_count == 0, "no ghost memory");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_unknown_frontmatter_keys_accepted(void) {
+ char *ws = tmpdir("ws-unk");
+ wfile_bin(ws, "agents/x/agent.md",
+ "---\nkind: agent\nid: x\nname: X\nmetadata: stuff\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "agent kept");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* Both ~/.agents and ./.agents must remain reachable in the model context:
+ * a global-only skill and a workspace-only skill both survive the merge, both
+ * appear in the skill index the model sees, and an id conflict resolves to
+ * the workspace copy. */
+static void test_both_layers_reachable_in_context(void) {
+ char *ws = tmpdir("ws-ctx");
+ char *global = tmpdir("g-ctx");
+ wfile_bin(global, "skills/g-only/skill.md",
+ "---\nid: g-only\nname: G Only\ndescription: global only skill\n"
+ "---\ng global body");
+ wfile_bin(global, "skills/shared/skill.md",
+ "---\nid: shared\nname: Shared G\ndescription: shared global\n"
+ "---\ng shared body");
+ wfile_bin(ws, "skills/w-only/skill.md",
+ "---\nid: w-only\nname: W Only\ndescription: workspace only "
+ "skill\n---\nw workspace body");
+ wfile_bin(ws, "skills/shared/skill.md",
+ "---\nid: shared\nname: Shared W\ndescription: shared "
+ "workspace\n---\nw shared body");
+
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+
+ /* Neither layer's skills are lost. */
+ CHECK(d.skill_count == 3, "g-only + w-only + shared");
+
+ const skill *g = dotagents_find_skill(&d, "g-only");
+ CHECK(g && g->layer == LAYER_GLOBAL, "g-only reachable");
+ CHECK(g && g->body && strstr(g->body, "g global body"), "g-only body");
+
+ const skill *w = dotagents_find_skill(&d, "w-only");
+ CHECK(w && w->layer == LAYER_WORKSPACE, "w-only reachable");
+ CHECK(w && w->body && strstr(w->body, "w workspace body"), "w-only body");
+
+ /* An id conflict resolves to the workspace copy. */
+ const skill *sh = dotagents_find_skill(&d, "shared");
+ CHECK(sh && sh->layer == LAYER_WORKSPACE, "shared -> workspace");
+ CHECK(sh && sh->body && strstr(sh->body, "w shared body"),
+ "shared workspace body");
+
+ /* Both layers remain visible in the context skill index. */
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- g-only") != NULL, "g-only in context index");
+ CHECK(strstr(sections, "- w-only") != NULL, "w-only in context index");
+ CHECK(strstr(sections, "- shared") != NULL, "shared in context index");
+ free(sections);
+
+ /* ...and therefore in the composed preamble the model actually receives. */
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "g-only") != NULL && strstr(pre, "w-only") != NULL,
+ "both layers in composed preamble");
+ free(pre);
+
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+/* skill_index_cap must apply to the merged two-layer set, not silently drop
+ * global skills: with 3 global + 2 workspace skills and cap 2, the first two
+ * (by id) are listed and the rest are elided. */
+static void test_skill_index_cap_across_layers(void) {
+ char *ws = tmpdir("ws-cap");
+ char *global = tmpdir("g-cap");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: g1\n---\nb");
+ wfile_bin(global, "skills/g2/skill.md",
+ "---\nid: g2\nname: G2\ndescription: g2\n---\nb");
+ wfile_bin(global, "skills/g3/skill.md",
+ "---\nid: g3\nname: G3\ndescription: g3\n---\nb");
+ wfile_bin(ws, "skills/w1/skill.md",
+ "---\nid: w1\nname: W1\ndescription: w1\n---\nb");
+ wfile_bin(ws, "skills/w2/skill.md",
+ "---\nid: w2\nname: W2\ndescription: w2\n---\nb");
+
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.skill_count == 5, "5 merged skills");
+
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- g1") != NULL, "g1 listed");
+ CHECK(strstr(sections, "- g2") != NULL, "g2 listed");
+ CHECK(!strstr(sections, "- g3"), "g3 capped");
+ CHECK(!strstr(sections, "- w1"), "w1 capped");
+ CHECK(strstr(sections, "and 3 more") != NULL, "...and 3 more");
+ free(sections);
+
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+int main(void) {
+ test_loads_global_only();
+ test_md_filenames_case_insensitive();
+ test_workspace_wins();
+ test_json_layers_merge_by_key();
+ test_malformed_files_warn_and_skip();
+ test_missing_layers_empty();
+ test_tasks_and_sub_agents();
+ test_malformed_sub_agent_config();
+ test_memories_rank();
+ test_project_sections();
+ test_skill_index_cap();
+ test_disabled_skills_excluded();
+ test_task_profile_id();
+ test_task_profile_id_workspace_override();
+ test_agent_connection_type();
+ test_settings_layers_merge();
+ test_settings_invalid_warns();
+ test_layouts_ui_merge();
+ test_settings_only_layer_discovered();
+ test_layouts_only_layer_discovered();
+ test_backups_ignored();
+ test_unknown_frontmatter_keys_accepted();
+ test_both_layers_reachable_in_context();
+ test_skill_index_cap_across_layers();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}