* feat(gateway): advertise chat attachment limits on hello-ok
Clients had no way to learn the gateway attachment ceilings, so external
clients hardcoded guesses that drifted from server enforcement. Publish the
two unconditional decoded-size ceilings on hello-ok policy.attachments from
one shared resolver so advertised values cannot drift from the parser.
MIME acceptance and per-message counts stay server-side: they depend on the
entrypoint, the resolved model, and payload sniffing, so they cannot be stated
once per connection.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 17d6c355-8948-4b48-a936-e08b1c8806ef
* feat(gateway): advertise chat attachment limits on hello-ok
---------
Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Copilot-Session: 17d6c355-8948-4b48-a936-e08b1c8806ef
Chat Completions replay flattened every assistant text block with an empty
separator, so two distinct blocks came back as one word-joined sentence. The
same message shape survives distinctly on the Anthropic, Responses and Mistral
lanes, and the string-content flattener for strict OpenAI-compatible servers
already joins with a newline.
Two blocks arise routinely: streaming opens a new text block after a tool call,
and cross-model replay converts a thinking block into a text block.
* fix(ai): Codex stream shows internal parser text on a malformed frame
The Codex SSE parse boundary rethrew JSON.parse failures with its own
message, so the three consumers that key off the shared malformed-fragment
marker by exact string equality could not recognise it and the internal
parser wording reached the operator verbatim.
Mirror the canonical transport contract instead: a SyntaxError becomes the
shared marker with the original error kept as cause, and anything else is
rethrown untouched. The WebSocket twin is left alone because two open PRs
own that block today.
* fix(ai): preserve consumer SSE errors
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
The provider stream path interpolated the rejected SSE frame and its raw lines
into the thrown error, so text the model had generated reached logs and the
operator's error surface. The transport path already answers this exact condition
with the shared MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE and keeps the
SyntaxError on cause, and the user-facing substitution in
formatRawAssistantErrorForUi matches that constant exactly. Align the provider
path with it so the same failure reads the same way on both paths.
The error-event branch is left as is: its payload is parsed downstream to build a
meaningful operator message, so redacting it would be a regression.
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* [AI] fix(agents): emit aborted tool results for skipped tool calls on mid-turn abort
When an abort fires mid-batch in executeToolCalls (after the assistant
message with tool_use is committed but before all tool_results are
written), the sequential and parallel dispatch loops break out and skip
the remaining tool calls. The committed assistant turn retains N tool_use
blocks but only M < N tool_results land in context.messages, leaving
orphaned tool_use that corrupts retries/continuation and triggers provider
400 errors on providers that do not synthesize missing results
(allowSyntheticToolResults=false, e.g. openai-completions/DeepSeek).
Emit aborted tool results (createErrorToolResult("Operation aborted")) for
the skipped tail in both executeToolCallsSequential and
executeToolCallsParallel so every tool_use keeps a paired tool_result.
This complements the existing write-side guard (which only covers
synthetic-enabled providers) and the persisted replay repair.
The aborted tail outcomes are routed through finalizeToolCallOutcome (via
a shared finalizeAbortedToolCall helper) so config.afterToolOutcome hooks
(audit, redaction, metadata, error-normalization) observe these skipped
calls just like every immediate or executed outcome, instead of bypassing
the outcome contract. Regression tests assert afterToolOutcome fires for
every skipped call in both dispatch modes.
Fixes#116379
Co-Authored-By: Maas <noreply@anthropic.com>
* [AI] fix(agents): emit tool_execution_start before aborted end for skipped calls
The abort-tail backfill added in #116379 emits tool_execution_end (and a
paired tool_result) for tool calls the dispatch loop never reached, but it
skipped the matching tool_execution_start. Channel/client subscribers that
pair start→end events received an end for an unknown tool-call id during
abort recovery.
Emit tool_execution_start for each skipped call before its aborted end/result,
mirroring the start event every dispatched (including immediate non-executed)
call already emits. Covers both sequential and parallel dispatch, with
regression assertions that every skipped call has a start before its end and
that start/end counts stay paired.
Co-Authored-By: Maas <noreply@anthropic.com>
* fix(agents): complete aborted tool tails safely
Fixes#116379
---------
Co-authored-by: Maas <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(ai): prevent websocket cache clobber on concurrent acquire
The WebSocket session cache acquire path (expired or non-reusable cached
entry) used unconditional delete/set around the connectWebSocket() await.
A concurrent request could install a newer entry during that await, which
the resuming acquire then clobbered, orphaning the socket that carried the
real previous_response_id continuation and corrupting multi-turn Codex
conversations.
Mirror the release path's owner-checked helpers: delete via
deleteOwnedWebSocketSession, and install via a new setOwnedWebSocketSession
that only writes when the cache still matches what this acquire left behind
(the stale entry, or undefined after removing it / on first connect). A
different cached entry means a concurrent request already won the session.
The busy-cached branch is unaffected: it returns a transient socket and
never writes the cache.
* fix(ai): close CAS loser websocket promptly without leaking its socket
* fix(ai): correct verifyClient callback type in race regression test
* fix(ai): remove unused variable and fix lint in race regression test
* test(ai): prove concurrent websocket loser closes
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
* fix(cron): bound restart catch-up to the active schedule
Editing a recurring job's schedule made the gateway fire it immediately on
the next restart. Startup catch-up compares the new schedule's previous slot
against lastRunAtMs, which still belongs to the retired schedule, so a slot
that never existed under the old schedule counted as missed.
Record when scheduling inputs take effect and replay a missed slot only when
it is newer than that. Jobs whose schedule never changed carry no stamp and
keep replaying every computed slot, so catch-up is unchanged for them.
The missed-slot predicate was duplicated in the runnable check and the
backoff-deferral pass; both now share one helper so the bound cannot drift.
Fixes#91944
* fix(cron): protect schedule activation ownership
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>