test(hook): bind the conflict notice with a portable reproduce-first test

The end-to-end shell regression could not run on CI (recorded in the
previous commit), which left the fix without a gating test. Move the
notice DECISION out of main.c - which is not linked into the test
runner, and is why no C test could reach it - into src/cli/
hook_augment.c, which is. main.c now calls the shared API for both the
absent-daemon and build-conflict cases instead of carrying its own
copies of the strings, so the test binds the real production path
rather than a parallel one.

cli_hook_conflict_emits_stdout_notice_issue1388 asserts what the bug
actually broke: a build conflict yields a stdout systemMessage naming a
different build and pointing at 'daemon stop'; the absent-daemon notice
stays distinct and keeps pointing at 'daemon start' (which cannot heal
a conflict); and non-Claude dialects receive no bare stdout JSON, whose
channel it would corrupt.

RED-on-revert verified: restoring the pre-fix behaviour (conflict emits
nothing on stdout) fails this test alone, for the right reason
('conflict is NULL'). cli suite 258 passed, the local end-to-end shell
test still green against a seam-bearing binary, lint-ci clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-05 15:56:18 +02:00
parent 0e0e481b67
commit ea466487e8
4 changed files with 71 additions and 12 deletions
+14
View File
@@ -477,6 +477,20 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_
void cbm_hook_augment_arm_deadline(void);
char *cbm_hook_augment_read_stdin(void);
/* Why a hook client is not augmenting. The hook caller only ever sees stdout,
* so each reason that is actionable by the user must have a stdout notice
* (#1388: a build-conflicted daemon used to report on stderr alone, which is
* invisible in-session and reads as silent skips). */
typedef enum {
CBM_HOOK_ADMISSION_DAEMON_ABSENT = 0, /* no daemon running: `daemon start` heals it */
CBM_HOOK_ADMISSION_BUILD_CONFLICT /* daemon runs another build: needs `daemon stop` */
} cbm_hook_admission_t;
/* The JSON systemMessage a hook client must print on stdout for `reason`, or
* NULL when nothing should be printed. hook_dialect NULL = Claude Code, the
* only dialect that surfaces a stdout systemMessage to the user. */
const char *cbm_hook_admission_notice(cbm_hook_admission_t reason, const char *hook_dialect);
/* Process one already-read hook payload using a caller-owned MCP session.
* Returns a malloc-owned hook output JSON string, or NULL for fail-open/no
* augmentation. This is the daemon entry; it never arms a process-global
+21
View File
@@ -1437,3 +1437,24 @@ int cbm_cmd_hook_augment(int argc, char **argv) {
free(input);
return 0;
}
const char *cbm_hook_admission_notice(cbm_hook_admission_t reason, const char *hook_dialect) {
/* Only Claude Code (the NULL dialect) consumes a bare stdout JSON object;
* emitting it into another dialect's channel would corrupt that protocol. */
if (hook_dialect) {
return NULL;
}
switch (reason) {
case CBM_HOOK_ADMISSION_DAEMON_ABSENT:
return "{\"systemMessage\":\"codebase-memory-mcp: no CBM daemon is running, so graph "
"augmentation is currently skipped. Run `codebase-memory-mcp daemon start` (or "
"open an MCP session) to enable it.\"}";
case CBM_HOOK_ADMISSION_BUILD_CONFLICT:
return "{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the "
"active CBM daemon runs a different build than this binary (usually an update was "
"installed while the old daemon kept running). Run `codebase-memory-mcp daemon "
"stop`, then retry - the next command starts a matching daemon.\"}";
default:
return NULL;
}
}
+8 -12
View File
@@ -1483,12 +1483,9 @@ static void main_hook_report_absent_daemon(const char *hook_dialect) {
(void)fprintf(stderr, "codebase-memory-mcp: no CBM daemon is running, so graph "
"augmentation is skipped. Start an MCP session or run "
"`codebase-memory-mcp daemon start` to enable it.\n");
if (!hook_dialect) {
/* Claude hook output: a systemMessage is surfaced to the user. */
(void)fputs("{\"systemMessage\":\"codebase-memory-mcp: no CBM daemon is running, so "
"graph augmentation is currently skipped. Run `codebase-memory-mcp daemon "
"start` (or open an MCP session) to enable it.\"}",
stdout);
const char *notice = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, hook_dialect);
if (notice) {
(void)fputs(notice, stdout);
(void)fflush(stdout);
}
}
@@ -1503,12 +1500,11 @@ static void main_hook_report_conflicted_daemon(const char *hook_dialect) {
if (hook_dialect || !main_hook_absent_notice_due()) {
return;
}
(void)fputs("{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the "
"active CBM daemon runs a different build than this binary (usually an update "
"was installed while the old daemon kept running). Run `codebase-memory-mcp "
"daemon stop`, then retry - the next command starts a matching daemon.\"}",
stdout);
(void)fflush(stdout);
const char *notice = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, hook_dialect);
if (notice) {
(void)fputs(notice, stdout);
(void)fflush(stdout);
}
}
static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, const char *hook_event,
+28
View File
@@ -4670,6 +4670,33 @@ TEST(cli_agent_reinstall_preserves_foreign_policy_entries) {
PASS();
}
/* Regression for #1388: a hook client blocked by a daemon BUILD CONFLICT must
* emit a stdout systemMessage. stdout is the only channel a hook caller sees,
* so the pre-fix stderr-only reporting was indistinguishable from "no matches"
* and produced silent skips for the whole session. The absent-daemon notice
* must stay distinct: it points at `daemon start`, which cannot heal a build
* conflict. Non-Claude dialects take no bare stdout JSON at all. */
TEST(cli_hook_conflict_emits_stdout_notice_issue1388) {
const char *conflict = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, NULL);
ASSERT_NOT_NULL(conflict);
ASSERT_NOT_NULL(strstr(conflict, "systemMessage"));
ASSERT_NOT_NULL(strstr(conflict, "different build"));
/* The actionable step: a conflicted daemon must be STOPPED, not started. */
ASSERT_NOT_NULL(strstr(conflict, "daemon stop"));
const char *absent = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, NULL);
ASSERT_NOT_NULL(absent);
ASSERT_NOT_NULL(strstr(absent, "daemon start"));
/* Distinct diagnoses: the conflict notice must never claim no daemon runs. */
ASSERT_TRUE(strcmp(conflict, absent) != 0);
ASSERT_NULL(strstr(conflict, "no CBM daemon is running"));
/* Other dialects do not consume a bare stdout JSON object. */
ASSERT_NULL(cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, "codex"));
ASSERT_NULL(cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, "codex"));
PASS();
}
TEST(cli_existing_agents_install_durable_child_context) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-durable-agents-XXXXXX");
@@ -11936,6 +11963,7 @@ SUITE(cli) {
RUN_TEST(cli_new_agent_install_plans_use_documented_paths);
RUN_TEST(cli_new_agent_configs_use_documented_schemas);
RUN_TEST(cli_agent_reinstall_preserves_foreign_policy_entries);
RUN_TEST(cli_hook_conflict_emits_stdout_notice_issue1388);
RUN_TEST(cli_existing_agents_install_durable_child_context);
RUN_TEST(cli_durable_profiles_follow_current_vendor_paths);
RUN_TEST(cli_cline_data_dir_only_redirects_data_state);