Repository navigation
fix(sdk): read the pod before replying; name the server's reason on 4xx (TASK-017) #2800
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| """ | ||
| Tests for the stdlib Python SDK's reply path (TASK-017). | ||
|
|
||
| The send guard refuses a reply to a pod the agent has not read since its newest | ||
| message: POST /messages answers 409 unseen_messages. The stock loop used to post | ||
| without reading, so every reply was refused and the SDK logged only the status. | ||
|
|
||
| These tests run the real SDK against a local server that enforces the same | ||
| contract as the backend guard: a context read marks the pod read, a post after | ||
| a new message is refused with the hold body (`code`, `message`, `unseenCount`). | ||
|
|
||
| Run: python3 -I -m unittest discover -s examples/sdk/python -p 'test_*.py' | ||
| """ | ||
|
|
||
| import json | ||
| import threading | ||
| import unittest | ||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||
|
|
||
| import commonly | ||
| from commonly import Commonly, CommonlyError | ||
|
|
||
|
|
||
| class FakeGuard: | ||
| """Mirrors agentSendFreshness: unread until a context read; a new message | ||
| makes the pod unread again.""" | ||
|
|
||
| def __init__(self): | ||
| self.unread = True | ||
| self.posts = [] | ||
| self.reads = 0 | ||
| self.attempts = 0 | ||
| self.refuse_all = False | ||
| self.land_message_before_next_post = False | ||
| self.events = [] | ||
| self.log = [] | ||
|
|
||
| def hold_body(self): | ||
| return { | ||
| "code": "unseen_messages", | ||
| "unseenCount": 1, | ||
| "messages": [], | ||
| "message": "1 new message in this pod. Read the conversation and retry, " | ||
| "or call again with continueAnyway:true to continue.", | ||
| } | ||
|
|
||
|
|
||
| def make_handler(guard): | ||
| class Handler(BaseHTTPRequestHandler): | ||
| def log_message(self, *args): | ||
| pass | ||
|
|
||
| def _send(self, status, body): | ||
| raw = json.dumps(body).encode("utf-8") | ||
| self.send_response(status) | ||
| self.send_header("Content-Type", "application/json") | ||
| self.send_header("Content-Length", str(len(raw))) | ||
| self.end_headers() | ||
| self.wfile.write(raw) | ||
|
|
||
| def do_GET(self): | ||
| if self.path.startswith("/api/agents/runtime/pods/") and self.path.endswith("/context"): | ||
| guard.unread = False | ||
| guard.reads += 1 | ||
| guard.log.append("read") | ||
| return self._send(200, {"recentMessages": []}) | ||
| if self.path.startswith("/api/agents/runtime/events"): | ||
| if not guard.events: | ||
| return self._send(401, {"message": "token revoked"}) | ||
| events, guard.events = guard.events, [] | ||
| return self._send(200, {"events": events}) | ||
| return self._send(404, {"message": "not found"}) | ||
|
|
||
| def do_POST(self): | ||
| length = int(self.headers.get("Content-Length") or 0) | ||
| body = json.loads(self.rfile.read(length) or b"{}") | ||
| if self.path.endswith("/ack"): | ||
| guard.log.append(f"ack:{self.path.split('/')[-2]}") | ||
| return self._send(200, {"success": True}) | ||
| guard.attempts += 1 | ||
| if guard.land_message_before_next_post: | ||
| guard.land_message_before_next_post = False | ||
| guard.unread = True | ||
| if guard.refuse_all or guard.unread: | ||
| guard.log.append("refused") | ||
| return self._send(409, guard.hold_body()) | ||
| guard.posts.append(body.get("content")) | ||
| guard.log.append(f"post:{body.get('content')}") | ||
| return self._send(200, {"success": True, "message": {"content": body.get("content")}}) | ||
|
|
||
| return Handler | ||
|
|
||
|
|
||
| class ReplyPathTest(unittest.TestCase): | ||
| def setUp(self): | ||
| self.guard = FakeGuard() | ||
| self.server = HTTPServer(("127.0.0.1", 0), make_handler(self.guard)) | ||
| threading.Thread(target=self.server.serve_forever, daemon=True).start() | ||
| base = f"http://127.0.0.1:{self.server.server_address[1]}" | ||
| self.bot = Commonly(base_url=base, runtime_token="cm_agent_test", timeout_s=5) | ||
|
|
||
| def tearDown(self): | ||
| self.server.shutdown() | ||
| self.server.server_close() | ||
|
|
||
| def test_raw_post_without_a_read_is_refused_and_the_reason_is_named(self): | ||
| with self.assertRaises(CommonlyError) as ctx: | ||
| self.bot.post_message("pod_1", "hello") | ||
| self.assertEqual(ctx.exception.status, 409) | ||
| self.assertIn("409", str(ctx.exception)) | ||
| self.assertIn("Read the conversation", str(ctx.exception)) | ||
|
|
||
| def test_reply_path_reads_the_pod_first_and_posts(self): | ||
| self.bot._post_reply("pod_1", "hello") | ||
| self.assertEqual(self.guard.reads, 1) | ||
| self.assertEqual(self.guard.posts, ["hello"]) | ||
|
|
||
| def test_a_message_landing_between_read_and_post_is_retried_once(self): | ||
| self.guard.land_message_before_next_post = True | ||
| self.bot._post_reply("pod_1", "hello") | ||
| self.assertEqual(self.guard.reads, 2) | ||
| self.assertEqual(self.guard.posts, ["hello"]) | ||
|
|
||
| def test_a_second_refusal_propagates_with_its_reason(self): | ||
| self.guard.refuse_all = True | ||
| with self.assertRaises(CommonlyError) as ctx: | ||
| self.bot._post_reply("pod_1", "hello") | ||
| self.assertEqual(ctx.exception.status, 409) | ||
| self.assertIn("1 new message", str(ctx.exception)) | ||
| self.assertEqual(self.guard.reads, 2) | ||
| self.assertEqual(self.guard.attempts, 2) | ||
| self.assertEqual(self.guard.posts, []) | ||
|
|
||
| def test_run_reads_then_posts_then_acks_and_never_posts_directly(self): | ||
| self.guard.events = [{"_id": "evt_1", "podId": "pod_1", "payload": {}}] | ||
| self.bot.run(lambda evt: "hello", interval_s=0.01, max_auth_errors=1) | ||
| self.assertEqual(self.guard.log, ["read", "post:hello", "ack:evt_1"]) | ||
|
|
||
|
|
||
| class ErrorDescriptionTest(unittest.TestCase): | ||
| def test_dict_payload_message_is_appended(self): | ||
| msg = commonly._describe_http_error(409, "POST", "/x", {"message": "held"}) | ||
| self.assertEqual(msg, "HTTP 409 POST /x: held") | ||
|
|
||
| def test_missing_payload_keeps_the_status_line(self): | ||
| self.assertEqual(commonly._describe_http_error(500, "GET", "/x", None), "HTTP 500 GET /x") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔎 Supported by static analysis
Script executed:
Repository: Team-Commonly/commonly
Length of output: 15728
Add a
run()test for the reply path.The reply-path tests call
_post_reply()orpost_message()directly. None of them callsrun(). Reverting the loop fromself._post_reply(pod_id, reply)toself.post_message(pod_id, reply)would therefore leave all six tests passing. That revert restores the unread-message refusal in the stock loop.Per the repository's review checklist, a test that still passes with the fix reverted does not cover the change. Add a bounded test that drives
run()through the fake server:GET /api/agents/runtime/eventswith one event that has_id,podId, andpayload.content. Accept thePOST /api/agents/runtime/events/<id>/ackrequest. Without this handler,poll_events()receives a 404, andrun()keeps retrying.run(..., interval_s=0.01, max_auth_errors=1)so the loop exits.The test must fail when
run()callspost_messagedirectly.