Skip to content

fix(api): let channels.voice update() send an explicit null agent_id - #67

Open
sudiptpa wants to merge 1 commit into
sentdm:mainfrom
sudiptpa:fix-voice-agent-unbind-null
Open

sudiptpa wants to merge 1 commit into
sentdm:mainfrom
sudiptpa:fix-voice-agent-unbind-null

Conversation

@sudiptpa

@sudiptpa sudiptpa commented Oct 31, 2026 •

Copy link
Copy Markdown

What's wrong

PATCH /v3/channels/voice/{number}'s own docblock gives agent_id three distinct states:

Body Effect
"agent_id": "agent_…" binds that agent to the number
"agent_id": null unbinds the agent, so callback_url answers calls again
key omitted left exactly as it is

VoiceService::update() builds its request body through Util::removeNulls(), which strips
every null field before the request goes out. So update(agentID: null) and update() (not
touching agentID at all) produce the identical wire request. There is no way to unbind an
agent through this method today, despite the method's own docs promising one.

Before (current behavior), calling with an explicit null:

update('+12125550100', agentID: null)
→ PATCH body: {}
→ agent_id untouched, no way to unbind

After this fix:

update('+12125550100', agentID: null)
→ PATCH body: {"agent_id": null}
→ agent unbound, callback_url takes calls again

Confirmed live (against the free voice-agent fields, which have the identical null-is-meaningful
contract, so no phone number was needed): the typed update() method 400s
("Name at least one field to change") when the only field passed is an explicit null, while
raw->update() with a plain PHP array already accepts and applies an explicit null correctly.
The bug is isolated to the typed convenience wrapper, not the underlying request machinery.

This isn't PHP-specific, it's already solved in the Python SDK

Checked the other Sent SDKs before writing this. The Python SDK has the identical
named-parameter architecture (and the identical problem, since Python's None collapses
"omitted" and "explicit None" the same way PHP's null does) and already solves it with a
dedicated sentinel: agent_id: Optional[str] | Omit = omit (sent-dm-python,
src/sent_dm/_types.py). This PR gives the PHP SDK the equivalent: a new SentDm\Core\Omit
marker, named to match.

The TypeScript, Ruby, Go, Java, and C# SDKs don't share this failure mode, each for its own
reason:

  • TypeScript passes a plain params object straight to JSON.stringify, which already
    distinguishes undefined from null.
  • Ruby's update(number, params = {}) takes a hash rather than individual named
    parameters, so an explicit nil key and an absent key aren't the same thing going in.
  • Go's param.Opt[T] has three states (omitted/null/included), with param.Null[T]()
    as the explicit-null constructor.
  • Java's JsonField.ofNullable() returns a JsonNull instance, distinct from the field's
    default JsonMissing.
  • C#'s AgentID is an init-only property on a record; the setter only runs if the caller
    explicitly assigns it, so "never touched" and "assigned null" are different code paths before
    serialization even happens.

What changed

agentID now defaults to SentDm\Core\Omit instead of null:

  • parameter omitted (default Omit marker) → key left out of the body, binding unchanged
  • parameter passed as null → "agent_id": null sent explicitly, unbinds the agent
  • parameter passed as a string → sent as before

Every other field on this method is untouched and still goes through removeNulls().

Is this a breaking change?

Flagging explicitly per this repo's compatibility rule rather than deciding it myself: the
signature's type changes (?string → string|null|Omit) and, more importantly, the runtime
behavior
for an existing caller passing agentID: null changes. Today it's a silent no-op,
after this it actually unbinds. No caller could have been relying on the old behavior to do
anything useful (it did nothing), but it is still a different result for the same call, so
this may warrant a minor/major version call rather than a patch. Leaving that to whoever
triages it.

How I verified it

Added VoiceTest::testUpdateSendsExplicitNullToUnbindAgent(), which inspects the actual
outgoing request body against a mock transporter and confirms agent_id is present and null.
Confirmed it fails against the code before this change (missing key) and passes after. Added
testUpdateOmitsAgentIdWhenNotPassed() to confirm the common case still omits the key.
./scripts/lint and ./scripts/test both pass.

Note for review

This hand-edits existing generated lines in VoiceService.php (the $params array and the
method signature) rather than only adding to them, which I know this repo's CLAUDE.md flags
as the riskier kind of patch. Given the fix brings generated behavior in line with what the
same generated docblock already documents, and matches a pattern already used elsewhere in
this SDK family, I think it's a reasonable patch. But if the preferred fix is at the generator
config level (teaching it to emit the same Omit-style sentinel PHP-side that Python already
gets), that's outside what I can reach from here.

PATCH /v3/channels/voice/{number}'s own docs give agent_id three
states: a value binds an agent, null unbinds it, and omitting the key
leaves the binding alone. VoiceService::update() builds its request
body through Util::removeNulls(), which strips every null field before
the request goes out - so calling update(agentID: null) produces the
same wire request as not passing agentID at all. There's no way to
unbind an agent through this method today.

Give agentID its own default, a new Omit marker (same role as the
Omit/omit sentinel the Python SDK already uses for this exact problem),
instead of null. A real null now survives into the request body;
leaving the parameter out still omits the key. Every other field on
this method keeps going through removeNulls() unchanged.

Confirmed live against the free voice-agent fields that have the same
null-is-meaningful contract (no phone number needed to test): the
typed update() method 400s today when passed an explicit null, while
raw->update() with a plain array already accepts and applies it. This
patch brings the typed method in line with what the API and its own
docblock already promise.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-31T07:01:09.701819Z
We couldn’t complete this review. Comment @codex review to try again.
d932798 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant