diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 98bb9543..842373c1 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -106,9 +106,15 @@ DEFAULT_CONTEXT_LENGTHS = { "claude-sonnet-4.6": 1000000, # Catch-all for older Claude models (must sort after specific entries) "claude": 200000, - # OpenAI + # OpenAI — GPT-5 family (most have 400k; specific overrides first) + # Source: https://developers.openai.com/api/docs/models + "gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4) + "gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4) + "gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context) + "gpt-5.3-codex-spark": 128000, # Spark variant has reduced 128k context + "gpt-5.1-chat": 128000, # Chat variant has 128k context + "gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k) "gpt-4.1": 1047576, - "gpt-5": 128000, "gpt-4": 128000, # Google "gemini": 1048576, diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index e743df8d..240084e9 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -280,6 +280,14 @@ class GatewayStreamConsumer: await self._send_or_edit(self._accumulated) except Exception: pass + # If we delivered any content before being cancelled, mark the + # final response as sent so the gateway's already_sent check + # doesn't trigger a duplicate message. The 5-second + # stream_task timeout (gateway/run.py) can cancel us while + # waiting on a slow Telegram API call — without this flag the + # gateway falls through to the normal send path. + if self._already_sent: + self._final_response_sent = True except Exception as e: logger.error("Stream consumer error: %s", e) diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index d6630672..d8a1be2d 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -599,3 +599,84 @@ class TestInterimCommentaryMessages: assert sent_texts == ["Hello ▉", "world"] assert consumer.already_sent is True assert consumer.final_response_sent is True + + +class TestCancelledConsumerSetsFlags: + """Cancellation must set final_response_sent when already_sent is True. + + The 5-second stream_task timeout in gateway/run.py can cancel the + consumer while it's still processing. If final_response_sent stays + False, the gateway falls through to the normal send path and the + user sees a duplicate message. + """ + + @pytest.mark.asyncio + async def test_cancelled_with_already_sent_marks_final_response_sent(self): + """Cancelling after content was sent should set final_response_sent.""" + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_1") + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True) + ) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5), + ) + + # Stream some text — the consumer sends it and sets already_sent + consumer.on_delta("Hello world") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.08) + + assert consumer.already_sent is True + + # Cancel the task (simulates the 5-second timeout in gateway) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # The fix: final_response_sent should be True even though _DONE + # was never processed, preventing a duplicate message. + assert consumer.final_response_sent is True + + @pytest.mark.asyncio + async def test_cancelled_without_any_sends_does_not_mark_final(self): + """Cancelling before anything was sent should NOT set final_response_sent.""" + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=False, message_id=None) + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True) + ) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5), + ) + + # Send fails — already_sent stays False + consumer.on_delta("x") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.08) + + assert consumer.already_sent is False + + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # Without a successful send, final_response_sent should stay False + # so the normal gateway send path can deliver the response. + assert consumer.final_response_sent is False