Skip to content

fix(items): coerce None refusal to empty string in extract_last_content#3747

Closed
comp wants to merge 1 commit into
openai:mainfrom
comp:fix/extract-last-content-null-refusal
Closed

fix(items): coerce None refusal to empty string in extract_last_content#3747
comp wants to merge 1 commit into
openai:mainfrom
comp:fix/extract-last-content-null-refusal

Conversation

@comp

@comp comp commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

ItemHelpers.extract_last_content is annotated -> str, and its text-content branch already coerces a None text to "" — with a comment noting that provider gateways (e.g. LiteLLM) and model_construct streaming paths can surface None even though the schema types the field as str. The refusal branch had the identical exposure but returned last_content.refusal bare, so for a refusal message whose refusal is None, extract_last_content returned None, violating its own -> str contract.

This coerces the refusal branch the same way, matching both the text branch above it and the sibling extract_refusal (which already uses refusal or "").

Test plan

Added test_extract_last_content_of_refusal_message_with_null_refusal, which reproduces the None-refusal case via model_construct (the documented provider-gateway path). Change-relevant checks:

  • uv run ruff format --check src/agents/items.py tests/test_items_helpers.py — pass
  • uv run ruff check src/agents/items.py tests/test_items_helpers.py — pass
  • uv run mypy src/agents/items.pySuccess: no issues found
  • uv run pytest tests/test_items_helpers.py — 35 passed

I also ran the full .agents/skills/code-change-verification/scripts/run.sh: make format and make lint pass. make typecheck fails only on ~22 pre-existing errors in files unrelated to this change (e.g. tests/sandbox/test_unix_local.py references signal.SIGQUIT, unavailable on Windows; plus tool-version/stub issues in extensions/memory/redis_session.py, sandbox/util/tar_utils.py, models/openai_responses.py, etc.). I verified there are no typecheck errors in the changed files (items.py, test_items_helpers.py). This is a local Windows/tool-version environment issue, not a regression from this PR, so I've left the "all steps pass" box unchecked.

Issue number

N/A

Checks

  • I've added new tests, if relevant (added a null-refusal regression test)
  • I've run .agents/skills/code-change-verification/scripts/run.sh (format + lint pass; typecheck blocked only by pre-existing, unrelated Windows errors — none in the changed files; see test plan)
  • I've confirmed all verification steps pass (change-relevant checks pass; full stack blocked by unrelated pre-existing typecheck errors in this local environment)
  • If using Codex, I've run /review before submitting this PR — N/A

@seratch

seratch commented Jul 7, 2026

Copy link
Copy Markdown
Member

Can you share the repro steps of this issue? Otherwise, we'll close it for the same reason with #3427 (comment)

@comp

comp commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @seratch — you're right, and I couldn't find a real path either.

Tracing every ResponseOutputRefusal construction in the SDK:

  • The streaming handler (chatcmpl_stream_handler.py) initializes it as refusal="" and only appends truthy delta.refusal, so it's never None.
  • Converter.message_to_output_items only builds one under if message.refusal:, so a None/empty refusal coming from LiteLLM/any-llm (InternalChatCompletionMessage(refusal=...)) never produces a refusal part.

So refusal=None reaching extract_last_content is only reachable via a synthetic model_construct, not a real provider path — same conclusion as #3427. Closing this; sorry for the duplicate. If a genuine provider case ever surfaces I'll reopen with an actual repro.

@comp comp closed this Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants