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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user