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
107 changes: 78 additions & 29 deletions cartridges/feedback-mcp/ffi/feedback_ffi.zig
Original file line number Diff line number Diff line change
Expand Up @@ -259,29 +259,69 @@ pub export fn boj_cartridge_version() [*:0]const u8 {

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

/// Dispatch the cartridge.json MCP tools. Grade D Alpha — each arm
/// returns a stub JSON body shaped to the tool's intended response.
/// Extract a (possibly negative) integer value for a quoted JSON key from a
/// flat object string — e.g. `jsonInt(args, "\"slot\"", 0)`. A deliberate
/// plain byte-scan (no std.json dependency) sufficient for the cartridge's
/// flat `{ "slot": N, "sentiment": N }` argument objects. Returns `default`
/// when the key or its number is absent.
fn jsonInt(json: [*c]const u8, quoted_key: []const u8, default: c_int) c_int {
if (json == null) return default;
const s = std.mem.span(@as([*:0]const u8, @ptrCast(json)));
const kpos = std.mem.indexOf(u8, s, quoted_key) orelse return default;
var i = kpos + quoted_key.len;
while (i < s.len and (s[i] == ' ' or s[i] == ':')) : (i += 1) {}
var neg: c_int = 1;
if (i < s.len and s[i] == '-') {
neg = -1;
i += 1;
}
var val: c_int = 0;
var found = false;
while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) {
val = val * 10 + @as(c_int, @intCast(s[i] - '0'));
found = true;
}
return if (found) neg * val else default;
}

/// Dispatch the cartridge.json MCP tools against the live feedback state
/// machine (the `fb_*` exports above). Reads `channel`/`slot`/`sentiment`
/// from the JSON args object and returns each tool's real result shape.
export fn boj_cartridge_invoke(
tool_name: [*c]const u8,
json_args: [*c]const u8,
out_buf: [*c]u8,
in_out_len: [*c]usize,
) callconv(.c) i32 {
_ = json_args;
if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;

const body: []const u8 = if (shim.toolIs(tool_name, "feedback_register_channel"))
"{\"result\":{\"status\":\"stub\"}}"
else if (shim.toolIs(tool_name, "feedback_start_collecting"))
"{\"result\":{\"status\":\"stub\"}}"
else if (shim.toolIs(tool_name, "feedback_submit"))
"{\"result\":{\"status\":\"stub\"}}"
else if (shim.toolIs(tool_name, "feedback_get_stats"))
"{\"result\":{\"metadata\":{},\"status\":\"stub\"}}"
else
return shim.RC_UNKNOWN_TOOL;

return shim.writeResult(out_buf, in_out_len, body);
var buf: [512]u8 = undefined;

if (shim.toolIs(tool_name, "feedback_register_channel")) {
const ch = jsonInt(json_args, "\"channel\"", @intFromEnum(FeedbackChannel.api_endpoint));
const slot = fb_register(ch);
if (slot < 0) return shim.writeResult(out_buf, in_out_len, "{\"error\":\"no_channel_slots_available\"}");
const body = std.fmt.bufPrint(&buf, "{{\"slot\":{d},\"channel\":{d},\"state\":{d}}}", .{ slot, ch, @intFromEnum(FeedbackState.channel_registered) }) catch return shim.RC_RUNTIME_ERROR;
return shim.writeResult(out_buf, in_out_len, body);
} else if (shim.toolIs(tool_name, "feedback_start_collecting")) {
const slot = jsonInt(json_args, "\"slot\"", 0);
const rc = fb_start_collecting(slot);
const body = std.fmt.bufPrint(&buf, "{{\"slot\":{d},\"collecting\":{},\"state\":{d}}}", .{ slot, rc == 0, fb_state(slot) }) catch return shim.RC_RUNTIME_ERROR;
return shim.writeResult(out_buf, in_out_len, body);
} else if (shim.toolIs(tool_name, "feedback_submit")) {
const slot = jsonInt(json_args, "\"slot\"", 0);
const sentiment = jsonInt(json_args, "\"sentiment\"", @intFromEnum(Sentiment.neutral));
const count = fb_submit(slot, sentiment);
if (count < 0) return shim.writeResult(out_buf, in_out_len, "{\"recorded\":false,\"error\":\"channel_not_collecting\"}");
const body = std.fmt.bufPrint(&buf, "{{\"recorded\":true,\"slot\":{d},\"feedback_count\":{d}}}", .{ slot, count }) catch return shim.RC_RUNTIME_ERROR;
return shim.writeResult(out_buf, in_out_len, body);
} else if (shim.toolIs(tool_name, "feedback_get_stats")) {
const slot = jsonInt(json_args, "\"slot\"", 0);
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;
return shim.writeResult(out_buf, in_out_len, body);
} else {
return shim.RC_UNKNOWN_TOOL;
}
}

// ═══════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -344,20 +384,29 @@ test "state transition validation" {
// ADR-0006 invoke dispatch tests
// ═══════════════════════════════════════════════════════════════════════

test "invoke: each declared tool succeeds" {
var buf: [256]u8 = undefined;
const tools = [_][]const u8{
"feedback_register_channel",
"feedback_start_collecting",
"feedback_submit",
"feedback_get_stats",
};
for (tools) |t| {
var len: usize = buf.len;
const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len);
try std.testing.expectEqual(@as(i32, 0), rc);
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null);
}
test "invoke: register -> start -> submit -> stats cycle returns real shapes" {
fb_reset();
var buf: [512]u8 = undefined;

var len: usize = buf.len;
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_register_channel", "{\"channel\":2}", &buf, &len));
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"slot\":0") != null);

len = buf.len;
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_start_collecting", "{\"slot\":0}", &buf, &len));
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"collecting\":true") != null);

len = buf.len;
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_submit", "{\"slot\":0,\"sentiment\":1}", &buf, &len));
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"recorded\":true") != null);

len = buf.len;
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_submit", "{\"slot\":0,\"sentiment\":-1}", &buf, &len));
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"recorded\":true") != null);

len = buf.len;
try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("feedback_get_stats", "{\"slot\":0}", &buf, &len));
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"total_feedback\":2") != null);
}

test "invoke: unknown tool returns -1" {
Expand Down
75 changes: 27 additions & 48 deletions tests/e2e_full.sh
Original file line number Diff line number Diff line change
Expand Up @@ -222,64 +222,43 @@ echo ""
# POST /cartridge/:name/invoke endpoint (singular `cartridge`).
bold "Step 6: Feedback-o-tron full cycle"

# feedback-o-tron's cartridge FFI (cartridges/feedback-mcp/ffi/feedback_ffi.zig,
# boj_cartridge_invoke) is a self-described "Grade D Alpha" stub: it answers only
# feedback_register_channel/start_collecting/submit/get_stats with a stub body and
# returns RC_UNKNOWN_TOOL for everything else. The full open_channel/submit/
# summary/export/status/list_channels cycle below is not yet implemented, so we
# record it as skipped rather than assert a feature that does not exist. Set
# FEEDBACK_OTRON=1 once the FFI (or a native Elixir handler) implements the cycle.
if [ "${FEEDBACK_OTRON:-0}" != "1" ]; then
yellow " SKIP: feedback-o-tron full cycle — cartridge FFI is a Grade-D-Alpha stub (not yet implemented)"
SKIP=$((SKIP + 7))
else
# 6a: Register (open_channel)
reg_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
-H "Content-Type: application/json" \
-d '{"tool":"open_channel","args":"{\"channel\":\"api\"}"}' 2>/dev/null || echo "{}")
check "feedback register (open_channel)" '"slot"' "$reg_result"
FEEDBACK_SLOT=$(echo "$reg_result" | jq -r '.slot // "0"' 2>/dev/null || echo "0")

# 6b: Submit feedback (positive)
submit_result=$(curl -sf -X POST "$BASE_URL/cartridge/feedback-mcp/invoke" \
-H "Content-Type: application/json" \
-d "{\"tool\":\"submit\",\"args\":\"{\\\"slot\\\":\\\"${FEEDBACK_SLOT}\\\",\\\"sentiment\\\":\\\"positive\\\"}\"}" \
2>/dev/null || echo "{}")
check "feedback submit (positive)" '"recorded"' "$submit_result"
# All dispatches go through the unified POST /cartridge/:name/invoke endpoint.
# The feedback-mcp FFI (feedback_ffi.zig boj_cartridge_invoke) backs the
# cartridge.json tools with the live channel state machine; arguments are passed
# as a JSON object under "arguments" (the field BojRest forwards to the FFI).

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

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

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

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

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

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