Skip to content

fix: preserve chat runner abort reasons - #2607

Open
sylvesterkaczmarek wants to merge 3 commits into
openai:mainfrom
sylvesterkaczmarek:fix/chat-runner-abort-reason
Open

fix: preserve chat runner abort reasons#2607
sylvesterkaczmarek wants to merge 3 commits into
openai:mainfrom
sylvesterkaczmarek:fix/chat-runner-abort-reason

Conversation

@sylvesterkaczmarek

@sylvesterkaczmarek sylvesterkaczmarek commented Sep 4, 2026

Copy link
Copy Markdown
Contributor
  • I understand that this repository is auto-generated and my pull request may not be merged

Changes being requested

Preserve caller-provided abort reasons across chat completion convenience runners.

The direct request layer already attaches signal.reason to APIUserAbortError.cause, but chat completion runners could lose or misclassify that reason at internal cancellation boundaries. This change:

  • forwards signal.reason when EventStream bridges an external signal into its runner-owned controller;
  • attaches the runner controller's reason to abort errors raised at tool-callback cancellation checkpoints;
  • normalizes a sequential tool callback that throws the aborted runner signal's exact reason into APIUserAbortError, preserving that reason as cause;
  • covers both later aborts and already-aborted source signals;
  • verifies parity with the existing streaming runner behavior.

Fixes #2606.

Review follow-up

The current head addresses the sequential-callback classification issue raised in review. Both parsed and unparsed tool callback paths now catch a rejection equal to runner.controller.signal.reason after cancellation and translate it through the same userAbortError() helper. The regression exercises parallel_tool_calls: false, cancels with a custom Error, has the active callback call throwIfAborted(), and verifies runner.done() rejects with APIUserAbortError, preserves the custom cause, emits the abort path, and leaves runner.aborted === true.

Fresh code/security review is running on commit 69e2710cce37947e2f51476d8b57ab3f05c836fb.

Validation

Previous-head validation:

  • pnpm test tests/lib/ChatCompletionRunFunctions.test.ts tests/lib/runner-abort-listeners.test.ts — 48 passed
  • pnpm exec tsc --noEmit — passed
  • repository-wide Ultracite formatting/lint check — passed, 0 warnings/errors
  • pnpm build — passed
  • git diff --check — passed

Pushed-head CI/review is required for the new regression.

Additional context & links

The change does not alter cancellation timing or add a new API; it preserves and consistently classifies the same abort-cause information already exposed by direct requests.

@sylvesterkaczmarek
sylvesterkaczmarek requested a review from a team as a code owner September 4, 2026 11:13
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 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 Completed 2026-09-06T00:31:53.527142Z d98a4b1 New commits
🔒 Security Review Completed 2026-09-06T00:31:42.947796Z d98a4b1 New commits
ℹ️ 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.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reason propagation is now consistent across both cancellation boundaries: external signals transfer their original reason into the runner controller, and tool checkpoints wrap that same reason in APIUserAbortError.cause. Covering already-aborted signals is important because that path bypasses listener delivery entirely.

gh-actions-shared Bot pushed a commit to xf-qubit/openai-node that referenced this pull request Sep 4, 2026
…#2591)

## Summary

Cancelling a tool runner during its final `afterCompletion` callback
could be reported as success: `done()` resolved and successful final
events were emitted even though the runner controller was aborted.

This PR observes callback cancellation at the existing post-callback
boundary and again at successful final settlement:

- Continue awaiting the configured callback at all three exit sites: a
final response, a forced function call, and the completion limit.
- Retain the early cancellation check before another request, and use
one final-settlement check to cover later fulfillment reactions on a
callback's returned promise.
- Preserve the runner signal's reason by identity on
`APIUserAbortError.cause`.
- Preserve completed tool results and the original error when the
callback itself rejects.

The final-settlement check is gated by a private flag set only when
`afterCompletion` is actually invoked. Omitted/null callbacks,
configured-but-never-invoked callbacks, and unrelated
`ChatCompletionStream` behavior remain unchanged.

## Scope

Only handwritten `src/lib/AbstractChatCompletionRunner.ts`, its existing
cancellation test, and `docs/helpers.md` change. No generated files,
dependencies, exported types, or general `EventStream` machinery change.

[openai#2607](openai#2607) owns the
broader external-signal reason forwarding and five preexisting tool
checkpoints. Those changes are not duplicated here. This PR preserves
the reason already present on the runner's signal at the new callback
boundaries and composes with that separate forwarding fix.

## Verification

- Fail-before regressions cover callback/external cancellation, cause
identity, and the late-promise-reaction race across JSON/SSE and every
exit route. The final settlement addition made eight previously failing
late-reaction cases pass while preserving 34 controls.
- **118 related tests pass** on Node 22.22.2 and 24.19.0, including
callback-error-precedence and no-callback controls.
- **474 final built CJS/ESM checks pass** across exact Node 22.0.0,
24.19.0, and 26.7.0: a 324-case runner matrix plus 150 independent
compatibility checks. These verify cause identity/descriptors,
arbitrary/falsy reasons, late reactions, one request, retained history,
lifecycle counts, omitted/null callbacks, never-invoked callbacks, and
bare streams.
- `tsc --noEmit`, canonical `./scripts/build`, pinned Oxfmt 0.62.0,
available cached Oxlint 1.76.0, and `git diff --check` pass. Emitted
CJS/ESM runner declarations remain byte-identical.
- Full handwritten unit suite on the final revision: **7,450 passed, 1
failed**. The sole `oxlint-config` package-command
dependency-verification failure reproduces identically on the clean
`dde19c5c` base.
- Repeated independent adversarial review covered security,
compatibility, callback/error precedence, final-settlement ordering, and
regression gaps. No blocking implementation findings remain within this
callback scope.

## Review follow-ups

-
[`718da1c6`](openai@718da1c)
adds cause preservation at the new checkpoint.
-
[`4ae4ac34`](openai@4ae4ac3)
closes the late-promise-reaction gap with the callback-scoped settlement
guard.

## Local tooling limitation

Fresh frozen installation remains blocked by missing publication-time
metadata for `qs@6.16.0` in the configured registry. Local checks use
available cached Vitest 4.1.10 and Oxlint 1.76.0 rather than pinned
4.1.11 and 1.79.0; TypeScript 6.0.3 and Oxfmt 0.62.0 match their pins.
Fresh CI validates the new head with the pinned toolchain. No lockfile,
registry configuration, or supply-chain safeguards were changed.

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for a cancellation classification regression in sequential tool callbacks. Forwarding the caller's reason also requires normalizing callback rejections that match that reason into APIUserAbortError, preserving cause.

Comment thread src/lib/EventStream.ts Outdated
}

const listener = () => this.controller.abort();
const listener = () => this.controller.abort(signal.reason);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve cancellation classification for tool callbacks

With parallel_tool_calls: false or a named tool_choice, a tool callback that calls runner.controller.signal.throwIfAborted() after the caller cancels with abort(new Error('stop')) now throws that custom error. The callback rejection propagates through await runToolCall(...), bypassing the new abort checkpoint, and EventStream.#handleError classifies it as an ordinary error because its name is not AbortError. Consequently, runner.done() rejects with OpenAIError, the runner emits error instead of abort, and runner.aborted remains false. Before this change, the runner controller's default AbortError was correctly converted into APIUserAbortError. Please normalize rejections matching the aborted runner signal's reason into APIUserAbortError, preserving cause.

@sylvesterkaczmarek
sylvesterkaczmarek force-pushed the fix/chat-runner-abort-reason branch from be65087 to 69e2710 Compare September 5, 2026 23:48
@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Addressed the requested cancellation-classification case. Sequential tool callbacks now normalize a rejection that is exactly the aborted runner signal reason into APIUserAbortError while preserving that reason as cause. Added a focused parallel_tool_calls:false regression where the callback observes the caller abort via throwIfAborted(); it now emits the abort path and the focused test passes.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69e2710cce

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/EventStream.ts Outdated
}

const listener = () => this.controller.abort();
const listener = () => this.controller.abort(signal.reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate the reason into mid-stream abort errors

When the caller aborts after a streaming response has begun, this listener stores the reason on the runner controller, but ChatCompletionStream._createChatCompletion() subsequently ends iteration and throws a fresh cause-less APIUserAbortError at line 1757; ResponseStream and AssistantStream have the same pattern. Consequently, runTools({ stream: true }).done() still loses the caller's reason during in-body cancellation, so construct those terminal errors from the controller reason and cover the actual streaming boundary.

AGENTS.md reference: AGENTS.md:L119-L123

Useful? React with 👍 / 👎.

Comment thread src/lib/EventStream.ts Outdated
}

const listener = () => this.controller.abort();
const listener = () => this.controller.abort(signal.reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Settle cancellation when reading a structural signal reason throws

For a structural or polyfilled AbortSignal whose reason getter throws, this abort-event callback fails before this.controller.abort() runs. With native EventTarget dispatch that can surface as an uncaught listener exception while the SDK request and runner.done() remain pending, rather than settling cancellation; guard the reason read and ensure the internal controller or terminal promise is settled with the resulting failure.

AGENTS.md reference: AGENTS.md:L98-L105

Useful? React with 👍 / 👎.

Comment thread src/lib/AbstractChatCompletionRunner.ts Outdated
try {
rawContent = await fn.function(parsed, runner, toolContext);
} catch (error) {
if (this.controller.signal.aborted && error === this.controller.signal.reason) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use SameValue when matching callback abort reasons

If a caller uses controller.abort(Number.NaN) and the tool callback observes cancellation through activeRunner.controller.signal.throwIfAborted(), the callback throws that exact reason but error === this.controller.signal.reason is false for NaN. The runner therefore converts the cancellation into an ordinary OpenAIError('NaN'), reports aborted as false, and loses the cause; use Object.is for this abort-reason comparison in both callback branches.

AGENTS.md reference: AGENTS.md:L119-L123

Useful? React with 👍 / 👎.

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Addressed the new cancellation findings. Abort propagation now tolerates structural signals whose reason getter throws, stream helpers preserve the runner controller reason in APIUserAbortError.cause, and sequential callback matching uses Object.is so values such as NaN retain user-abort classification. Added regressions for the structural signal, NaN, and Responses helper cause propagation. Focused validation: 117 tests passed across runner-abort-listeners, ChatCompletionRunFunctions, and streaming-core.

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.

Non-streaming chat runners drop caller abort reasons

2 participants