Skip to content

Commit ea46648

Browse files
committed
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>
1 parent 0e0e481 commit ea46648

4 files changed

Lines changed: 71 additions & 12 deletions

File tree

‎src/cli/cli.h‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,20 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_
477477
void cbm_hook_augment_arm_deadline(void);
478478
char *cbm_hook_augment_read_stdin(void);
479479

480+
/* Why a hook client is not augmenting. The hook caller only ever sees stdout,
481+
* so each reason that is actionable by the user must have a stdout notice
482+
* (#1388: a build-conflicted daemon used to report on stderr alone, which is
483+
* invisible in-session and reads as silent skips). */
484+
typedef enum {
485+
CBM_HOOK_ADMISSION_DAEMON_ABSENT = 0, /* no daemon running: `daemon start` heals it */
486+
CBM_HOOK_ADMISSION_BUILD_CONFLICT /* daemon runs another build: needs `daemon stop` */
487+
} cbm_hook_admission_t;
488+
489+
/* The JSON systemMessage a hook client must print on stdout for `reason`, or
490+
* NULL when nothing should be printed. hook_dialect NULL = Claude Code, the
491+
* only dialect that surfaces a stdout systemMessage to the user. */
492+
const char *cbm_hook_admission_notice(cbm_hook_admission_t reason, const char *hook_dialect);
493+
480494
/* Process one already-read hook payload using a caller-owned MCP session.
481495
* Returns a malloc-owned hook output JSON string, or NULL for fail-open/no
482496
* augmentation. This is the daemon entry; it never arms a process-global

‎src/cli/hook_augment.c‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,3 +1437,24 @@ int cbm_cmd_hook_augment(int argc, char **argv) {
14371437
free(input);
14381438
return 0;
14391439
}
1440+
1441+
const char *cbm_hook_admission_notice(cbm_hook_admission_t reason, const char *hook_dialect) {
1442+
/* Only Claude Code (the NULL dialect) consumes a bare stdout JSON object;
1443+
* emitting it into another dialect's channel would corrupt that protocol. */
1444+
if (hook_dialect) {
1445+
return NULL;
1446+
}
1447+
switch (reason) {
1448+
case CBM_HOOK_ADMISSION_DAEMON_ABSENT:
1449+
return "{\"systemMessage\":\"codebase-memory-mcp: no CBM daemon is running, so graph "
1450+
"augmentation is currently skipped. Run `codebase-memory-mcp daemon start` (or "
1451+
"open an MCP session) to enable it.\"}";
1452+
case CBM_HOOK_ADMISSION_BUILD_CONFLICT:
1453+
return "{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the "
1454+
"active CBM daemon runs a different build than this binary (usually an update was "
1455+
"installed while the old daemon kept running). Run `codebase-memory-mcp daemon "
1456+
"stop`, then retry - the next command starts a matching daemon.\"}";
1457+
default:
1458+
return NULL;
1459+
}
1460+
}

‎src/main.c‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1483,12 +1483,9 @@ static void main_hook_report_absent_daemon(const char *hook_dialect) {
14831483
(void)fprintf(stderr, "codebase-memory-mcp: no CBM daemon is running, so graph "
14841484
"augmentation is skipped. Start an MCP session or run "
14851485
"`codebase-memory-mcp daemon start` to enable it.\n");
1486-
if (!hook_dialect) {
1487-
/* Claude hook output: a systemMessage is surfaced to the user. */
1488-
(void)fputs("{\"systemMessage\":\"codebase-memory-mcp: no CBM daemon is running, so "
1489-
"graph augmentation is currently skipped. Run `codebase-memory-mcp daemon "
1490-
"start` (or open an MCP session) to enable it.\"}",
1491-
stdout);
1486+
const char *notice = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, hook_dialect);
1487+
if (notice) {
1488+
(void)fputs(notice, stdout);
14921489
(void)fflush(stdout);
14931490
}
14941491
}
@@ -1503,12 +1500,11 @@ static void main_hook_report_conflicted_daemon(const char *hook_dialect) {
15031500
if (hook_dialect || !main_hook_absent_notice_due()) {
15041501
return;
15051502
}
1506-
(void)fputs("{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the "
1507-
"active CBM daemon runs a different build than this binary (usually an update "
1508-
"was installed while the old daemon kept running). Run `codebase-memory-mcp "
1509-
"daemon stop`, then retry - the next command starts a matching daemon.\"}",
1510-
stdout);
1511-
(void)fflush(stdout);
1503+
const char *notice = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, hook_dialect);
1504+
if (notice) {
1505+
(void)fputs(notice, stdout);
1506+
(void)fflush(stdout);
1507+
}
15121508
}
15131509

15141510
static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, const char *hook_event,

‎tests/test_cli.c‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4670,6 +4670,33 @@ TEST(cli_agent_reinstall_preserves_foreign_policy_entries) {
46704670
PASS();
46714671
}
46724672

4673+
/* Regression for #1388: a hook client blocked by a daemon BUILD CONFLICT must
4674+
* emit a stdout systemMessage. stdout is the only channel a hook caller sees,
4675+
* so the pre-fix stderr-only reporting was indistinguishable from "no matches"
4676+
* and produced silent skips for the whole session. The absent-daemon notice
4677+
* must stay distinct: it points at `daemon start`, which cannot heal a build
4678+
* conflict. Non-Claude dialects take no bare stdout JSON at all. */
4679+
TEST(cli_hook_conflict_emits_stdout_notice_issue1388) {
4680+
const char *conflict = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, NULL);
4681+
ASSERT_NOT_NULL(conflict);
4682+
ASSERT_NOT_NULL(strstr(conflict, "systemMessage"));
4683+
ASSERT_NOT_NULL(strstr(conflict, "different build"));
4684+
/* The actionable step: a conflicted daemon must be STOPPED, not started. */
4685+
ASSERT_NOT_NULL(strstr(conflict, "daemon stop"));
4686+
4687+
const char *absent = cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, NULL);
4688+
ASSERT_NOT_NULL(absent);
4689+
ASSERT_NOT_NULL(strstr(absent, "daemon start"));
4690+
/* Distinct diagnoses: the conflict notice must never claim no daemon runs. */
4691+
ASSERT_TRUE(strcmp(conflict, absent) != 0);
4692+
ASSERT_NULL(strstr(conflict, "no CBM daemon is running"));
4693+
4694+
/* Other dialects do not consume a bare stdout JSON object. */
4695+
ASSERT_NULL(cbm_hook_admission_notice(CBM_HOOK_ADMISSION_BUILD_CONFLICT, "codex"));
4696+
ASSERT_NULL(cbm_hook_admission_notice(CBM_HOOK_ADMISSION_DAEMON_ABSENT, "codex"));
4697+
PASS();
4698+
}
4699+
46734700
TEST(cli_existing_agents_install_durable_child_context) {
46744701
char tmpdir[256];
46754702
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-durable-agents-XXXXXX");
@@ -11936,6 +11963,7 @@ SUITE(cli) {
1193611963
RUN_TEST(cli_new_agent_install_plans_use_documented_paths);
1193711964
RUN_TEST(cli_new_agent_configs_use_documented_schemas);
1193811965
RUN_TEST(cli_agent_reinstall_preserves_foreign_policy_entries);
11966+
RUN_TEST(cli_hook_conflict_emits_stdout_notice_issue1388);
1193911967
RUN_TEST(cli_existing_agents_install_durable_child_context);
1194011968
RUN_TEST(cli_durable_profiles_follow_current_vendor_paths);
1194111969
RUN_TEST(cli_cline_data_dir_only_redirects_data_state);

0 commit comments

Comments
 (0)