Created
July 10, 2026 04:23
-
-
Save kojiromike/8c6f5b9b47c053e5b2a04af2f6676eda to your computer and use it in GitHub Desktop.
goose empty-turn silent-stop repro: mock OpenAI-compatible endpoint that returns a valid-but-empty SSE turn (see aaif-goose/goose#10360)
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
| """Mock OpenAI-compatible endpoint that always returns a valid but EMPTY turn. | |
| Reproduces the goose silent-stop bug: the server sends a 200 SSE stream that | |
| carries an assistant *role* delta, then no content/tool deltas, then | |
| finish_reason=stop + usage + [DONE]. goose parses this as a successful | |
| response with zero content -> an empty turn. Unpatched goose ends the | |
| conversation with no assistant message; patched goose retries a bounded | |
| number of times and then surfaces a visible "empty response" message. | |
| Run it: | |
| python3 mock_empty_server.py 8899 | |
| Point goose at it (OpenAI-compatible custom provider): | |
| Provider API URL: http://127.0.0.1:8899/v1 | |
| Model: empty-mock | |
| Streaming: yes | |
| Auth: none | |
| Gotchas: | |
| - Use http://, NOT https:// -- this server speaks plain HTTP. | |
| - Prefer 127.0.0.1 over "localhost". "localhost" can resolve to IPv6 (::1) | |
| first; this server binds dual-stack when possible, but 127.0.0.1 is the | |
| surest bet. | |
| Then send any message. The reply body is ignored -- every request gets the | |
| same empty stream. | |
| """ | |
| import json | |
| import socket | |
| import sys | |
| import time | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| MODEL = "empty-mock" | |
| def sse(obj): | |
| return f"data: {json.dumps(obj)}\n\n".encode() | |
| class Handler(BaseHTTPRequestHandler): | |
| def log_message(self, fmt, *args): | |
| sys.stderr.write("[mock] " + (fmt % args) + "\n") | |
| def do_GET(self): | |
| # Serve /v1/models so provider setup/health checks pass. | |
| body = json.dumps( | |
| {"object": "list", "data": [{"id": MODEL, "object": "model"}]} | |
| ).encode() | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def do_POST(self): | |
| length = int(self.headers.get("Content-Length", 0)) | |
| _ = self.rfile.read(length) # drain request; we ignore its content | |
| created = int(time.time()) | |
| cid = "chatcmpl-emptymock" | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/event-stream") | |
| self.send_header("Cache-Control", "no-cache") | |
| self.end_headers() | |
| def chunk(delta=None, finish=None, choices=None, usage=None): | |
| base = { | |
| "id": cid, | |
| "object": "chat.completion.chunk", | |
| "created": created, | |
| "model": MODEL, | |
| } | |
| if choices is None: | |
| choices = [{"index": 0, "delta": delta or {}, "finish_reason": finish}] | |
| base["choices"] = choices | |
| base["usage"] = usage | |
| return base | |
| # Role delta with empty content, then an immediate stop with NO content | |
| # deltas in between -> a valid, empty assistant turn. | |
| self.wfile.write(sse(chunk(delta={"role": "assistant", "content": ""}))) | |
| self.wfile.write(sse(chunk(delta={}, finish="stop"))) | |
| self.wfile.write( | |
| sse( | |
| chunk( | |
| choices=[], | |
| usage={ | |
| "prompt_tokens": 10, | |
| "completion_tokens": 0, | |
| "total_tokens": 10, | |
| }, | |
| ) | |
| ) | |
| ) | |
| self.wfile.write(b"data: [DONE]\n\n") | |
| self.wfile.flush() | |
| class DualStackServer(ThreadingHTTPServer): | |
| """Bind :: with IPV6_V6ONLY off so both ::1 and 127.0.0.1 reach the mock.""" | |
| address_family = socket.AF_INET6 | |
| def server_bind(self): | |
| self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) | |
| super().server_bind() | |
| def main(): | |
| port = int(sys.argv[1]) if len(sys.argv) > 1 else 8899 | |
| try: | |
| server = DualStackServer(("::", port), Handler) | |
| bound = f"http://127.0.0.1:{port} and http://[::1]:{port}" | |
| except OSError: | |
| # Fall back to IPv4-only if dual-stack isn't available. | |
| server = ThreadingHTTPServer(("127.0.0.1", port), Handler) | |
| bound = f"http://127.0.0.1:{port}" | |
| sys.stderr.write(f"[mock] empty-turn endpoint on {bound}\n") | |
| server.serve_forever() | |
| if __name__ == "__main__": | |
| main() |
kojiromike
commented
Jul 10, 2026
Author
Author
control, testing with a model that (hopefully) doesn't have this issue.
$ goose run --provider lmstudio --model qwen/qwen3.6-35b-a3b --no-session -t "tell me something I don't know"
__( O)> ● new session · lmstudio qwen/qwen3.6-35b-a3b
\____) 20260710_25 · /Users/michael
L L goose is ready
Here's something neat: **octopuses have three hearts, and two of them stop beating when they swim.**
Two of the hearts pump blood to the gills, while the third pumps it to the rest of the body. The main heart actually stops during swimming, which is why octopuses prefer to crawl along the seafloor rather than swim — swimming is exhausting for them. They even have a behavioral aversion to it; most octopuses will choose to crawl over swimming even when both paths lead to food.
Oh, and their blood is **blue** because it uses copper-based hemocyanin instead of iron-based hemoglobin to transport oxygen.
Author
actual test case:
$ goose run --provider custom_testbug --model empty-mock --no-session -t "tell me something I don't know"
__( O)> ● new session · custom_testbug empty-mock
\____) 20260710_27 · /Users/michael
L L goose is ready
00:31:17 !(22639) j(0) ~ $
Author
$ goose version
1.41.0
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment