Skip to content

Instantly share code, notes, and snippets.

@kojiromike
Created July 10, 2026 04:23
Show Gist options
  • Select an option

  • Save kojiromike/8c6f5b9b47c053e5b2a04af2f6676eda to your computer and use it in GitHub Desktop.

Select an option

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)
"""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

Copy link
Copy Markdown
Author
┌   goose-configure
│
◇  What would you like to configure?
│  Custom Providers
│
◇  What would you like to do?
│  Add A Custom Provider
│
◇  What type of API is this?
│  OpenAI Compatible
│
◇  What should we call this provider?
│  testbug
│
◇  Provider API URL:
│  http://127.0.0.1:8899
│
◇  Does this provider require authentication?
│  No
│
◇  Available models (separate with commas):
│  empty-mock
│
◇  Does this provider support streaming responses?
│  Yes
│
◇  API base path (optional, press Enter to skip):
│  e.g., v1/chat/completions or project_id/v1
│
◇  Does this provider require custom headers?
│  No
│
◇  Which model should be the default?
│  empty-mock
│
└  Custom provider added: testbug
└  Configuration saved successfully

@kojiromike

Copy link
Copy Markdown
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.

@kojiromike

kojiromike commented Jul 10, 2026

Copy link
Copy Markdown
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) ~ $

@kojiromike

Copy link
Copy Markdown
Author
$ goose version
1.41.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment