diff --git a/src/mcp.zig b/src/mcp.zig index eef0de2f..ad3051b0 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -2197,6 +2197,59 @@ fn extractContextCandidates(task: []const u8, alloc: std.mem.Allocator, out: *st } } +// #570: fallback for tasks with no identifier-shaped token. Plain words +// (≥4 chars, glue/generic words dropped) sorted longest-first — longer words +// are more specific ("ranking" beats "fix") — capped like the identifier pass. +fn extractContextFallbackWords(task: []const u8, alloc: std.mem.Allocator, out: *std.ArrayList([]const u8)) void { + const stop = [_][]const u8{ + "that", "this", "with", "from", "into", "when", "where", + "what", "which", "then", "them", "they", "have", "will", + "should", "would", "could", "make", "makes", "using", "used", + "does", "like", "also", "than", "each", "more", "most", + "some", "such", "very", "just", "been", "being", "about", + "after", "before", "while", "there", "their", "other", "only", + "over", "under", "between", "improve", "implement", "ensure", "change", + "update", + }; + var words: std.ArrayList([]const u8) = .empty; + defer words.deinit(alloc); + var seen = std.StringHashMap(void).init(alloc); + defer seen.deinit(); + var i: usize = 0; + while (i < task.len) { + if (isContextIdentStart(task[i])) { + const start = i; + while (i < task.len and isContextIdentCont(task[i])) : (i += 1) {} + const tok = task[start..i]; + if (tok.len >= 4 and tok.len <= 64 and !seen.contains(tok)) { + var is_stop = false; + for (stop) |s| { + if (std.ascii.eqlIgnoreCase(tok, s)) { + is_stop = true; + break; + } + } + if (!is_stop) { + seen.put(tok, {}) catch {}; + words.append(alloc, tok) catch {}; + } + } + continue; + } + i += 1; + } + std.sort.block([]const u8, words.items, {}, struct { + pub fn lessThan(_: void, a: []const u8, b: []const u8) bool { + if (a.len != b.len) return a.len > b.len; + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + for (words.items) |w| { + out.append(alloc, w) catch {}; + if (out.items.len >= CONTEXT_MAX_CANDIDATES) return; + } +} + fn handleContext(io: std.Io, alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: *std.ArrayList(u8), explorer: *Explorer, project_root: []const u8) void { const task = getStr(args, "task") orelse { out.appendSlice(alloc, "error: missing 'task' argument") catch {}; @@ -2253,6 +2306,13 @@ fn handleContext(io: std.Io, alloc: std.mem.Allocator, args: *const std.json.Obj var candidates: std.ArrayList([]const u8) = .empty; extractContextCandidates(task, A, &candidates); + if (candidates.items.len == 0) { + // #570: all-lowercase tasks ("fix search ranking") carry no + // identifier-shaped token. Fall back to the task's plain words so the + // composer orients instead of dead-ending — natural language is the + // documented input shape. + extractContextFallbackWords(task, A, &candidates); + } if (candidates.items.len == 0) { out.appendSlice(alloc, "no candidate identifiers found in task — include symbol names (camelCase or snake_case) or \"quoted strings\" so the composer can extract keywords") catch {}; return; diff --git a/src/test_mcp.zig b/src/test_mcp.zig index 47f409ba..033254df 100644 --- a/src/test_mcp.zig +++ b/src/test_mcp.zig @@ -1982,3 +1982,39 @@ test "issue-538: temp roots are indexable only when CODEDB_ALLOW_TEMP opts in" { try testing.expect(!root_policy.isIndexableRoot("/usr/local/bin")); try testing.expect(!root_policy.isIndexableRoot("/")); } + + +test "issue-570: codedb_context falls back to plain words for all-lowercase tasks" { + // 'fix search ranking' has no identifier-shaped token (no snake_case, no + // camelCase, no quotes), so extractContextCandidates finds nothing and the + // handler dead-ends with 'no candidate identifiers found'. Natural-language + // tasks are the documented input shape — the composer must fall back to + // plain words instead of erroring. + var explorer = Explorer.init(testing.allocator, Explorer.DEFAULT_CONTENT_CACHE_CAPACITY); + defer explorer.deinit(); + try explorer.indexFile("src/ranking.zig", "pub fn rankingBoost() void {}\n"); + + var store = Store.init(testing.allocator); + defer store.deinit(); + var agents = AgentRegistry.init(testing.allocator); + defer agents.deinit(); + _ = try agents.register("__filesystem__"); + + var bench_ctx = mcp_mod.BenchContext.init(testing.allocator, ".", Explorer.DEFAULT_CONTENT_CACHE_CAPACITY); + defer bench_ctx.deinit(); + + const args_json = + \\{"task":"fix search ranking"} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, args_json, .{}); + defer parsed.deinit(); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); + bench_ctx.runDispatch(io, testing.allocator, .codedb_context, &parsed.value.object, &out, &store, &explorer, &agents); + + // An all-lowercase task must not dead-end… + try testing.expect(std.mem.indexOf(u8, out.items, "no candidate identifiers") == null); + // …its longest meaningful word ('ranking') must drive the composer. + try testing.expect(std.mem.indexOf(u8, out.items, "ranking") != null); +}