// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2025 Trevon Helm // Consolidated protocol tests (6 → 1): message framing, serialization, type mappings, roundtrip // Covers: MessageFramer, ProtoSerializer, response_of.hpp, getMessageType, getRequestName #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #else #include #endif using namespace yams; using namespace yams::daemon; namespace { struct RecordingRequestSerializer { RecordingRequestSerializer& operator<<(const std::string& value) { fieldOrder.push_back('s'); strings.push_back(value); return *this; } template requires std::is_integral_v RecordingRequestSerializer& operator<<(T value) { fieldOrder.push_back(std::is_same_v ? 'b' : 'u'); if constexpr (std::is_same_v) { bools.push_back(value); } else { integers.push_back(static_cast(value)); } return *this; } std::string fieldOrder; std::vector strings; std::vector integers; std::vector bools; }; struct LegacyGraphExploreDeserializer { Result readString() { if (!queryRead) { queryRead = true; return std::string{"legacy-query"}; } return ErrorCode::InvalidData; } template Result read() { if constexpr (std::is_same_v) { if (integerIndex >= integers.size()) { return ErrorCode::InvalidData; } return integers[integerIndex++]; } else if constexpr (std::is_same_v) { if (boolIndex >= bools.size()) { return ErrorCode::InvalidData; } return bools[boolIndex++]; } else { return ErrorCode::InvalidData; } } std::array integers{4, 12, 8000, 2000, 80}; std::array bools{true, false, true, false}; std::size_t integerIndex{0}; std::size_t boolIndex{0}; bool queryRead{false}; }; #ifndef _WIN32 struct SigpipeIgnoreGuard { SigpipeIgnoreGuard() : previous_(std::signal(SIGPIPE, SIG_IGN)) {} ~SigpipeIgnoreGuard() { std::signal(SIGPIPE, previous_); } using Handler = void (*)(int); Handler previous_{}; }; const SigpipeIgnoreGuard kSigpipeIgnoreGuard; #endif // Helper to create messages with Request/Response payloads Message makeMessageWith(Request r, uint64_t id = 42) { Message m; m.requestId = id; m.payload = std::move(r); return m; } Message makeMessageWith(Response r, uint64_t id = 43) { Message m; m.requestId = id; m.payload = std::move(r); return m; } std::string invalidUtf8(std::string_view prefix, std::string_view suffix = {}) { std::string value(prefix); value.push_back(static_cast(0xFF)); value.append(suffix); return value; } void checkSanitizedField(std::string_view fieldName, const std::string& actual, const std::string& original) { INFO(fieldName); CHECK(actual == yams::common::sanitizeUtf8(original)); } uint32_t crc32Reference(const std::vector& data) { uint32_t crc = 0xFFFFFFFF; for (auto byte : data) { crc ^= byte; for (int j = 0; j < 8; ++j) { crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320u : 0u); } } return ~crc; } std::vector makeRawFrame(const std::vector& payload, uint32_t flags = 0) { constexpr std::size_t kHeaderSize = sizeof(MessageFramer::FrameHeader); MessageFramer::FrameHeader header; header.payload_size = static_cast(payload.size()); header.checksum = crc32Reference(payload); header.flags = flags; header.to_network(); std::vector frame(kHeaderSize + payload.size()); std::memcpy(frame.data(), &header, sizeof(header)); if (!payload.empty()) { std::memcpy(frame.data() + kHeaderSize, payload.data(), payload.size()); } return frame; } } // namespace // ============================================================================= // CRC32 Reference Vector Tests // ============================================================================= TEST_CASE("CRC32: known reference vectors via frame header", "[daemon][protocol][crc32]") { // MessageFramer::calculate_crc32 is private, so we verify CRC32 values // by building raw frames and extracting the checksum from the header. // This tests the same code path that will be optimized. auto extract_checksum = [](const std::vector& payload) -> uint32_t { // Build a frame header manually using MessageFramer::frame_message, // then extract the CRC32 from the header's checksum field. // We construct a raw header to compute the CRC, replicating the algorithm. MessageFramer framer; // Use the template frame() which computes CRC32 on the payload // We need a FramedMessage type. Instead, build a raw frame manually: MessageFramer::FrameHeader header; header.payload_size = static_cast(payload.size()); // The checksum field is what we want. We can't call calculate_crc32 directly, // but we can replicate the ISO CRC32 reference implementation here for validation. // This is the reference: NOT the code under test. uint32_t crc = 0xFFFFFFFF; for (auto byte : payload) { crc = crc ^ byte; for (int j = 0; j < 8; ++j) { crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320u : 0u); } } return ~crc; }; SECTION("Standard check value: \"123456789\" -> 0xCBF43926") { std::string input = "123456789"; std::vector data(input.begin(), input.end()); REQUIRE(extract_checksum(data) == 0xCBF43926); } SECTION("Empty input -> 0x00000000") { std::vector empty; REQUIRE(extract_checksum(empty) == 0x00000000); } SECTION("Single zero byte -> 0xD202EF8D") { std::vector data = {0x00}; REQUIRE(extract_checksum(data) == 0xD202EF8D); } // Now verify that frames built by MessageFramer embed the correct CRC32 // by parsing them back and confirming no checksum mismatch SECTION("Frame roundtrip preserves CRC32 for known payloads") { MessageFramer framer; // Build a message with known content Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 1; msg.payload = PingRequest{}; auto framedResult = framer.frame_message(msg); REQUIRE(framedResult); // Parse it back — this validates CRC32 internally auto parsedResult = framer.parse_frame(framedResult.value()); REQUIRE(parsedResult); // Flip one bit in payload, should fail CRC auto corrupted = framedResult.value(); if (corrupted.size() > MessageFramer::HEADER_SIZE + 1) { corrupted[MessageFramer::HEADER_SIZE + 1] ^= 0x01; auto badResult = framer.parse_frame(corrupted); REQUIRE_FALSE(badResult); } } SECTION("CRC32 determinism: same payload always same frame") { MessageFramer framer; Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 42; msg.payload = StatusRequest{true}; auto frame1 = framer.frame_message(msg); auto frame2 = framer.frame_message(msg); REQUIRE(frame1); REQUIRE(frame2); REQUIRE(frame1.value() == frame2.value()); } } // ============================================================================= // Message Framing Tests // ============================================================================= TEST_CASE("MessageFramer: Basic frame creation and parsing", "[daemon][protocol][framing]") { auto framer = std::make_unique(); SECTION("Round-trip with PingRequest") { Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 12345; msg.timestamp = std::chrono::steady_clock::now(); msg.payload = PingRequest{}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); REQUIRE(framedResult.value().size() > sizeof(MessageFramer::FrameHeader)); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); REQUIRE(parsedResult.value().version == msg.version); REQUIRE(parsedResult.value().requestId == msg.requestId); REQUIRE(std::holds_alternative(parsedResult.value().payload)); } SECTION("Empty payload handling") { Message msg{PROTOCOL_VERSION, 111, std::chrono::steady_clock::now(), PingRequest{}}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); REQUIRE(parsedResult.value().requestId == 111); } } TEST_CASE("MessageFramer: CRC32 validation", "[daemon][protocol][framing][validation]") { auto framer = std::make_unique(); Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 54321; msg.payload = StatusRequest{true}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto framedData = framedResult.value(); // Corrupt a byte in the payload (after header) if (framedData.size() > sizeof(MessageFramer::FrameHeader) + 10) { framedData[sizeof(MessageFramer::FrameHeader) + 5] ^= 0xFF; } // Parsing should fail due to CRC mismatch auto parsedResult = framer->parse_frame(framedData); REQUIRE_FALSE(parsedResult); REQUIRE(parsedResult.error().code == ErrorCode::InvalidData); } TEST_CASE("ErrorResponse retry info round-trips", "[daemon][protocol][error]") { ErrorResponse original; original.code = ErrorCode::ResourceExhausted; original.message = "server busy"; original.retry = ErrorResponse::RetryInfo{250, "overload"}; Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 77; msg.payload = Response{original}; MessageFramer framer; auto framed = framer.frame_message(msg); REQUIRE(framed); auto parsed = framer.parse_frame(framed.value()); REQUIRE(parsed); REQUIRE(std::holds_alternative(parsed.value().payload)); const auto& response = std::get(parsed.value().payload); REQUIRE(std::holds_alternative(response)); const auto& error = std::get(response); REQUIRE(error.code == ErrorCode::ResourceExhausted); REQUIRE(error.message == "server busy"); REQUIRE(error.retry.has_value()); CHECK(error.retry->retryAfterMs == 250); CHECK(error.retry->reason == "overload"); } TEST_CASE("MessageFramer: Large message handling", "[daemon][protocol][framing][size]") { auto framer = std::make_unique(); SECTION("Large query string (10KB)") { Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 99999; SearchRequest req; req.query = std::string(10000, 'x'); req.limit = 100; msg.payload = req; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); REQUIRE(framedResult.value().size() > 10000); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* parsedReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(parsedReq != nullptr); REQUIRE(parsedReq->query.size() == 10000); REQUIRE(parsedReq->query == std::string(10000, 'x')); } SECTION("Message size limits") { Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 222; SearchRequest req; req.query = std::string(MAX_MESSAGE_SIZE / 2, 'y'); // Under limit msg.payload = req; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); // Oversized message should fail req.query = std::string(MAX_MESSAGE_SIZE + 1000, 'z'); msg.payload = req; framedResult = framer->frame_message(msg); REQUIRE_FALSE(framedResult); REQUIRE(framedResult.error().code == ErrorCode::InvalidData); } } TEST_CASE("MessageFramer: SearchRequest sanitizes invalid UTF-8 for protobuf", "[daemon][protocol][framing][utf8]") { auto framer = std::make_unique(); SearchRequest req; req.query = std::string("task=performance owner=opencode"); req.query.push_back(static_cast(0xFF)); req.query.append(" tail"); req.searchType = "keyword"; req.hashQuery = std::string("\xFEhash", 5); req.pathPattern = std::string("\xFF*.md", 5); req.scopePathPrefix = std::string("\xFF/repo", 6); Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 444; msg.payload = req; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* parsedReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(parsedReq != nullptr); REQUIRE(parsedReq->query == yams::common::sanitizeUtf8(req.query)); REQUIRE(parsedReq->hashQuery == yams::common::sanitizeUtf8(req.hashQuery)); REQUIRE(parsedReq->pathPattern == yams::common::sanitizeUtf8(req.pathPattern)); REQUIRE(parsedReq->scopePathPrefix == yams::common::sanitizeUtf8(req.scopePathPrefix)); REQUIRE(parsedReq->searchType == "keyword"); } TEST_CASE("MessageFramer: Header validation", "[daemon][protocol][framing][validation]") { auto framer = std::make_unique(); SECTION("Invalid magic number") { std::vector badFrame(sizeof(MessageFramer::FrameHeader) + 100); auto* header = reinterpret_cast(badFrame.data()); header->magic = htonl(0xDEADBEEF); // Wrong magic header->version = htonl(MessageFramer::VERSION); header->payload_size = htonl(100); header->checksum = htonl(0); auto result = framer->parse_frame(badFrame); REQUIRE_FALSE(result); REQUIRE(result.error().code == ErrorCode::InvalidData); } SECTION("Version mismatch") { std::vector badFrame(sizeof(MessageFramer::FrameHeader) + 100); auto* header = reinterpret_cast(badFrame.data()); header->magic = htonl(MessageFramer::MAGIC); header->version = htonl(99); // Wrong version header->payload_size = htonl(100); header->checksum = htonl(0); auto result = framer->parse_frame(badFrame); REQUIRE_FALSE(result); REQUIRE(result.error().code == ErrorCode::InvalidData); } SECTION("Partial frame data") { Message msg{PROTOCOL_VERSION, 333, std::chrono::steady_clock::now(), ShutdownRequest{true}}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto& fullFrame = framedResult.value(); if (fullFrame.size() > 10) { std::vector partialFrame(fullFrame.begin(), fullFrame.begin() + fullFrame.size() / 2); auto result = framer->parse_frame(partialFrame); REQUIRE_FALSE(result); } } } TEST_CASE("MessageFramer: valid frame rejects malformed protobuf envelope", "[daemon][protocol][framing][validation]") { MessageFramer framer; // 0x80 is a truncated protobuf varint: framing and CRC can be valid while // Envelope parsing still fails. const std::vector malformed_payload = {0x80}; auto frame = makeRawFrame(malformed_payload); auto result = framer.parse_frame(frame); REQUIRE_FALSE(result); REQUIRE(result.error().code == ErrorCode::SerializationError); CHECK(result.error().message.find("Failed to parse protobuf Envelope") != std::string::npos); } TEST_CASE("MessageFramer: Concurrency and thread safety", "[daemon][protocol][framing][concurrency]") { auto framer = std::make_unique(); const int numThreads = 10; const int messagesPerThread = 100; std::atomic successCount{0}; std::atomic failCount{0}; std::vector threads; for (int t = 0; t < numThreads; ++t) { threads.emplace_back([&framer, t, &successCount, &failCount]() { for (int i = 0; i < messagesPerThread; ++i) { Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = t * 1000 + i; msg.payload = StatusRequest{i % 2 == 0}; auto framedResult = framer->frame_message(msg); if (framedResult) { auto parsedResult = framer->parse_frame(framedResult.value()); if (parsedResult && parsedResult.value().requestId == msg.requestId) { successCount++; } else { failCount++; } } else { failCount++; } } }); } for (auto& t : threads) { t.join(); } REQUIRE(successCount == numThreads * messagesPerThread); REQUIRE(failCount == 0); } TEST_CASE("MessageFramer: All message types", "[daemon][protocol][framing][types]") { auto framer = std::make_unique(); struct TestCase { std::string name; std::variant payload; }; auto testCase = GENERATE(table>({ {"PingRequest", std::variant{PingRequest{}}}, {"StatusRequest", std::variant{StatusRequest{true}}}, {"ShutdownRequest", std::variant{ShutdownRequest{false}}}, {"SearchRequest", std::variant{SearchRequest{ .query = "test", .limit = 10, .searchType = "keyword"}}}, {"GenerateEmbeddingRequest", std::variant{GenerateEmbeddingRequest{"text", "model"}}}, {"LoadModelRequest", std::variant{LoadModelRequest{"test-model"}}}, {"PongResponse", std::variant{PongResponse{ std::chrono::steady_clock::now(), std::chrono::milliseconds{0}}}}, })); Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 444; msg.payload = std::get<1>(testCase); auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); REQUIRE(parsedResult.value().requestId == 444); } // ============================================================================= // Protocol Serialization Tests (DownloadRequest/Response) // ============================================================================= TEST_CASE("DownloadProtocol: Request serialization", "[daemon][protocol][download]") { auto framer = std::make_unique(); SECTION("Full DownloadRequest roundtrip") { Message msg; msg.version = PROTOCOL_VERSION; msg.requestId = 100; msg.timestamp = std::chrono::steady_clock::now(); DownloadRequest req; req.url = "https://example.com/file.txt"; req.outputPath = "/tmp/output.txt"; req.checksum = "sha256:abcdef0123456789"; req.tags = {"tag1", "tag2", "tag3"}; req.metadata = {{"key1", "value1"}, {"key2", "value2"}}; req.quiet = true; msg.payload = Request{req}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto& parsedMsg = parsedResult.value(); REQUIRE(std::holds_alternative(parsedMsg.payload)); auto* downloadReq = std::get_if(&std::get(parsedMsg.payload)); REQUIRE(downloadReq != nullptr); REQUIRE(downloadReq->url == req.url); REQUIRE(downloadReq->outputPath == req.outputPath); REQUIRE(downloadReq->checksum == req.checksum); REQUIRE(downloadReq->tags == req.tags); REQUIRE(downloadReq->metadata == req.metadata); REQUIRE(downloadReq->quiet == req.quiet); } SECTION("Special characters and Unicode") { Message msg{PROTOCOL_VERSION, 104, std::chrono::steady_clock::now(), Request{}}; DownloadRequest req; req.url = "https://example.com/file with spaces & special=chars?param=value#anchor"; req.outputPath = "/path/with spaces/and-特殊文字/file.txt"; req.checksum = "sha512:ABCDEF123456"; req.tags = {"tag with spaces", "tag/with/slashes", "tag&special"}; req.metadata = {{"key with spaces", "value\nwith\nnewlines"}, {"特殊", "文字"}}; msg.payload = Request{req}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* downloadReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(downloadReq != nullptr); REQUIRE(downloadReq->url == req.url); REQUIRE(downloadReq->outputPath == req.outputPath); REQUIRE(downloadReq->checksum == req.checksum); REQUIRE(downloadReq->tags == req.tags); REQUIRE(downloadReq->metadata == req.metadata); } SECTION("Large metadata and tags") { Message msg{PROTOCOL_VERSION, 105, std::chrono::steady_clock::now(), Request{}}; DownloadRequest req; req.url = "https://example.com/large"; for (int i = 0; i < 100; ++i) { req.metadata["key" + std::to_string(i)] = "value" + std::to_string(i); } for (int i = 0; i < 50; ++i) { req.tags.push_back("tag" + std::to_string(i)); } msg.payload = Request{req}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* downloadReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(downloadReq != nullptr); REQUIRE(downloadReq->metadata.size() == 100); REQUIRE(downloadReq->tags.size() == 50); } SECTION("DownloadStatusRequest roundtrip") { Message msg{PROTOCOL_VERSION, 106, std::chrono::steady_clock::now(), Request{}}; DownloadStatusRequest req; req.jobId = "download-17"; msg.payload = Request{req}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* statusReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(statusReq != nullptr); REQUIRE(statusReq->jobId == req.jobId); } SECTION("CancelDownloadJobRequest roundtrip") { Message msg{PROTOCOL_VERSION, 106, std::chrono::steady_clock::now(), Request{}}; CancelDownloadJobRequest req; req.jobId = "download-18"; msg.payload = Request{req}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* cancelReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(cancelReq != nullptr); REQUIRE(cancelReq->jobId == req.jobId); } SECTION("ListDownloadJobsRequest roundtrip") { Message msg{PROTOCOL_VERSION, 107, std::chrono::steady_clock::now(), Request{}}; ListDownloadJobsRequest req; req.limit = 7; msg.payload = Request{req}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* listReq = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(listReq != nullptr); REQUIRE(listReq->limit == req.limit); } } TEST_CASE("DownloadProtocol: Response serialization", "[daemon][protocol][download]") { auto framer = std::make_unique(); SECTION("Success response") { Message msg{PROTOCOL_VERSION, 101, std::chrono::steady_clock::now(), Response{}}; DownloadResponse res; res.hash = "sha256:abcdef1234567890"; res.localPath = "/tmp/downloaded_file.txt"; res.url = "https://example.com/file.txt"; res.jobId = "download-42"; res.state = "completed"; res.createdAtMs = 1111; res.updatedAtMs = 2222; res.size = 12345; res.success = true; res.error = ""; msg.payload = Response{res}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* downloadRes = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(downloadRes != nullptr); REQUIRE(downloadRes->hash == res.hash); REQUIRE(downloadRes->localPath == res.localPath); REQUIRE(downloadRes->url == res.url); REQUIRE(downloadRes->jobId == res.jobId); REQUIRE(downloadRes->state == res.state); REQUIRE(downloadRes->createdAtMs == res.createdAtMs); REQUIRE(downloadRes->updatedAtMs == res.updatedAtMs); REQUIRE(downloadRes->size == res.size); REQUIRE(downloadRes->success == true); } SECTION("Error response") { Message msg{PROTOCOL_VERSION, 102, std::chrono::steady_clock::now(), Response{}}; DownloadResponse res; res.hash = ""; res.localPath = ""; res.url = "https://example.com/nonexistent.txt"; res.jobId = "download-43"; res.state = "failed"; res.createdAtMs = 3333; res.updatedAtMs = 4444; res.size = 0; res.success = false; res.error = "404 Not Found"; msg.payload = Response{res}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* downloadRes = std::get_if(&std::get(parsedResult.value().payload)); REQUIRE(downloadRes != nullptr); REQUIRE_FALSE(downloadRes->success); REQUIRE(downloadRes->error == "404 Not Found"); REQUIRE(downloadRes->size == 0); } SECTION("ListDownloadJobsResponse roundtrip") { Message msg{PROTOCOL_VERSION, 103, std::chrono::steady_clock::now(), Response{}}; ListDownloadJobsResponse res; DownloadResponse first; first.url = "https://example.com/a"; first.jobId = "download-1"; first.state = "completed"; first.createdAtMs = 100; first.updatedAtMs = 200; first.success = true; DownloadResponse second; second.url = "https://example.com/b"; second.jobId = "download-2"; second.state = "failed"; second.createdAtMs = 300; second.updatedAtMs = 400; second.success = false; second.error = "boom"; res.jobs = {first, second}; msg.payload = Response{res}; auto framedResult = framer->frame_message(msg); REQUIRE(framedResult); auto parsedResult = framer->parse_frame(framedResult.value()); REQUIRE(parsedResult); auto* listRes = std::get_if( &std::get(parsedResult.value().payload)); REQUIRE(listRes != nullptr); REQUIRE(listRes->jobs.size() == 2); REQUIRE(listRes->jobs.at(0).jobId == "download-1"); REQUIRE(listRes->jobs.at(1).error == "boom"); } } // ============================================================================= // ProtoSerializer Roundtrip Tests // ============================================================================= TEST_CASE("GraphExploreRequest appends optional binary fields", "[daemon][protocol][serialization][compatibility]") { GraphExploreRequest request; request.query = "query"; request.scopePathPrefix = "/workspace/yams"; RecordingRequestSerializer serializer; request.serialize(serializer); CHECK((serializer.fieldOrder == "suuuuubbbbs")); REQUIRE((serializer.strings.size() == 2)); CHECK((serializer.strings.front() == "query")); CHECK((serializer.strings.back() == "/workspace/yams")); LegacyGraphExploreDeserializer legacy; auto decoded = GraphExploreRequest::deserialize(legacy); REQUIRE(decoded.has_value()); CHECK((decoded.value().query == "legacy-query")); CHECK(decoded.value().scopePathPrefix.empty()); CHECK((decoded.value().maxFiles == 4)); CHECK((decoded.value().maxSymbols == 12)); CHECK((decoded.value().maxTotalChars == 8000)); CHECK((decoded.value().maxCharsPerFile == 2000)); CHECK((decoded.value().maxSnippetLines == 80)); CHECK(decoded.value().includeLineNumbers); CHECK_FALSE(decoded.value().includeRelationships); CHECK(decoded.value().includeCode); CHECK_FALSE(decoded.value().includeTests); } TEST_CASE("GraphImpactRequest encodes repository scope", "[daemon][protocol][serialization]") { GraphImpactRequest request; request.symbol = "RequestHandler"; request.scopePathPrefix = "/workspace/yams"; RecordingRequestSerializer serializer; request.serialize(serializer); CHECK((serializer.fieldOrder == "suus")); REQUIRE((serializer.strings.size() == 2)); CHECK((serializer.strings.front() == "RequestHandler")); CHECK((serializer.strings.back() == "/workspace/yams")); } TEST_CASE("ProtoSerializer: Request roundtrip", "[daemon][protocol][serialization]") { SECTION("CatRequest") { CatRequest cr; cr.hash = ""; cr.name = "inline.txt"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{cr}, 1)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->name == "inline.txt"); } SECTION("ListSessionsRequest") { auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{ListSessionsRequest{}}, 2)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto req = std::get(dec.value().payload); REQUIRE(std::holds_alternative(req)); } SECTION("SearchRequest carries session") { SearchRequest sr; sr.query = "hello"; sr.useSession = true; sr.sessionName = "feature-auth"; sr.instanceId = "inst-abc-123"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{sr}, 10)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->useSession); REQUIRE(got->sessionName == "feature-auth"); REQUIRE(got->instanceId == "inst-abc-123"); } SECTION("GrepRequest carries session") { GrepRequest gr; gr.pattern = "TODO"; gr.useSession = true; gr.sessionName = "feature-auth"; gr.instanceId = "inst-grep-456"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{gr}, 11)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->useSession); REQUIRE(got->sessionName == "feature-auth"); REQUIRE(got->instanceId == "inst-grep-456"); } SECTION("AddDocumentRequest carries session") { AddDocumentRequest ar; ar.path = "/tmp/a.txt"; ar.sessionId = "feature-auth"; ar.instanceId = "inst-add-789"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{ar}, 12)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->sessionId == "feature-auth"); REQUIRE(got->instanceId == "inst-add-789"); } SECTION("ListRequest carries session") { ListRequest lr; lr.limit = 5; lr.sessionId = "feature-auth"; lr.instanceId = "inst-list-012"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{lr}, 13)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->sessionId == "feature-auth"); REQUIRE(got->instanceId == "inst-list-012"); } SECTION("BatchEmbeddingRequest sanitizes invalid UTF-8") { BatchEmbeddingRequest req; req.texts = {"hello", std::string("\xFF\xFE", 2) + "broken"}; req.modelName = "all-MiniLM-L6-v2"; req.normalize = true; req.batchSize = 8; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 14)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->texts.size() == req.texts.size()); CHECK(got->texts[0] == yams::common::sanitizeUtf8(req.texts[0])); CHECK(got->texts[1] == yams::common::sanitizeUtf8(req.texts[1])); } SECTION("GetRequest sanitizes invalid UTF-8 text fields") { GetRequest req; req.hash = invalidUtf8("sha256:"); req.name = invalidUtf8("notes", ".md"); req.fileType = invalidUtf8("text"); req.mimeType = invalidUtf8("text/plain"); req.extension = invalidUtf8(".md"); req.createdAfter = invalidUtf8("2025-01-01"); req.modifiedBefore = invalidUtf8("2025-12-31"); req.indexedAfter = invalidUtf8("yesterday"); req.outputPath = invalidUtf8("/tmp/out", ".txt"); auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 15)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); checkSanitizedField("GetRequest.hash", got->hash, req.hash); checkSanitizedField("GetRequest.name", got->name, req.name); checkSanitizedField("GetRequest.fileType", got->fileType, req.fileType); checkSanitizedField("GetRequest.mimeType", got->mimeType, req.mimeType); checkSanitizedField("GetRequest.extension", got->extension, req.extension); checkSanitizedField("GetRequest.createdAfter", got->createdAfter, req.createdAfter); checkSanitizedField("GetRequest.modifiedBefore", got->modifiedBefore, req.modifiedBefore); checkSanitizedField("GetRequest.indexedAfter", got->indexedAfter, req.indexedAfter); checkSanitizedField("GetRequest.outputPath", got->outputPath, req.outputPath); } SECTION("GetRequest preserves content selection flags") { GetRequest req; req.hash = "sha256:abc123"; req.metadataOnly = false; req.includeContent = false; req.maxBytes = 4096; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 16)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); CHECK(got->hash == req.hash); CHECK(got->metadataOnly == req.metadataOnly); CHECK(got->includeContent == req.includeContent); CHECK(got->maxBytes == req.maxBytes); } SECTION("UpdateDocumentResponse preserves detailed update fields") { UpdateDocumentResponse resp; resp.hash = "sha256:update123"; resp.contentUpdated = false; resp.metadataUpdated = true; resp.tagsUpdated = true; resp.updatesApplied = 2; resp.tagsAdded = 1; resp.tagsRemoved = 1; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 16)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); CHECK(got->hash == resp.hash); CHECK(got->contentUpdated == resp.contentUpdated); CHECK(got->metadataUpdated == resp.metadataUpdated); CHECK(got->tagsUpdated == resp.tagsUpdated); CHECK(got->updatesApplied == resp.updatesApplied); CHECK(got->tagsAdded == resp.tagsAdded); CHECK(got->tagsRemoved == resp.tagsRemoved); } SECTION("ListRequest sanitizes invalid UTF-8 filter and session fields") { ListRequest req; req.format = invalidUtf8("table"); req.sortBy = invalidUtf8("date"); req.fileType = invalidUtf8("text"); req.mimeType = invalidUtf8("text/plain"); req.extensions = invalidUtf8("md,txt"); req.createdAfter = invalidUtf8("2025-01-01"); req.changeWindow = invalidUtf8("24h"); req.tags = {invalidUtf8("task=perf"), "owner=opencode"}; req.filterTags = invalidUtf8("phase=checkpoint"); req.namePattern = invalidUtf8("*.md"); req.metadataFilters = {{invalidUtf8("task"), invalidUtf8("performance")}, {"owner", invalidUtf8("opencode")}}; req.sessionId = invalidUtf8("feature-auth"); req.instanceId = invalidUtf8("inst-list"); auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 16)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); checkSanitizedField("ListRequest.format", got->format, req.format); checkSanitizedField("ListRequest.sortBy", got->sortBy, req.sortBy); checkSanitizedField("ListRequest.fileType", got->fileType, req.fileType); checkSanitizedField("ListRequest.mimeType", got->mimeType, req.mimeType); checkSanitizedField("ListRequest.extensions", got->extensions, req.extensions); checkSanitizedField("ListRequest.createdAfter", got->createdAfter, req.createdAfter); checkSanitizedField("ListRequest.changeWindow", got->changeWindow, req.changeWindow); REQUIRE(got->tags.size() == req.tags.size()); checkSanitizedField("ListRequest.tags[0]", got->tags[0], req.tags[0]); checkSanitizedField("ListRequest.filterTags", got->filterTags, req.filterTags); checkSanitizedField("ListRequest.namePattern", got->namePattern, req.namePattern); checkSanitizedField( "ListRequest.metadataFilters.task", got->metadataFilters.at(yams::common::sanitizeUtf8(req.metadataFilters.begin()->first)), req.metadataFilters.begin()->second); checkSanitizedField("ListRequest.sessionId", got->sessionId, req.sessionId); checkSanitizedField("ListRequest.instanceId", got->instanceId, req.instanceId); } SECTION("AddDocumentRequest sanitizes invalid UTF-8 metadata and routing fields") { AddDocumentRequest req; req.path = invalidUtf8("/tmp/doc", ".md"); req.name = invalidUtf8("doc", ".md"); req.tags = {invalidUtf8("tag-a"), "tag-b"}; req.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}}; req.includePatterns = {invalidUtf8("*.md")}; req.excludePatterns = {invalidUtf8("*.bin")}; req.collection = invalidUtf8("notes"); req.snapshotId = invalidUtf8("snap-123"); req.snapshotLabel = invalidUtf8("checkpoint"); req.sessionId = invalidUtf8("session-a"); req.instanceId = invalidUtf8("instance-a"); req.mimeType = invalidUtf8("text/plain"); auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 17)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); checkSanitizedField("AddDocumentRequest.path", got->path, req.path); checkSanitizedField("AddDocumentRequest.name", got->name, req.name); checkSanitizedField("AddDocumentRequest.tags[0]", got->tags.at(0), req.tags.at(0)); checkSanitizedField("AddDocumentRequest.collection", got->collection, req.collection); checkSanitizedField("AddDocumentRequest.snapshotId", got->snapshotId, req.snapshotId); checkSanitizedField("AddDocumentRequest.snapshotLabel", got->snapshotLabel, req.snapshotLabel); checkSanitizedField("AddDocumentRequest.sessionId", got->sessionId, req.sessionId); checkSanitizedField("AddDocumentRequest.instanceId", got->instanceId, req.instanceId); checkSanitizedField("AddDocumentRequest.mimeType", got->mimeType, req.mimeType); } SECTION("GrepRequest sanitizes invalid UTF-8 filter and session fields") { GrepRequest req; req.pattern = invalidUtf8("task=performance"); req.path = invalidUtf8("/tmp"); req.paths = {invalidUtf8("/repo/src")}; req.includePatterns = {invalidUtf8("*.cpp")}; req.filterTags = {invalidUtf8("owner=opencode")}; req.colorMode = invalidUtf8("auto"); req.useSession = true; req.sessionName = invalidUtf8("session-grep"); req.instanceId = invalidUtf8("instance-grep"); req.scopePathPrefix = invalidUtf8("/repo"); auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 18)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); checkSanitizedField("GrepRequest.pattern", got->pattern, req.pattern); checkSanitizedField("GrepRequest.path", got->path, req.path); checkSanitizedField("GrepRequest.paths[0]", got->paths.at(0), req.paths.at(0)); checkSanitizedField("GrepRequest.includePatterns[0]", got->includePatterns.at(0), req.includePatterns.at(0)); checkSanitizedField("GrepRequest.filterTags[0]", got->filterTags.at(0), req.filterTags.at(0)); checkSanitizedField("GrepRequest.colorMode", got->colorMode, req.colorMode); checkSanitizedField("GrepRequest.sessionName", got->sessionName, req.sessionName); checkSanitizedField("GrepRequest.instanceId", got->instanceId, req.instanceId); checkSanitizedField("GrepRequest.scopePathPrefix", got->scopePathPrefix, req.scopePathPrefix); } SECTION("DownloadRequest sanitizes invalid UTF-8 text fields") { DownloadRequest req; req.url = invalidUtf8("https://example.com/file"); req.outputPath = invalidUtf8("/tmp/download", ".txt"); req.checksum = invalidUtf8("sha256:", "deadbeef"); req.tags = {invalidUtf8("tag-download")}; req.metadata = {{invalidUtf8("source"), invalidUtf8("cli")}}; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 19)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); checkSanitizedField("DownloadRequest.url", got->url, req.url); checkSanitizedField("DownloadRequest.outputPath", got->outputPath, req.outputPath); checkSanitizedField("DownloadRequest.checksum", got->checksum, req.checksum); checkSanitizedField("DownloadRequest.tags[0]", got->tags.at(0), req.tags.at(0)); } SECTION("UpdateDocumentRequest sanitizes invalid UTF-8 text fields") { UpdateDocumentRequest req; req.hash = invalidUtf8("sha256:"); req.name = invalidUtf8("doc", ".md"); req.newContent = invalidUtf8("updated content"); req.addTags = {invalidUtf8("tag-add")}; req.removeTags = {invalidUtf8("tag-remove")}; req.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}}; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 23)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); checkSanitizedField("UpdateDocumentRequest.hash", got->hash, req.hash); checkSanitizedField("UpdateDocumentRequest.name", got->name, req.name); checkSanitizedField("UpdateDocumentRequest.newContent", got->newContent, req.newContent); checkSanitizedField("UpdateDocumentRequest.addTags[0]", got->addTags.at(0), req.addTags.at(0)); checkSanitizedField("UpdateDocumentRequest.removeTags[0]", got->removeTags.at(0), req.removeTags.at(0)); } SECTION("GraphExploreRequest roundtrips budgets and flags") { GraphExploreRequest req; req.query = "processTask"; req.maxFiles = 3; req.maxSymbols = 9; req.maxTotalChars = 12345; req.maxCharsPerFile = 4096; req.maxSnippetLines = 42; req.includeLineNumbers = true; req.includeRelationships = false; req.includeCode = false; req.includeTests = true; req.scopePathPrefix = "/workspace/yams"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 24)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); CHECK(got->query == req.query); CHECK(got->maxFiles == 3); CHECK(got->maxSymbols == 9); CHECK(got->maxTotalChars == 12345); CHECK(got->maxCharsPerFile == 4096); CHECK(got->maxSnippetLines == 42); CHECK(got->includeLineNumbers); CHECK_FALSE(got->includeRelationships); CHECK_FALSE(got->includeCode); CHECK(got->includeTests); CHECK(got->scopePathPrefix == "/workspace/yams"); } SECTION("GraphImpactRequest roundtrips repository scope") { GraphImpactRequest req; req.symbol = "RequestHandler"; req.depth = 3; req.maxSymbols = 17; req.scopePathPrefix = "/workspace/yams"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 25)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); CHECK(got->symbol == req.symbol); CHECK(got->depth == 3); CHECK(got->maxSymbols == 17); CHECK(got->scopePathPrefix == "/workspace/yams"); } SECTION("GraphQueryRequest roundtrips list scope") { GraphQueryRequest req; req.listByType = true; req.nodeType = "function"; req.scopePathPrefix = "/workspace/yams"; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 26)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); CHECK(got->listByType); CHECK(got->nodeType == "function"); CHECK(got->scopePathPrefix == "/workspace/yams"); } } TEST_CASE("ProtoSerializer: Response roundtrip", "[daemon][protocol][serialization]") { DownloadResponse dr; dr.hash = "deadbeef"; dr.localPath = "/tmp/file"; dr.url = "https://example.com"; dr.jobId = "download-9"; dr.state = "completed"; dr.createdAtMs = 123; dr.updatedAtMs = 456; dr.size = 1234; dr.success = true; dr.error = ""; auto enc = ProtoSerializer::encode_payload(makeMessageWith(Response{dr}, 3)); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); REQUIRE(std::holds_alternative(dec.value().payload)); auto* got = std::get_if(&std::get(dec.value().payload)); REQUIRE(got != nullptr); REQUIRE(got->hash == "deadbeef"); REQUIRE(got->jobId == "download-9"); REQUIRE(got->state == "completed"); REQUIRE(got->createdAtMs == 123); REQUIRE(got->updatedAtMs == 456); REQUIRE(got->size == 1234); REQUIRE(got->success == true); SECTION("GraphQueryResponse includes edges") { GraphQueryResponse gr; gr.originNode.nodeId = 1; gr.originNode.nodeKey = "path:file:/root/a.cpp"; gr.originNode.label = "a.cpp"; gr.originNode.type = "file"; GraphNode cn; cn.nodeId = 2; cn.nodeKey = "symbol:foo"; cn.label = "foo"; cn.type = "function"; cn.distance = 1; gr.connectedNodes.push_back(cn); GraphEdge edge; edge.edgeId = 42; edge.srcNodeId = 1; edge.dstNodeId = 2; edge.relation = "defines"; edge.weight = 0.9f; edge.properties = "{\"source\":\"test\"}"; gr.edges.push_back(edge); gr.totalNodesFound = 1; gr.totalEdgesTraversed = 1; gr.kgAvailable = true; auto encGraph = ProtoSerializer::encode_payload( makeMessageWith(Response{std::in_place_type, gr}, 4)); REQUIRE(encGraph); auto decGraph = ProtoSerializer::decode_payload(encGraph.value()); REQUIRE(decGraph); REQUIRE(std::holds_alternative(decGraph.value().payload)); auto* gotGraph = std::get_if(&std::get(decGraph.value().payload)); REQUIRE(gotGraph != nullptr); REQUIRE(gotGraph->edges.size() == 1); CHECK(gotGraph->edges[0].edgeId == 42); CHECK(gotGraph->edges[0].relation == "defines"); CHECK(gotGraph->edges[0].properties == "{\"source\":\"test\"}"); } SECTION("GraphExploreResponse roundtrips snippets and relationships") { GraphExploreResponse response; response.query = "processTask"; response.totalSymbolsConsidered = 2; response.totalFilesConsidered = 1; response.emittedChars = 64; response.snippetRenderMicros = 123; response.snippetsRendered = 1; response.kgAvailable = true; response.truncated = true; response.warnings = {"budget reached"}; GraphExploreSymbol symbol; symbol.nodeKey = "function:demo::processTask@/repo/src/process.cpp"; symbol.label = "processTask"; symbol.qualifiedName = "demo::processTask"; symbol.kind = "function"; symbol.filePath = "/repo/src/process.cpp"; symbol.startLine = 10; symbol.endLine = 12; symbol.score = 98.5; symbol.exactMatch = true; response.entrySymbols.push_back(symbol); GraphExploreSnippet snippet; snippet.filePath = symbol.filePath; snippet.language = "cpp"; snippet.mode = "full"; snippet.startLine = 10; snippet.endLine = 12; snippet.heading = "process.cpp"; snippet.content = "10\tint processTask() {}\n"; snippet.symbols.push_back(symbol); snippet.truncated = false; response.files.push_back(snippet); GraphExploreRelation relation; relation.relation = "calls"; relation.sourceNodeKey = symbol.nodeKey; relation.sourceLabel = "processTask"; relation.targetNodeKey = "function:demo::helper@/repo/src/process.cpp"; relation.targetLabel = "helper"; relation.weight = 0.75F; relation.confidence = 0.75; response.relationships.push_back(relation); auto encGraph = ProtoSerializer::encode_payload( makeMessageWith(Response{std::in_place_type, response}, 5)); REQUIRE(encGraph); auto decGraph = ProtoSerializer::decode_payload(encGraph.value()); REQUIRE(decGraph); REQUIRE(std::holds_alternative(decGraph.value().payload)); auto* gotGraph = std::get_if(&std::get(decGraph.value().payload)); REQUIRE(gotGraph != nullptr); CHECK(gotGraph->query == "processTask"); REQUIRE(gotGraph->entrySymbols.size() == 1); CHECK(gotGraph->entrySymbols[0].startLine == 10); REQUIRE(gotGraph->files.size() == 1); CHECK(gotGraph->files[0].content.find("processTask") != std::string::npos); REQUIRE(gotGraph->relationships.size() == 1); CHECK(gotGraph->relationships[0].relation == "calls"); CHECK(gotGraph->warnings.at(0) == "budget reached"); CHECK(gotGraph->snippetRenderMicros == 123); CHECK(gotGraph->snippetsRendered == 1); CHECK(gotGraph->truncated); } SECTION("GraphSymbolLookupResponse roundtrips snippet render metrics") { GraphSymbolLookupResponse response; response.symbol = "processTask"; response.snippetRenderMicros = 456; response.snippetsRendered = 2; auto encoded = ProtoSerializer::encode_payload( makeMessageWith(Response{std::in_place_type, response}, 6)); REQUIRE(encoded); auto decoded = ProtoSerializer::decode_payload(encoded.value()); REQUIRE(decoded); REQUIRE(std::holds_alternative(decoded.value().payload)); auto* got = std::get_if(&std::get(decoded.value().payload)); REQUIRE(got != nullptr); CHECK(got->snippetRenderMicros == 456); CHECK(got->snippetsRendered == 2); } SECTION("GraphTraceResponse roundtrips snippet render metrics") { GraphTraceResponse response; response.from = "processTask"; response.to = "helper"; response.snippetRenderMicros = 789; response.snippetsRendered = 3; auto encoded = ProtoSerializer::encode_payload( makeMessageWith(Response{std::in_place_type, response}, 7)); REQUIRE(encoded); auto decoded = ProtoSerializer::decode_payload(encoded.value()); REQUIRE(decoded); REQUIRE(std::holds_alternative(decoded.value().payload)); auto* got = std::get_if(&std::get(decoded.value().payload)); REQUIRE(got != nullptr); CHECK(got->snippetRenderMicros == 789); CHECK(got->snippetsRendered == 3); } SECTION("ErrorResponse sanitizes invalid UTF-8 message") { ErrorResponse err; err.code = ErrorCode::InvalidData; err.message = invalidUtf8("bad-search-query"); auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{err}, 20)); REQUIRE(encoded); auto decoded = ProtoSerializer::decode_payload(encoded.value()); REQUIRE(decoded); auto* decodedError = std::get_if(&std::get(decoded.value().payload)); REQUIRE(decodedError != nullptr); checkSanitizedField("ErrorResponse.message", decodedError->message, err.message); } SECTION("RepairEvent sanitizes invalid UTF-8 progress text") { RepairEvent ev; ev.phase = invalidUtf8("repairing"); ev.operation = invalidUtf8("fts5"); ev.message = invalidUtf8("rebuilding-index"); auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{ev}, 21)); REQUIRE(encoded); auto decoded = ProtoSerializer::decode_payload(encoded.value()); REQUIRE(decoded); auto* decodedEvent = std::get_if(&std::get(decoded.value().payload)); REQUIRE(decodedEvent != nullptr); checkSanitizedField("RepairEvent.phase", decodedEvent->phase, ev.phase); checkSanitizedField("RepairEvent.operation", decodedEvent->operation, ev.operation); checkSanitizedField("RepairEvent.message", decodedEvent->message, ev.message); } SECTION("RepairResponse sanitizes invalid UTF-8 errors and operation messages") { RepairResponse resp; resp.success = false; resp.errors = {invalidUtf8("fts5 failed"), invalidUtf8("mime failed")}; RepairOperationResult op; op.operation = invalidUtf8("embeddings"); op.message = invalidUtf8("bad utf8 in batch"); resp.operationResults.push_back(op); auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 22)); REQUIRE(encoded); auto decoded = ProtoSerializer::decode_payload(encoded.value()); REQUIRE(decoded); auto* decodedResponse = std::get_if(&std::get(decoded.value().payload)); REQUIRE(decodedResponse != nullptr); REQUIRE(decodedResponse->errors.size() == resp.errors.size()); checkSanitizedField("RepairResponse.errors[0]", decodedResponse->errors.at(0), resp.errors.at(0)); REQUIRE(decodedResponse->operationResults.size() == 1); checkSanitizedField("RepairResponse.operationResults[0].operation", decodedResponse->operationResults.at(0).operation, resp.operationResults.at(0).operation); checkSanitizedField("RepairResponse.operationResults[0].message", decodedResponse->operationResults.at(0).message, resp.operationResults.at(0).message); } SECTION("GetResponse sanitizes invalid UTF-8 descriptor fields without content") { GetResponse resp; resp.hash = invalidUtf8("sha256:"); resp.name = invalidUtf8("doc", ".md"); resp.path = invalidUtf8("/tmp/doc", ".md"); resp.hasContent = false; resp.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}}; auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 24)); REQUIRE(encoded); auto decoded = ProtoSerializer::decode_payload(encoded.value()); REQUIRE(decoded); auto* decodedResponse = std::get_if(&std::get(decoded.value().payload)); REQUIRE(decodedResponse != nullptr); checkSanitizedField("GetResponse.hash", decodedResponse->hash, resp.hash); checkSanitizedField("GetResponse.name", decodedResponse->name, resp.name); checkSanitizedField("GetResponse.path", decodedResponse->path, resp.path); REQUIRE_FALSE(decodedResponse->hasContent); } } TEST_CASE("GrepResponse: Serialization edge cases", "[daemon][protocol][grep]") { SECTION("Paths-only mode (file only, no line)") { GrepResponse in{}; GrepMatch m{}; std::string candidate = "/tmp/test/README.md"; m.file = candidate; m.line = ""; in.matches.push_back(m); Message msg{}; msg.payload = Response{std::in_place_type, in}; auto enc = ProtoSerializer::encode_payload(msg); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); const auto& resp = std::get(dec.value().payload); REQUIRE(std::holds_alternative(resp)); const GrepResponse& gr = std::get(resp); REQUIRE(gr.matches.size() == 1); REQUIRE(gr.matches[0].file == candidate); REQUIRE(gr.matches[0].line.empty()); } SECTION("Line match preserves content") { GrepResponse in{}; GrepMatch m{}; m.file = ""; m.line = "hello world"; in.matches.push_back(m); Message msg{}; msg.payload = Response{std::in_place_type, in}; auto enc = ProtoSerializer::encode_payload(msg); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); const GrepResponse& gr = std::get(std::get(dec.value().payload)); REQUIRE(gr.matches.size() == 1); REQUIRE(gr.matches[0].line == "hello world"); REQUIRE(gr.matches[0].file.empty()); } SECTION("Binary content preservation") { GrepResponse in{}; GrepMatch m{}; m.file = "binary.bin"; m.line = std::string("\xFF\x00\x1B", 3); m.contextBefore.push_back(std::string("\x01\x02", 2)); m.contextAfter.push_back(std::string("ok", 2)); in.matches.push_back(m); Message msg{}; msg.payload = Response{std::in_place_type, in}; auto enc = ProtoSerializer::encode_payload(msg); REQUIRE(enc); auto dec = ProtoSerializer::decode_payload(enc.value()); REQUIRE(dec); const GrepResponse& gr = std::get(std::get(dec.value().payload)); REQUIRE(gr.matches.size() == 1); REQUIRE(gr.matches[0].line == std::string("\xFF\x00\x1B", 3)); REQUIRE(gr.matches[0].contextBefore.size() == 1); REQUIRE(gr.matches[0].contextAfter.size() == 1); } } // ============================================================================= // Type System Tests (ResponseOf, MessageType) // ============================================================================= TEST_CASE("ResponseOf: Compile-time request→response mappings", "[daemon][protocol][types]") { // Search operations static_assert(std::is_same_v, SearchResponse>); static_assert(std::is_same_v, AddDocumentResponse>); static_assert(std::is_same_v, GetResponse>); static_assert(std::is_same_v, DeleteResponse>); // System operations static_assert(std::is_same_v, StatusResponse>); static_assert(std::is_same_v, PongResponse>); // Model operations static_assert(std::is_same_v, EmbeddingResponse>); static_assert(std::is_same_v, BatchEmbeddingResponse>); static_assert(std::is_same_v, ModelLoadResponse>); static_assert(std::is_same_v, SuccessResponse>); static_assert(std::is_same_v, ModelStatusResponse>); // Download and other operations static_assert(std::is_same_v, DownloadResponse>); static_assert(std::is_same_v, GrepResponse>); static_assert(std::is_same_v, UpdateDocumentResponse>); static_assert(std::is_same_v, GetStatsResponse>); SUCCEED(); } TEST_CASE("ResponseOf: Runtime usage with actual instances", "[daemon][protocol][types]") { SearchRequest searchReq{.query = "test query", .limit = 10, .searchType = "keyword"}; using SearchResType = ResponseOfT; static_assert(std::is_same_v); SearchResType searchRes; searchRes.totalCount = 5; REQUIRE(searchRes.totalCount == 5); GenerateEmbeddingRequest embedReq{"sample text", "model"}; using EmbedResType = ResponseOfT; static_assert(std::is_same_v); EmbedResType embedRes; embedRes.dimensions = 384; REQUIRE(embedRes.dimensions == 384); } TEST_CASE("MessageType: Request and response enum mappings", "[daemon][protocol][types]") { SECTION("Request type mappings") { REQUIRE(MessageType::CatRequest == getMessageType(Request{CatRequest{}})); REQUIRE(MessageType::ListSessionsRequest == getMessageType(Request{ListSessionsRequest{}})); REQUIRE(MessageType::DownloadStatusRequest == getMessageType(Request{DownloadStatusRequest{.jobId = "download-1"}})); REQUIRE(MessageType::CancelDownloadJobRequest == getMessageType(Request{CancelDownloadJobRequest{.jobId = "download-2"}})); REQUIRE(MessageType::ListDownloadJobsRequest == getMessageType(Request{ListDownloadJobsRequest{.limit = 5}})); REQUIRE(MessageType::UseSessionRequest == getMessageType(Request{UseSessionRequest{.session_name = "s"}})); REQUIRE(MessageType::AddPathSelectorRequest == getMessageType(Request{AddPathSelectorRequest{.session_name = "s", .path = "/p"}})); REQUIRE( MessageType::RemovePathSelectorRequest == getMessageType(Request{RemovePathSelectorRequest{.session_name = "s", .path = "/p"}})); REQUIRE(MessageType::GraphExploreRequest == getMessageType(Request{GraphExploreRequest{.query = "processTask"}})); } SECTION("Response type mappings") { REQUIRE(MessageType::CatResponse == getMessageType(Response{CatResponse{}})); REQUIRE(MessageType::ListResponse == getMessageType(Response{ListResponse{}})); REQUIRE(MessageType::StatusResponse == getMessageType(Response{StatusResponse{}})); REQUIRE(MessageType::ListDownloadJobsResponse == getMessageType(Response{ListDownloadJobsResponse{}})); REQUIRE(MessageType::GraphExploreResponse == getMessageType(Response{GraphExploreResponse{.query = "processTask"}})); } SECTION("Request name extraction") { REQUIRE(std::string("Cat") == getRequestName(Request{CatRequest{}})); REQUIRE(std::string("ListSessions") == getRequestName(Request{ListSessionsRequest{}})); REQUIRE(std::string("DownloadStatus") == getRequestName(Request{DownloadStatusRequest{.jobId = "download-1"}})); REQUIRE(std::string("GraphExplore") == getRequestName(Request{GraphExploreRequest{.query = "processTask"}})); REQUIRE(std::string("CancelDownloadJob") == getRequestName(Request{CancelDownloadJobRequest{.jobId = "download-2"}})); REQUIRE(std::string("ListDownloadJobs") == getRequestName(Request{ListDownloadJobsRequest{.limit = 5}})); REQUIRE(std::string("UseSession") == getRequestName(Request{UseSessionRequest{.session_name = "x"}})); REQUIRE(std::string("AddPathSelector") == getRequestName(Request{AddPathSelectorRequest{}})); REQUIRE(std::string("RemovePathSelector") == getRequestName(Request{RemovePathSelectorRequest{}})); } }