Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 117 additions & 7 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ if [ "$TOTAL" -lt 1 ]; then
fi

echo ""
echo "=== Phase 3z: no tool duplicates its payload into structuredContent (#1375) ==="
echo "=== Phase 3z: structuredContent carries structure or is absent — never {} and never a copy (#1375, #1522) ==="
# Asserts on the SHIPPED artifact what the unit suite asserts from source: a
# non-JSON payload must travel ONCE. It used to appear twice — content[0].text
# plus an identical structuredContent.text — costing 2.05x the bytes on a large
Expand Down Expand Up @@ -421,21 +421,32 @@ if d.get("isError"):
print("skip"); raise SystemExit
content = d.get("content") or []
text = content[0].get("text", "") if content else ""
sc = d.get("structuredContent")
if not isinstance(sc, dict):
print("no-structured"); raise SystemExit
sc = d.get("structuredContent", "ABSENT")
try:
payload_is_object = isinstance(json.loads(text), dict)
except Exception:
payload_is_object = False
if payload_is_object:
print("skip"); raise SystemExit
print("dup" if sc.get("text") == text and text else "ok")
# Object payloads must carry the parsed, NON-EMPTY object.
print("ok" if isinstance(sc, dict) and sc else "object-lost"); raise SystemExit
# Text payloads: no structuredContent key at all. {} rendered as the whole
# result in outputSchema-honoring clients (#1522); {"text": ...} duplicated
# the wire (#1375).
if sc == "ABSENT":
print("ok")
elif isinstance(sc, dict) and not sc:
print("empty-lie")
elif isinstance(sc, dict) and sc.get("text") == text and text:
print("dup")
else:
print("unexpected")
')
case "$VERDICT" in
dup) echo "FAIL: $(echo "$TOOL_ARGS" | cut -d" " -f1) repeats its payload in structuredContent (#1375)"; DUP_TOOLS=$((DUP_TOOLS+1)) ;;
empty-lie) echo "FAIL: $(echo "$TOOL_ARGS" | cut -d" " -f1) ships structuredContent {} beside a non-empty payload (#1522)"; DUP_TOOLS=$((DUP_TOOLS+1)) ;;
object-lost) echo "FAIL: $(echo "$TOOL_ARGS" | cut -d" " -f1) dropped the parsed object from structuredContent"; DUP_TOOLS=$((DUP_TOOLS+1)) ;;
unexpected) echo "FAIL: $(echo "$TOOL_ARGS" | cut -d" " -f1) has an unexpected structuredContent shape"; DUP_TOOLS=$((DUP_TOOLS+1)) ;;
ok) DUP_CHECKED=$((DUP_CHECKED+1)) ;;
no-structured) echo "FAIL: $(echo "$TOOL_ARGS" | cut -d" " -f1) has no structuredContent object (outputSchema requires one)"; DUP_TOOLS=$((DUP_TOOLS+1)) ;;
esac
done
if [ "$DUP_TOOLS" -ne 0 ]; then
Expand All @@ -448,6 +459,105 @@ if [ "$DUP_CHECKED" -eq 0 ]; then
fi
echo "OK: $DUP_CHECKED tool(s) deliver their payload exactly once"

echo "=== Phase 3z1: default-format MCP replies are usable in schema-honoring clients (#1522) ==="
# The exact end-to-end failure shape of #1522: a spec-compliant MCP client
# (Claude Code) reads structuredContent as THE result when a tool declares an
# outputSchema. Drive the REAL stdio server the way such a client does and
# assert a default-format search_graph reply cannot render as "{}": either
# structuredContent is absent (client falls back to content text) or it is a
# non-empty object. Also assert no tool declares an outputSchema anymore.
MCP_1522=$(python3 - "$BINARY" "$PROJECT" <<'PYMCP'
import json, subprocess, sys
BIN, PROJECT = sys.argv[1], sys.argv[2]
def rpc(i, m, p): return json.dumps({"jsonrpc": "2.0", "id": i, "method": m, "params": p})
reqs = "\n".join([
rpc(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "smoke-1522", "version": "0"}}),
json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}),
rpc(2, "tools/list", {}),
rpc(3, "tools/call", {"name": "search_graph", "arguments":
{"project": PROJECT, "name_pattern": "compute"}}),
]) + "\n"
out = subprocess.run([BIN], input=reqs, capture_output=True, text=True, timeout=180).stdout
verdict = []
for line in out.splitlines():
try:
d = json.loads(line)
except Exception:
continue
if d.get("id") == 2:
schemas = [t["name"] for t in d.get("result", {}).get("tools", []) if "outputSchema" in t]
if schemas:
verdict.append("FAIL: outputSchema declared by: " + ",".join(schemas))
if d.get("id") == 3:
r = d.get("result", {})
text = (r.get("content") or [{}])[0].get("text", "")
sc = r.get("structuredContent", "ABSENT")
if not text:
verdict.append("FAIL: search_graph content text is empty")
if isinstance(sc, dict) and not sc:
verdict.append("FAIL: search_graph ships structuredContent {} (#1522)")
if not verdict:
verdict.append("OK")
print("; ".join(verdict))
PYMCP
)
case "$MCP_1522" in
OK) echo "OK: default-format MCP reply is client-usable, no outputSchema declared" ;;
*) echo "$MCP_1522"; exit 1 ;;
esac

echo "=== Phase 3z2: pipelined requests beyond queue capacity all get answers ==="
# Any 7+ requests written in one stdin burst used to kill the server with
# rc=1 and ZERO bytes of output — the frontend queue (capacity 8 frames,
# initialize + notification included) failed the whole session at overflow
# instead of backpressuring the reader. Agent clients issuing parallel tool
# calls pipeline exactly like this.
PIPELINE=$(python3 - "$BINARY" <<'PYPIPE'
import json, subprocess, sys
BIN = sys.argv[1]
N = 24
def rpc(i, m, p): return json.dumps({"jsonrpc": "2.0", "id": i, "method": m, "params": p})
lines = [rpc(1, "initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "smoke-pipe", "version": "0"}}),
json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"})]
for i in range(N):
lines.append(rpc(100 + i, "tools/call", {"name": "list_projects", "arguments": {}}))
r = subprocess.run([BIN], input="\n".join(lines) + "\n",
capture_output=True, text=True, timeout=300)
answered = set()
for line in r.stdout.splitlines():
try:
d = json.loads(line)
except Exception:
continue
if isinstance(d.get("id"), int) and d["id"] >= 100:
answered.add(d["id"])
if r.returncode == 0 and len(answered) == N:
print("OK")
else:
print(f"FAIL: rc={r.returncode} answered={len(answered)}/{N} stdout_bytes={len(r.stdout)}")
PYPIPE
)
case "$PIPELINE" in
OK) echo "OK: 24 pipelined tool calls all answered, clean exit" ;;
*) echo "$PIPELINE"; exit 1 ;;
esac

echo "=== Phase 3z3: config get prints real defaults and rejects unknown keys (#1522) ==="
CFG_HOME=$(smoke_mktemp_dir)
CFG_WATCH=$(CBM_CACHE_DIR="$CFG_HOME" "$BINARY" config get auto_watch 2>/dev/null || true)
if [ "$CFG_WATCH" != "true" ] && [ "$CFG_WATCH" != "false" ]; then
echo "FAIL: config get auto_watch printed '$CFG_WATCH' (expected the stored value or the default 'true')"
rm -rf "$CFG_HOME"; exit 1
fi
if CBM_CACHE_DIR="$CFG_HOME" "$BINARY" config get totally_bogus_key >/dev/null 2>&1; then
echo "FAIL: config get of an unknown key exited 0"
rm -rf "$CFG_HOME"; exit 1
fi
rm -rf "$CFG_HOME"
echo "OK: config get returns defaults and errors on unknown keys"

echo "OK: search_graph found $TOTAL result(s) for 'compute'"

# 3b: trace_path — verify compute has callers
Expand Down
78 changes: 61 additions & 17 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -6510,6 +6510,43 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) {

/* ── Config CLI subcommand ────────────────────────────────────── */

/* THE config-key table. list, get, help, and key validation all read this one
* definition, so a key cannot exist with different defaults in different
* subcommands again: `config list` used to print stored-or-DEFAULT while
* `config get` printed stored-or-EMPTY — every unset key (and every typo'd
* key, at exit 0) "got" an empty string, indistinguishable from a real value
* (#1522). The defaults here mirror the runtime readers' fallbacks
* (cbm_config_get_bool/int call sites); keep them in sync. */
typedef struct {
const char *key;
const char *default_value;
const char *help;
} config_key_def_t;

static const config_key_def_t CONFIG_KEYS[] = {
{CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"},
{CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"},
{CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"},
{CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"},
};

static const config_key_def_t *config_key_lookup(const char *key) {
for (size_t i = 0; key && i < sizeof(CONFIG_KEYS) / sizeof(CONFIG_KEYS[0]); i++) {
if (strcmp(CONFIG_KEYS[i].key, key) == 0) {
return &CONFIG_KEYS[i];
}
}
return NULL;
}

static void config_print_unknown_key(const char *key) {
(void)fprintf(stderr, "error: unknown config key: %s\nKnown keys:", key ? key : "");
for (size_t i = 0; i < sizeof(CONFIG_KEYS) / sizeof(CONFIG_KEYS[0]); i++) {
(void)fprintf(stderr, " %s", CONFIG_KEYS[i].key);
}
(void)fprintf(stderr, "\n");
}

int cbm_cmd_config(int argc, char **argv) {
/* NULL argv with a nonzero argc previously slipped past this guard (the
* inner `argv &&` shielded only the help comparison) and dereferenced
Expand All @@ -6522,14 +6559,10 @@ int cbm_cmd_config(int argc, char **argv) {
printf(" set <key> <val> Set a config value\n");
printf(" reset <key> Reset a key to default\n\n");
printf("Config keys:\n");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_INDEX, "false",
"Enable auto-indexing on MCP session start");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_INDEX_LIMIT, "50000",
"Max files for auto-indexing new projects");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_WATCH, "true",
"Register background git watcher on session connect");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
"Pin graph UI language: en, zh, or auto");
for (size_t i = 0; i < sizeof(CONFIG_KEYS) / sizeof(CONFIG_KEYS[0]); i++) {
printf(" %-25s default=%-10s %s\n", CONFIG_KEYS[i].key, CONFIG_KEYS[i].default_value,
CONFIG_KEYS[i].help);
}
return 0;
}

Expand All @@ -6551,25 +6584,33 @@ int cbm_cmd_config(int argc, char **argv) {
int rc = 0;
if (strcmp(argv[0], "list") == 0 || strcmp(argv[0], "ls") == 0) {
printf("Configuration:\n");
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX,
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX, "false"));
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX_LIMIT,
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_WATCH,
cbm_config_get(cfg, CBM_CONFIG_AUTO_WATCH, "true"));
printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
for (size_t i = 0; i < sizeof(CONFIG_KEYS) / sizeof(CONFIG_KEYS[0]); i++) {
printf(" %-25s = %-10s\n", CONFIG_KEYS[i].key,
cbm_config_get(cfg, CONFIG_KEYS[i].key, CONFIG_KEYS[i].default_value));
}
} else if (strcmp(argv[0], "get") == 0) {
if (argc < MIN_ARGC_GET) {
(void)fprintf(stderr, "Usage: config get <key>\n");
rc = CLI_TRUE;
} else {
printf("%s\n", cbm_config_get(cfg, argv[CLI_SKIP_ONE], ""));
const config_key_def_t *def = config_key_lookup(argv[CLI_SKIP_ONE]);
if (!def) {
config_print_unknown_key(argv[CLI_SKIP_ONE]);
rc = CLI_TRUE;
} else {
/* Stored value or the key's real default — the same fallback
* the runtime readers use. `""` here was #1522's bug 2: every
* unset key read as empty with exit 0. */
printf("%s\n", cbm_config_get(cfg, def->key, def->default_value));
}
}
} else if (strcmp(argv[0], "set") == 0) {
if (argc < MIN_ARGC_CMD) {
(void)fprintf(stderr, "Usage: config set <key> <value>\n");
rc = CLI_TRUE;
} else if (!config_key_lookup(argv[CLI_SKIP_ONE])) {
config_print_unknown_key(argv[CLI_SKIP_ONE]);
rc = CLI_TRUE;
} else {
if (cbm_config_set(cfg, argv[CLI_SKIP_ONE], argv[CLI_PAIR_LEN]) == 0) {
printf("%s = %s\n", argv[CLI_SKIP_ONE], argv[CLI_PAIR_LEN]);
Expand All @@ -6582,6 +6623,9 @@ int cbm_cmd_config(int argc, char **argv) {
if (argc < MIN_ARGC_GET) {
(void)fprintf(stderr, "Usage: config reset <key>\n");
rc = CLI_TRUE;
} else if (!config_key_lookup(argv[CLI_SKIP_ONE])) {
config_print_unknown_key(argv[CLI_SKIP_ONE]);
rc = CLI_TRUE;
} else {
cbm_config_delete(cfg, argv[CLI_SKIP_ONE]);
printf("%s reset to default\n", argv[CLI_SKIP_ONE]);
Expand Down
71 changes: 47 additions & 24 deletions src/daemon/frontend.c
Original file line number Diff line number Diff line change
Expand Up @@ -492,33 +492,56 @@ static bool frontend_enqueue(frontend_state_t *state, char *message, bool conten
return false;
}
}
cbm_mutex_lock(&state->mutex);
bool stopped = state->stopping || state->failed;
bool capacity = state->count < FRONTEND_QUEUE_CAPACITY &&
state->queued_bytes <= FRONTEND_QUEUE_BYTES_MAX &&
length <= FRONTEND_QUEUE_BYTES_MAX - state->queued_bytes;
if (!stopped && capacity) {
size_t tail = (state->head + state->count) % FRONTEND_QUEUE_CAPACITY;
state->queue[tail] = (frontend_item_t){
.message = message,
.length = length,
.content_length_framed = content_length_framed,
.has_id = has_id,
.id = id,
.id_str = id_str,
};
state->count++;
state->queued_bytes += length;
/* A frame that exceeds the whole byte budget can never be admitted no
* matter how long we wait; only that case is a hard failure. */
if (length > FRONTEND_QUEUE_BYTES_MAX) {
cbm_mutex_lock(&state->mutex);
if (!state->stopping && !state->failed) {
state->failed = true;
state->stopping = true;
}
cbm_mutex_unlock(&state->mutex);
return true;
free(id_str);
return false;
}
if (!stopped) {
state->failed = true;
state->stopping = true;
/* A full queue is BACKPRESSURE, not failure. This runs on the stdin reader
* thread, so blocking here stops further reads and lets the kernel pipe
* absorb the client's burst — a pipelining client is a client going fast,
* not a client going wrong. The old code failed the whole session at frame
* capacity+1, killing it with every buffered response unwritten: any 7+
* pipelined requests (e.g. an agent issuing parallel tool calls) died with
* rc=1 and zero output (#1522 follow-up). Waits are bounded only by the
* stop/fail flags, which every teardown path already sets — the same
* condition the polling worker and watchdogs key on. */
for (;;) {
cbm_mutex_lock(&state->mutex);
bool stopped = state->stopping || state->failed;
if (stopped) {
cbm_mutex_unlock(&state->mutex);
free(id_str);
return false;
}
bool capacity = state->count < FRONTEND_QUEUE_CAPACITY &&
state->queued_bytes <= FRONTEND_QUEUE_BYTES_MAX &&
length <= FRONTEND_QUEUE_BYTES_MAX - state->queued_bytes;
if (capacity) {
size_t tail = (state->head + state->count) % FRONTEND_QUEUE_CAPACITY;
state->queue[tail] = (frontend_item_t){
.message = message,
.length = length,
.content_length_framed = content_length_framed,
.has_id = has_id,
.id = id,
.id_str = id_str,
};
state->count++;
state->queued_bytes += length;
cbm_mutex_unlock(&state->mutex);
return true;
}
cbm_mutex_unlock(&state->mutex);
cbm_usleep(FRONTEND_WAIT_US);
}
cbm_mutex_unlock(&state->mutex);
free(id_str);
return false;
}

static bool frontend_stop_begin(frontend_state_t *state) {
Expand Down
Loading
Loading