Skip to content

Commit 3f49db3

Browse files
feat(feedback): implement feedback-o-tron invoke dispatch (FFI-backed) (#193)
## What Builds feedback-o-tron *for real* (the follow-up you green-lit), replacing the "Grade D Alpha" stub in `feedback_ffi.zig`'s `boj_cartridge_invoke` with a live, state-machine-backed implementation, and switching the e2e cycle to the cartridge's **actual declared tools**. ## Key insight The stub already dispatched the **correct** `cartridge.json` tool names (`feedback_register_channel/start_collecting/submit/get_stats`) — it just returned `{"status":"stub"}`. The old e2e cycle invoked tools that **never existed** (`open_channel/summary/export/status/list_channels`), which is why it got `{}`. So the fix is: make the FFI real, and point the test at the real tools. ## Changes - **`feedback_ffi.zig`** — `boj_cartridge_invoke` now backs the 4 manifest tools with the existing `fb_*` channel state machine: - `feedback_register_channel` → `fb_register` → `{"slot":N,"channel":C,"state":1}` - `feedback_start_collecting` → `fb_start_collecting` → `{"slot":N,"collecting":true,"state":2}` - `feedback_submit` → `fb_submit` → `{"recorded":true,"slot":N,"feedback_count":K}` - `feedback_get_stats` → `fb_count`/`fb_positive_count`/… → `{"total_feedback":…,"positive":…,…}` - `channel`/`slot`/`sentiment` are read from the JSON args via a small, `std.json`-free byte scanner (`jsonInt`) — sufficient for the flat arg objects and version-stable. - **`feedback_ffi.zig` test** — the in-file invoke test now drives the real `register → start → submit ×2 → get_stats` cycle and asserts the real shapes (so `zig build test` covers it in CI). - **`tests/e2e_full.sh` Step 6** — rewritten to exercise the real tools via the `"arguments"` object (the field BojRest forwards to the FFI); asserts `slot` / `collecting:true` / `recorded:true` / `total_feedback`. Removes the `FEEDBACK_OTRON` skip from #192. - **No Elixir change** — the router already forwards `arguments` and returns the FFI's parsed JSON as the body. The cross-language flow (Zig JSON → Invoker parse → Jason re-encode → test) lands the keys the assertions read. ## Verification — please note `e2e_full.sh` passes `bash -n` + `shellcheck` locally. **The Zig could not be compiled locally**: this sandbox's network is GitHub-only — `ziglang.org` returns **403** and Zig isn't on GitHub releases, and apt is blocked. So the FFI is verified by **CI** (`abi-drift.yml` + `e2e.yml` both run `zig build`) and the added Zig unit test. I'll watch this PR and fix any compile error promptly. (If you'd rather I verify locally, allowing `ziglang.org` in the environment's network policy would let me install Zig 0.15.1.) The ABI is unchanged (only the dispatch body), so `abi-drift` should stay green. 🤖 Draft via Claude Code. --- _Generated by [Claude Code](https://claude.ai/code/session_019tMcRS1Dm1nWjjYP4WvbJa)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c540b1 commit 3f49db3

2 files changed

Lines changed: 105 additions & 77 deletions

File tree

cartridges/feedback-mcp/ffi/feedback_ffi.zig

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -259,29 +259,69 @@ pub export fn boj_cartridge_version() [*:0]const u8 {
259259

260260
const shim = @import("cartridge_shim.zig");
261261

262-
/// Dispatch the cartridge.json MCP tools. Grade D Alpha — each arm
263-
/// returns a stub JSON body shaped to the tool's intended response.
262+
/// Extract a (possibly negative) integer value for a quoted JSON key from a
263+
/// flat object string — e.g. `jsonInt(args, "\"slot\"", 0)`. A deliberate
264+
/// plain byte-scan (no std.json dependency) sufficient for the cartridge's
265+
/// flat `{ "slot": N, "sentiment": N }` argument objects. Returns `default`
266+
/// when the key or its number is absent.
267+
fn jsonInt(json: [*c]const u8, quoted_key: []const u8, default: c_int) c_int {
268+
if (json == null) return default;
269+
const s = std.mem.span(@as([*:0]const u8, @ptrCast(json)));
270+
const kpos = std.mem.indexOf(u8, s, quoted_key) orelse return default;
271+
var i = kpos + quoted_key.len;
272+
while (i < s.len and (s[i] == ' ' or s[i] == ':')) : (i += 1) {}
273+
var neg: c_int = 1;
274+
if (i < s.len and s[i] == '-') {
275+
neg = -1;
276+
i += 1;
277+
}
278+
var val: c_int = 0;
279+
var found = false;
280+
while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) {
281+
val = val * 10 + @as(c_int, @intCast(s[i] - '0'));
282+
found = true;
283+
}
284+
return if (found) neg * val else default;
285+
}
286+
287+
/// Dispatch the cartridge.json MCP tools against the live feedback state
288+
/// machine (the `fb_*` exports above). Reads `channel`/`slot`/`sentiment`
289+
/// from the JSON args object and returns each tool's real result shape.
264290
export fn boj_cartridge_invoke(
265291
tool_name: [*c]const u8,
266292
json_args: [*c]const u8,
267293
out_buf: [*c]u8,
268294
in_out_len: [*c]usize,
269295
) callconv(.c) i32 {
270-
_ = json_args;
271296
if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;
272297

273-
const body: []const u8 = if (shim.toolIs(tool_name, "feedback_register_channel"))
274-
"{\"result\":{\"status\":\"stub\"}}"
275-
else if (shim.toolIs(tool_name, "feedback_start_collecting"))
276-
"{\"result\":{\"status\":\"stub\"}}"
277-
else if (shim.toolIs(tool_name, "feedback_submit"))
278-
"{\"result\":{\"status\":\"stub\"}}"
279-
else if (shim.toolIs(tool_name, "feedback_get_stats"))
280-
"{\"result\":{\"metadata\":{},\"status\":\"stub\"}}"
281-
else
282-
return shim.RC_UNKNOWN_TOOL;
283-
284-
return shim.writeResult(out_buf, in_out_len, body);
298+
var buf: [512]u8 = undefined;
299+
300+
if (shim.toolIs(tool_name, "feedback_register_channel")) {
301+
const ch = jsonInt(json_args, "\"channel\"", @intFromEnum(FeedbackChannel.api_endpoint));
302+
const slot = fb_register(ch);
303+
if (slot < 0) return shim.writeResult(out_buf, in_out_len, "{\"error\":\"no_channel_slots_available\"}");
304+
const body = std.fmt.bufPrint(&buf, "{{\"slot\":{d},\"channel\":{d},\"state\":{d}}}", .{ slot, ch, @intFromEnum(FeedbackState.channel_registered) }) catch return shim.RC_RUNTIME_ERROR;
305+
return shim.writeResult(out_buf, in_out_len, body);
306+
} else if (shim.toolIs(tool_name, "feedback_start_collecting")) {
307+
const slot = jsonInt(json_args, "\"slot\"", 0);
308+
const rc = fb_start_collecting(slot);
309+
const body = std.fmt.bufPrint(&buf, "{{\"slot\":{d},\"collecting\":{},\"state\":{d}}}", .{ slot, rc == 0, fb_state(slot) }) catch return shim.RC_RUNTIME_ERROR;
310+
return shim.writeResult(out_buf, in_out_len, body);
311+
} else if (shim.toolIs(tool_name, "feedback_submit")) {
312+
const slot = jsonInt(json_args, "\"slot\"", 0);
313+
const sentiment = jsonInt(json_args, "\"sentiment\"", @intFromEnum(Sentiment.neutral));
314+
const count = fb_submit(slot, sentiment);
315+
if (count < 0) return shim.writeResult(out_buf, in_out_len, "{\"recorded\":false,\"error\":\"channel_not_collecting\"}");
316+
const body = std.fmt.bufPrint(&buf, "{{\"recorded\":true,\"slot\":{d},\"feedback_count\":{d}}}", .{ slot, count }) catch return shim.RC_RUNTIME_ERROR;
317+
return shim.writeResult(out_buf, in_out_len, body);
318+
} else if (shim.toolIs(tool_name, "feedback_get_stats")) {
319+
const slot = jsonInt(json_args, "\"slot\"", 0);
320+
const body = std.fmt.bufPrint(&buf, "{{\"slot\":{d},\"total_feedback\":{d},\"positive\":{d},\"negative\":{d},\"neutral\":{d},\"state\":{d}}}", .{ slot, fb_count(slot), fb_positive_count(slot), fb_negative_count(slot), fb_neutral_count(slot), fb_state(slot) }) catch return shim.RC_RUNTIME_ERROR;
321+
return shim.writeResult(out_buf, in_out_len, body);
322+
} else {
323+
return shim.RC_UNKNOWN_TOOL;
324+
}
285325
}
286326

287327
// ═══════════════════════════════════════════════════════════════════════
@@ -344,20 +384,29 @@ test "state transition validation" {
344384
// ADR-0006 invoke dispatch tests
345385
// ═══════════════════════════════════════════════════════════════════════
346386

347-
test "invoke: each declared tool succeeds" {
348-
var buf: [256]u8 = undefined;
349-
const tools = [_][]const u8{
350-
"feedback_register_channel",
351-
"feedback_start_collecting",
352-
"feedback_submit",
353-
"feedback_get_stats",
354-
};
355-
for (tools) |t| {
356-
var len: usize = buf.len;
357-
const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len);
358-
try std.testing.expectEqual(@as(i32, 0), rc);
359-
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null);
360-
}
387+
test "invoke: register -> start -> submit -> stats cycle returns real shapes" {
388+
fb_reset();
389+
var buf: [512]u8 = undefined;
390+
391+
var len: usize = buf.len;
392+
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_register_channel", "{\"channel\":2}", &buf, &len));
393+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"slot\":0") != null);
394+
395+
len = buf.len;
396+
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_start_collecting", "{\"slot\":0}", &buf, &len));
397+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"collecting\":true") != null);
398+
399+
len = buf.len;
400+
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_submit", "{\"slot\":0,\"sentiment\":1}", &buf, &len));
401+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"recorded\":true") != null);
402+
403+
len = buf.len;
404+
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_submit", "{\"slot\":0,\"sentiment\":-1}", &buf, &len));
405+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"recorded\":true") != null);
406+
407+
len = buf.len;
408+
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_get_stats", "{\"slot\":0}", &buf, &len));
409+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"total_feedback\":2") != null);
361410
}
362411

363412
test "invoke: unknown tool returns -1" {

tests/e2e_full.sh

Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -222,64 +222,43 @@ echo ""
222222
# POST /cartridge/:name/invoke endpoint (singular `cartridge`).
223223
bold "Step 6: Feedback-o-tron full cycle"
224224

225-
# feedback-o-tron's cartridge FFI (cartridges/feedback-mcp/ffi/feedback_ffi.zig,
226-
# boj_cartridge_invoke) is a self-described "Grade D Alpha" stub: it answers only
227-
# feedback_register_channel/start_collecting/submit/get_stats with a stub body and
228-
# returns RC_UNKNOWN_TOOL for everything else. The full open_channel/submit/
229-
# summary/export/status/list_channels cycle below is not yet implemented, so we
230-
# record it as skipped rather than assert a feature that does not exist. Set
231-
# FEEDBACK_OTRON=1 once the FFI (or a native Elixir handler) implements the cycle.
232-
if [ "${FEEDBACK_OTRON:-0}" != "1" ]; then
233-
yellow " SKIP: feedback-o-tron full cycle — cartridge FFI is a Grade-D-Alpha stub (not yet implemented)"
234-
SKIP=$((SKIP + 7))
235-
else
236-
# 6a: Register (open_channel)
237-
reg_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
238-
-H "Content-Type: application/json" \
239-
-d '{"tool":"open_channel","args":"{\"channel\":\"api\"}"}' 2>/dev/null || echo "{}")
240-
check "feedback register (open_channel)" '"slot"' "$reg_result"
241-
FEEDBACK_SLOT=$(echo "$reg_result" | jq -r '.slot // "0"' 2>/dev/null || echo "0")
242-
243-
# 6b: Submit feedback (positive)
244-
submit_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
245-
-H "Content-Type: application/json" \
246-
-d "{\"tool\":\"submit\",\"args\":\"{\\\"slot\\\":\\\"${FEEDBACK_SLOT}\\\",\\\"sentiment\\\":\\\"positive\\\"}\"}" \
247-
2>/dev/null || echo "{}")
248-
check "feedback submit (positive)" '"recorded"' "$submit_result"
225+
# All dispatches go through the unified POST /cartridge/:name/invoke endpoint.
226+
# The feedback-mcp FFI (feedback_ffi.zig boj_cartridge_invoke) backs the
227+
# cartridge.json tools with the live channel state machine; arguments are passed
228+
# as a JSON object under "arguments" (the field BojRest forwards to the FFI).
249229

250-
# 6c: Submit feedback (negative)
251-
submit_neg=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
230+
# 6a: Register an api_endpoint channel (FeedbackChannel.api_endpoint = 2)
231+
reg_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
252232
-H "Content-Type: application/json" \
253-
-d "{\"tool\":\"submit\",\"args\":\"{\\\"slot\\\":\\\"${FEEDBACK_SLOT}\\\",\\\"sentiment\\\":\\\"negative\\\"}\"}" \
254-
2>/dev/null || echo "{}")
255-
check "feedback submit (negative)" '"recorded"' "$submit_neg"
233+
-d '{"tool":"feedback_register_channel","arguments":{"channel":2}}' 2>/dev/null || echo "{}")
234+
check "feedback register channel" '"slot"' "$reg_result"
235+
FEEDBACK_SLOT=$(echo "$reg_result" | jq -r '.slot // 0' 2>/dev/null || echo 0)
256236

257-
# 6d: Summary
258-
summary_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
237+
# 6b: Start collecting on the slot
238+
collect_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
259239
-H "Content-Type: application/json" \
260-
-d '{"tool":"summary","args":"{}"}' 2>/dev/null || echo "{}")
261-
check "feedback summary" '"total_feedback"' "$summary_result"
240+
-d "{\"tool\":\"feedback_start_collecting\",\"arguments\":{\"slot\":${FEEDBACK_SLOT}}}" 2>/dev/null || echo "{}")
241+
check "feedback start collecting" '"collecting":true' "$collect_result"
262242

263-
# 6e: Export
264-
export_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
243+
# 6c: Submit positive (sentiment: positive = 1)
244+
submit_pos=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
265245
-H "Content-Type: application/json" \
266-
-d '{"tool":"export_feedback","args":"{}"}' 2>/dev/null || echo "{}")
267-
check "feedback export" '"export_feedback"' "$export_result"
246+
-d "{\"tool\":\"feedback_submit\",\"arguments\":{\"slot\":${FEEDBACK_SLOT},\"sentiment\":1}}" 2>/dev/null || echo "{}")
247+
check "feedback submit (positive)" '"recorded":true' "$submit_pos"
268248

269-
# 6f: Status check
270-
status_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
249+
# 6d: Submit negative (sentiment: negative = -1)
250+
submit_neg=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
271251
-H "Content-Type: application/json" \
272-
-d '{"tool":"status","args":"{}"}' 2>/dev/null || echo "{}")
273-
check "feedback status" '"feedback-o-tron"' "$status_result"
252+
-d "{\"tool\":\"feedback_submit\",\"arguments\":{\"slot\":${FEEDBACK_SLOT},\"sentiment\":-1}}" 2>/dev/null || echo "{}")
253+
check "feedback submit (negative)" '"recorded":true' "$submit_neg"
274254

275-
# 6g: Deregister — close the channel
276-
# Note: deregister is done via the FFI fb_deregister; we test through
277-
# the list_channels tool which should still work after channel close
278-
list_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
255+
# 6e: Stats — expect the two submissions to be counted
256+
stats_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
279257
-H "Content-Type: application/json" \
280-
-d '{"tool":"list_channels","args":"{}"}' 2>/dev/null || echo "{}")
281-
check "feedback list_channels" '"available"' "$list_result"
282-
fi
258+
-d "{\"tool\":\"feedback_get_stats\",\"arguments\":{\"slot\":${FEEDBACK_SLOT}}}" 2>/dev/null || echo "{}")
259+
check "feedback get_stats" '"total_feedback"' "$stats_result"
260+
fb_total=$(echo "$stats_result" | jq -r '.total_feedback // 0' 2>/dev/null || echo 0)
261+
check "feedback recorded 2 submissions" "1" "$([ "$fb_total" -ge 2 ] && echo 1 || echo 0)"
283262
echo ""
284263

285264
# Step 7 (order-ticket flow) lives in tests/order_ticket_e2e.sh against

0 commit comments

Comments
 (0)