fix(agents): detect empty provider responses as failures, improve Telegram error routing#60830
fix(agents): detect empty provider responses as failures, improve Telegram error routing#60830garnetlyx wants to merge 2 commits into
Conversation
…egram error routing
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fb3b7e10a6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Re-throw dispatcher crashes after fallback/cleanup so callers can log/alert | ||
| if (caughtDispatchError) { | ||
| throw caughtDispatchError; |
There was a problem hiding this comment.
Re-throw dispatch crashes before early no-response return
When dispatchReplyWithBufferedBlockDispatcher throws and caughtDispatchError is set, the function can still exit via the !hasFinalResponse early return (for example if fallback delivery also fails or only partial/non-final output was sent), so the new re-throw block is never reached. This silently swallows dispatcher crashes that previously propagated, which can hide production failures from upstream logging/alerting and make incident triage harder.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR introduces two improvements: a typed Key changes:
Confidence Score: 4/5Safe to merge with minor observability and test-coverage gaps; no runtime correctness issues found. The core logic is sound: EmptyResponseError propagates correctly through the fallback loop and into the Telegram dispatch routing in all scenarios. Abort/timeout re-throw prevents spurious fallback replies. The only concerns are: (1) the meta-field heuristic in runFallbackAttempt is not type-enforced and could silently break if either EmbeddedPiRunResult or raw provider response shapes change; (2) EmptyResponseError from the last/only candidate bypasses attempts.push and params.onError, reducing observability; (3) the single-candidate empty-content path is not covered by tests. None of these are correctness bugs in the current code. src/agents/model-fallback.ts (meta-field guard and last-attempt observability); src/agents/model-fallback.test.ts (single-candidate empty-content test coverage)
|
| // Check for empty raw provider response content array (treat as failure) | ||
| // Do NOT check EmbeddedPiRunResult payloads here - that should be handled upstream | ||
| // to avoid breaking valid use cases like memory flush which intentionally return empty | ||
| const result = runResult.result; | ||
| if ( | ||
| result && | ||
| typeof result === "object" && | ||
| "content" in result && | ||
| Array.isArray(result.content) && | ||
| result.content.length === 0 && | ||
| // Only apply to raw provider responses (not wrapped in EmbeddedPiRunResult) | ||
| !("meta" in result) | ||
| ) { |
There was a problem hiding this comment.
Fragile
meta-field discriminant for EmbeddedPiRunResult
The !("meta" in result) guard is load-bearing for correctness: if a raw provider ever adds a meta field to its empty-content response (or if EmbeddedPiRunResult ever drops its meta field), the detection will silently misfire in the wrong direction. Since this heuristic is also completely invisible in the type system, future callers of runWithModelFallback have no way to know their result type needs to satisfy the guard.
A more robust approach would be to either:
- Pass an explicit
isRawProviderResulttype predicate intorunFallbackAttempt, or - Require callers to mark which result types should trigger the empty-content check (e.g. a wrapper type or a config flag)
The current approach is an improvement over the prior string-matched check, but it introduces a new latent coupling between runFallbackAttempt's heuristic and the shape of EmbeddedPiRunResult that isn't enforced anywhere in the type system.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.ts
Line: 163-175
Comment:
**Fragile `meta`-field discriminant for EmbeddedPiRunResult**
The `!("meta" in result)` guard is load-bearing for correctness: if a raw provider ever adds a `meta` field to its empty-content response (or if `EmbeddedPiRunResult` ever drops its `meta` field), the detection will silently misfire in the wrong direction. Since this heuristic is also completely invisible in the type system, future callers of `runWithModelFallback` have no way to know their result type needs to satisfy the guard.
A more robust approach would be to either:
1. Pass an explicit `isRawProviderResult` type predicate into `runFallbackAttempt`, or
2. Require callers to mark which result types should trigger the empty-content check (e.g. a wrapper type or a config flag)
The current approach is an improvement over the prior string-matched check, but it introduces a new latent coupling between `runFallbackAttempt`'s heuristic and the shape of `EmbeddedPiRunResult` that isn't enforced anywhere in the type system.
How can I resolve this? If you propose a fix, please make it concise.| it("treats empty content array (no meta) as failure and falls back to next candidate", async () => { | ||
| const cfg = makeCfg({ | ||
| agents: { | ||
| defaults: { | ||
| model: { primary: "openai/gpt-4.1-mini", fallbacks: ["fallback/ok-model"] }, | ||
| }, | ||
| }, | ||
| }); | ||
| const run = vi | ||
| .fn() | ||
| .mockResolvedValueOnce({ content: [] }) // primary: empty, no meta → EmptyResponseError | ||
| .mockResolvedValueOnce("ok"); // fallback succeeds | ||
| const result = await runWithModelFallback({ | ||
| cfg, | ||
| provider: "openai", | ||
| model: "gpt-4.1-mini", | ||
| run, | ||
| }); | ||
| expect(result.result).toBe("ok"); | ||
| expect(run).toHaveBeenCalledTimes(2); | ||
| }); |
There was a problem hiding this comment.
Missing test for single-candidate empty-content scenario
The new test only covers the two-candidate case (primary returns { content: [] }, fallback returns "ok"). The single-candidate path is meaningfully different: isKnownFailover is false and i === candidates.length - 1, so EmptyResponseError is thrown directly (before attempts.push and onError), bypassing throwFallbackFailureSummary.
It would be good to add a test asserting that when the only candidate returns { content: [] }, the call rejects with an EmptyResponseError (not a generic "All models failed" wrapper):
it("throws EmptyResponseError when the only candidate returns empty content", async () => {
const cfg = makeCfg({
agents: { defaults: { model: { primary: "openai/gpt-4.1-mini" } } },
});
const run = vi.fn().mockResolvedValueOnce({ content: [] });
await expect(
runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run }),
).rejects.toBeInstanceOf(EmptyResponseError);
expect(run).toHaveBeenCalledTimes(1);
});Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/model-fallback.test.ts
Line: 1210-1230
Comment:
**Missing test for single-candidate empty-content scenario**
The new test only covers the two-candidate case (primary returns `{ content: [] }`, fallback returns `"ok"`). The single-candidate path is meaningfully different: `isKnownFailover` is false and `i === candidates.length - 1`, so `EmptyResponseError` is thrown directly (before `attempts.push` and `onError`), bypassing `throwFallbackFailureSummary`.
It would be good to add a test asserting that when the only candidate returns `{ content: [] }`, the call rejects with an `EmptyResponseError` (not a generic "All models failed" wrapper):
```ts
it("throws EmptyResponseError when the only candidate returns empty content", async () => {
const cfg = makeCfg({
agents: { defaults: { model: { primary: "openai/gpt-4.1-mini" } } },
});
const run = vi.fn().mockResolvedValueOnce({ content: [] });
await expect(
runWithModelFallback({ cfg, provider: "openai", model: "gpt-4.1-mini", run }),
).rejects.toBeInstanceOf(EmptyResponseError);
expect(run).toHaveBeenCalledTimes(1);
});
```
How can I resolve this? If you propose a fix, please make it concise.…mpty-result predicate - Re-throw caughtDispatchError in Telegram early-return path (P1) - Treat EmptyResponseError as known failover for observability (P2) - Replace fragile meta-field heuristic with isRawProviderResult option (P2) - Add single-candidate empty-content test (P2)
|
Thanks for the context here. I did a careful shell check against current Close as mostly implemented on current main: the central empty/invisible terminal-result fallback path has shipped through the current model-fallback classifier, Telegram now lives in the extension path this branch does not touch, and the remaining durable final fallback semantics are owned by the open maintainer issue. So I’m closing this older PR as already covered on Review detailsBest possible solution: Keep the shipped classifier-based fallback implementation and route remaining durable final fallback delivery semantics through #87561 instead of rebasing this stale branch. Do we have a high-confidence way to reproduce the issue? Yes, source-backed. Current main and v2026.6.6 show the model-fallback classifier path that converts empty or invisible embedded terminal results into fallback-eligible failures; I did not run live Telegram proof for this stale branch. Is this the best way to solve the issue? No. The branch is no longer the best fix because current main has shipped a classifier-based fallback path and Telegram moved to extensions/telegram, while the remaining delivery semantics belong in the canonical maintainer issue. Security review: Security review cleared: The diff only touches TypeScript fallback/Telegram dispatch code, tests, and .gitignore entries; I found no concrete security or supply-chain regression. AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model internal, reasoning high; reviewed against 37e3e895b020; fix evidence: release v2026.6.6, commit 8c802aa68351. |
|
ClawSweeper PR egg 🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat. Where did the egg go?
|
|
This pull request has been automatically marked as stale due to inactivity. |
|
ClawSweeper applied the proposed close for this PR.
|
Summary
EmptyResponseErrortyped class inmodel-fallback.ts; replaces the fragile string-matchednew Error("Empty response content received from provider")runFallbackAttempt: treats a raw provider response withcontent: []and nometafield as a failure, triggering fallback to the next candidate (benefits all channels)lastErrorviaonErrorand an outer try/catch; routes the user-visible fallback message by error type (rate limit / empty response / general); re-throws aborts and timeouts so they don't produce spurious fallback replies; re-throws other dispatcher crashes after cleanup so callers still see the failure for logging/alertingmetafield guard (accepted as valid)Related
Follow-up work tracked in:
Test plan
pnpm test src/agents/model-fallback.test.ts— all tests pass including 3 new onesbunx oxlint src/agents/model-fallback.ts src/telegram/bot-message-dispatch.ts— 0 errors/stopor timeout → no spurious fallback reply sent