8db3c8c7f7
Neuron Soul CI / build (pull_request) Failing after 13m18s
BLOCKER 1: use untyped reassignment (let x = ...) for the fallback bindings in agentic_resume instead of re-declaring typed let bindings (let x: Type = ...) for the same variable in the same scope. The typed form risks shadowing semantics that differ from the established pattern used everywhere else in the loop (e.g. agentic_loop line 720). BLOCKER 2: add empty-string guards in both bridge_save and agentic_resume. bridge_save now returns false without writing state if messages or tools_json is empty — preventing syntactically invalid JSON blobs. agentic_resume now returns an error envelope after the fallback resolution if either field is still empty, rather than passing empty strings into agentic_loop which would silently start a fresh turn with no context. Also add tests: - test_bridge_serialization.el: covers bridge_save empty-guard, golden-path raw-JSON round-trip, agentic_resume unknown/corrupt/missing-fields paths, and legacy string-escaped fallback path - test_sessions_routes.el: covers DELETE and PATCH /api/sessions/:id routes (valid args, unknown id, empty body) and GET /api/sessions regression after removal of the duplicate route_sessions() handler
258 lines
12 KiB
EmacsLisp
258 lines
12 KiB
EmacsLisp
// ── test_bridge_serialization.el ──────────────────────────────────────────────
|
|
//
|
|
// Tests for PR #20 fix/bridge-save-serialization:
|
|
// - bridge_save raw JSON serialization (BLOCKER 1 & 2 regression guards)
|
|
// - agentic_resume error-path handling
|
|
// - Legacy fallback: old string-escaped fields still readable
|
|
// - Corrupt/missing bridge state error envelope
|
|
// - Empty messages/tools_json guard in bridge_save
|
|
//
|
|
// What CANNOT be tested here without a live Anthropic API:
|
|
// - agentic_resume golden-path (calls agentic_loop which hits the API)
|
|
// - Full save/resume round-trip with a real tool_result
|
|
//
|
|
// To run:
|
|
// elc chat.el && ./soul --test tests/test_bridge_serialization.el
|
|
//
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
import "../chat.el"
|
|
|
|
// ── Test harness ──────────────────────────────────────────────────────────────
|
|
|
|
let pass_count: Int = 0
|
|
let fail_count: Int = 0
|
|
|
|
fn assert_eq(label: String, got: String, expected: String) -> Void {
|
|
if str_eq(got, expected) {
|
|
let pass_count = pass_count + 1
|
|
println(" PASS: " + label)
|
|
} else {
|
|
let fail_count = fail_count + 1
|
|
println(" FAIL: " + label)
|
|
println(" got: " + got)
|
|
println(" expected: " + expected)
|
|
}
|
|
}
|
|
|
|
fn assert_true(label: String, cond: Bool) -> Void {
|
|
if cond {
|
|
let pass_count = pass_count + 1
|
|
println(" PASS: " + label)
|
|
} else {
|
|
let fail_count = fail_count + 1
|
|
println(" FAIL: " + label)
|
|
}
|
|
}
|
|
|
|
fn assert_false(label: String, cond: Bool) -> Void {
|
|
assert_true(label, !cond)
|
|
}
|
|
|
|
fn assert_contains(label: String, haystack: String, needle: String) -> Void {
|
|
if str_contains(haystack, needle) {
|
|
let pass_count = pass_count + 1
|
|
println(" PASS: " + label)
|
|
} else {
|
|
let fail_count = fail_count + 1
|
|
println(" FAIL: " + label)
|
|
println(" missing '" + needle + "' in: " + haystack)
|
|
}
|
|
}
|
|
|
|
fn assert_not_contains(label: String, haystack: String, needle: String) -> Void {
|
|
if str_contains(haystack, needle) {
|
|
let fail_count = fail_count + 1
|
|
println(" FAIL: " + label)
|
|
println(" unexpected '" + needle + "' found in: " + haystack)
|
|
} else {
|
|
let pass_count = pass_count + 1
|
|
println(" PASS: " + label)
|
|
}
|
|
}
|
|
|
|
fn assert_not_empty(label: String, s: String) -> Void {
|
|
if str_eq(s, "") {
|
|
let fail_count = fail_count + 1
|
|
println(" FAIL: " + label + " (got empty string)")
|
|
} else {
|
|
let pass_count = pass_count + 1
|
|
println(" PASS: " + label)
|
|
}
|
|
}
|
|
|
|
// ── Section 1: bridge_save — empty messages guard ─────────────────────────────
|
|
//
|
|
// BLOCKER 2 regression guard: bridge_save must refuse to write a blob when
|
|
// messages or tools_json is empty, as the resulting JSON would be syntactically
|
|
// invalid (bare colon with no value).
|
|
|
|
println("")
|
|
println("1. bridge_save — empty messages guard")
|
|
|
|
let sid1: String = "test-session-empty-messages"
|
|
state_set("mcp_bridge:" + sid1, "")
|
|
|
|
let save1_ok: Bool = bridge_save(sid1, "claude-sonnet-4-5", "sys", "[]", "", "", "call-1")
|
|
assert_false("empty messages -> bridge_save returns false", save1_ok)
|
|
|
|
let saved1: String = state_get("mcp_bridge:" + sid1)
|
|
assert_eq("empty messages -> no blob written to state", saved1, "")
|
|
|
|
// ── Section 2: bridge_save — empty tools_json guard ───────────────────────────
|
|
|
|
println("")
|
|
println("2. bridge_save — empty tools_json guard")
|
|
|
|
let sid2: String = "test-session-empty-tools"
|
|
state_set("mcp_bridge:" + sid2, "")
|
|
|
|
let save2_ok: Bool = bridge_save(sid2, "claude-sonnet-4-5", "sys", "", "[{\"role\":\"user\",\"content\":\"hi\"}]", "", "call-2")
|
|
assert_false("empty tools_json -> bridge_save returns false", save2_ok)
|
|
|
|
let saved2: String = state_get("mcp_bridge:" + sid2)
|
|
assert_eq("empty tools_json -> no blob written to state", saved2, "")
|
|
|
|
// ── Section 3: bridge_save — golden path writes raw JSON fields ───────────────
|
|
//
|
|
// Verifies that messages_raw and tools_raw are stored as inline JSON (not
|
|
// string-escaped) so that json_get_raw retrieves them without corruption.
|
|
|
|
println("")
|
|
println("3. bridge_save — golden path writes messages_raw and tools_raw as raw JSON")
|
|
|
|
let sid3: String = "test-session-golden"
|
|
state_set("mcp_bridge:" + sid3, "")
|
|
|
|
let msgs3: String = "[{\"role\":\"user\",\"content\":\"hello\"}]"
|
|
let tools3: String = "[{\"name\":\"read_file\"}]"
|
|
let save3_ok: Bool = bridge_save(sid3, "claude-sonnet-4-5", "You are a helper.", tools3, msgs3, "read_file", "toolu_abc")
|
|
assert_true("valid args -> bridge_save returns true", save3_ok)
|
|
|
|
let blob3: String = state_get("mcp_bridge:" + sid3)
|
|
assert_not_empty("valid args -> blob written to state", blob3)
|
|
|
|
// messages_raw should be stored as a raw JSON array (not a quoted string)
|
|
// so json_get_raw on the blob returns the array directly
|
|
let raw_msgs3: String = json_get_raw(blob3, "messages_raw")
|
|
assert_contains("messages_raw field present in blob", blob3, "messages_raw")
|
|
assert_eq("messages_raw round-trips without corruption", raw_msgs3, msgs3)
|
|
|
|
let raw_tools3: String = json_get_raw(blob3, "tools_raw")
|
|
assert_eq("tools_raw round-trips without corruption", raw_tools3, tools3)
|
|
|
|
// Scalar fields should still be present as normal string-escaped JSON fields
|
|
let model3: String = json_get(blob3, "model")
|
|
assert_eq("model field preserved in blob", model3, "claude-sonnet-4-5")
|
|
|
|
let tool_use_id3: String = json_get(blob3, "tool_use_id")
|
|
assert_eq("tool_use_id field preserved in blob", tool_use_id3, "toolu_abc")
|
|
|
|
// Verify the blob does NOT contain old-style double-escaped fields
|
|
assert_not_contains("no legacy 'messages' string field in new-format blob", blob3, "\"messages\":\"")
|
|
assert_not_contains("no legacy 'tools_json' string field in new-format blob", blob3, "\"tools_json\":\"")
|
|
|
|
// ── Section 4: agentic_resume — unknown session_id returns error envelope ──────
|
|
|
|
println("")
|
|
println("4. agentic_resume — unknown session_id (empty state)")
|
|
|
|
let sid4: String = "test-session-unknown-xyzzy"
|
|
state_set("mcp_bridge:" + sid4, "")
|
|
|
|
let resume4: String = agentic_resume(sid4, "toolu_xyz", "some result")
|
|
assert_contains("unknown session_id -> error field present", resume4, "\"error\"")
|
|
assert_contains("unknown session_id -> reply field present", resume4, "\"reply\"")
|
|
assert_contains("unknown session_id -> 'unknown session_id' message", resume4, "unknown session_id")
|
|
let reply4: String = json_get(resume4, "reply")
|
|
assert_eq("unknown session_id -> reply is empty string", reply4, "")
|
|
|
|
// ── Section 5: agentic_resume — syntactically invalid JSON in state ───────────
|
|
|
|
println("")
|
|
println("5. agentic_resume — syntactically invalid JSON blob in state")
|
|
|
|
let sid5: String = "test-session-corrupt-json"
|
|
// Write a non-JSON value that state_get would return as-is
|
|
state_set("mcp_bridge:" + sid5, "NOT_JSON_AT_ALL")
|
|
|
|
let resume5: String = agentic_resume(sid5, "toolu_xyz", "some result")
|
|
// The function may take multiple paths here; in all cases it must not crash and
|
|
// must return a JSON envelope with at least an error or empty reply field.
|
|
// When json_get_raw returns "" on unparseable input, the guard catches it.
|
|
assert_contains("corrupt JSON blob -> resume returns JSON", resume5, "\"reply\"")
|
|
|
|
// ── Section 6: agentic_resume — blob with no messages produces error envelope ─
|
|
|
|
println("")
|
|
println("6. agentic_resume — blob missing messages_raw and messages fields")
|
|
|
|
let sid6: String = "test-session-no-messages"
|
|
// Blob with only model/safe_sys — no messages or tools
|
|
state_set("mcp_bridge:" + sid6, "{\"model\":\"claude-sonnet-4-5\",\"safe_sys\":\"sys\",\"tool_use_id\":\"toolu_abc\"}")
|
|
|
|
let resume6: String = agentic_resume(sid6, "toolu_abc", "result")
|
|
assert_contains("missing messages -> error field present", resume6, "\"error\"")
|
|
assert_contains("missing messages -> error mentions corrupt state", resume6, "corrupt bridge state")
|
|
let reply6: String = json_get(resume6, "reply")
|
|
assert_eq("missing messages -> reply is empty string", reply6, "")
|
|
|
|
// ── Section 7: Legacy fallback — old-format blob (string-escaped fields) ──────
|
|
//
|
|
// BLOCKER 1 regression guard: sessions saved before the fix used 'messages'
|
|
// and 'tools_json' as string-escaped fields. The fallback path in agentic_resume
|
|
// must read them correctly. We verify the fallback resolves the correct values
|
|
// before the function reaches the api call (which we cannot make in tests).
|
|
//
|
|
// We test the fallback by writing a legacy blob and verifying that
|
|
// agentic_resume does NOT return the "corrupt bridge state" error
|
|
// (which would mean the fallback is broken), instead it gets past the guard
|
|
// and then fails on the API call (outside our test scope).
|
|
//
|
|
// NOTE: We cannot confirm a successful API-dependent round-trip in this test;
|
|
// the goal is only to confirm the state-reading fallback path resolves values.
|
|
|
|
println("")
|
|
println("7. Legacy fallback — old-format blob with string-escaped 'messages' field")
|
|
|
|
let sid7: String = "test-session-legacy-format"
|
|
// Simulate an old-format blob: messages and tools_json as json_safe-escaped strings.
|
|
// json_safe escapes " to \" so the stored value is a JSON string containing the array.
|
|
let legacy_msgs: String = "[{\"role\":\"user\",\"content\":\"legacy hello\"}]"
|
|
let legacy_tools: String = "[{\"name\":\"read_file\"}]"
|
|
// Build the blob the OLD way: string-escaped
|
|
let safe_msgs: String = json_safe(legacy_msgs)
|
|
let safe_tools: String = json_safe(legacy_tools)
|
|
let legacy_blob: String = "{\"model\":\"claude-sonnet-4-5\",\"safe_sys\":\"sys\",\"messages\":\"" + safe_msgs + "\",\"tools_json\":\"" + safe_tools + "\",\"tool_use_id\":\"toolu_legacy\"}"
|
|
state_set("mcp_bridge:" + sid7, legacy_blob)
|
|
|
|
let resume7: String = agentic_resume(sid7, "toolu_legacy", "legacy result")
|
|
// The fallback should successfully read the fields and NOT return "corrupt bridge state"
|
|
assert_not_contains("legacy blob -> no 'corrupt bridge state' error (fallback working)", resume7, "corrupt bridge state")
|
|
// It will fail on API call in test env, but should get past the state-reading guard
|
|
// Accept "unknown session_id" NOT happening - the blob was found, just API fails
|
|
|
|
// ── Section 8: bridge_save with tool_use_id containing special chars ──────────
|
|
|
|
println("")
|
|
println("8. bridge_save — tool_use_id with JSON-special characters is escaped")
|
|
|
|
let sid8: String = "test-session-special-chars"
|
|
state_set("mcp_bridge:" + sid8, "")
|
|
|
|
let special_id: String = "toolu_test\"quoted\""
|
|
let msgs8: String = "[{\"role\":\"user\",\"content\":\"hi\"}]"
|
|
let tools8: String = "[{\"name\":\"read_file\"}]"
|
|
let save8_ok: Bool = bridge_save(sid8, "claude-sonnet-4-5", "sys", tools8, msgs8, "", special_id)
|
|
assert_true("special chars in tool_use_id -> bridge_save returns true", save8_ok)
|
|
|
|
let blob8: String = state_get("mcp_bridge:" + sid8)
|
|
// The blob must be parseable (json_get succeeds on it)
|
|
let retrieved_id: String = json_get(blob8, "tool_use_id")
|
|
assert_eq("tool_use_id with quotes round-trips via json_safe", retrieved_id, special_id)
|
|
|
|
// ── Summary ────────────────────────────────────────────────────────────────────
|
|
|
|
println("")
|
|
println("test_bridge_serialization.el: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")
|