Skip to content

fix(stream): 识别空流/截断流,不再伪造成功 - #146

Merged
Quorinex merged 1 commit into
Quorinex:mainfrom
asuan-dev:fix/stream-integrity-retry
Jul 29, 2026
Merged

fix(stream): 识别空流/截断流,不再伪造成功#146
Quorinex merged 1 commit into
Quorinex:mainfrom
asuan-dev:fix/stream-integrity-retry

Conversation

@asuan-dev

Copy link
Copy Markdown
Contributor

摘要

依赖 #145 本 PR 补的是「读出来的流完不完整」这一层,建立在 #145 修好的 OnStopReason 信号上。

注意:因为 #145 尚未合并,GitHub 会把 #145 的提交也显示在本 PR 的 Files 里。本 PR 真正新增的是顶部那一个提交(detect empty and truncated upstream streams)。请先审 #145,再看本 PR 的顶部提交。

parseEventStream 在干净 EOF 时返回 nil,所以中途断掉的流和正常结束的流对调用方完全一样:没有 error、OnComplete 照常触发、handler 用 stop_reason: end_turn 收尾。客户端把半截答案当成已完成并停止;非流式路径把截断文本当正常 200 返回。

这是线上「思考完整但该进入下一步时就停了」最直接的对症修复。

改动

  • 新增 classifyStreamIntegrity:有 stopReason 或有工具调用 → 完整;有内容或仅 reasoning 但无终止信号 → 截断。
  • 新增 runKiroWithIntegrityRetry,六条路径(Claude / OpenAI / Responses × stream / non-stream)统一接入。
  • 未 flush → 同账号最多重试 2 次,再轮换账号。
  • 已 flush → 传播 integrity error,各路径发自己的失败事件(SSE error / response.failed),绝不伪造 end_turn
  • integrity 错误是软失败:轮换账号,但不走 handleAccountFailure,上游偶发不会把账号标记为不健康。

与官方 IDE 的关系(一手源码)

官方 Kiro IDE 解包源码:

kiro-ide-unpack/app/extensions/kiro.kiro-agent/dist/extension.js
  • 空响应判定 C43 = contentChars===0 && toolCallCount===0 && !reasoningSeen && stopReason===undefined —— 与本 PR 一致。
  • 截断判定 P45 末尾是 (contentChars > 0 || !reasoningSeen),所以「仅 reasoning 无正文」在 IDE 里判完整、不重试
  • 本 PR 对「仅 reasoning」更严,是有意的:IDE 是自己的客户端,知道后面还有轮次;代理不知道下游是谁,把「只有思考没有答案」当成成功,正是线上症状本身。注释里写明了这个偏离,没有谎称 mirroring。
  • 空响应分支在本 PR 不实现:作者的 errEmptyKiroStream 已在更低层覆盖(触发信号完全相同),再写一层是死代码。

重试次数

同账号 2 次。IDE 的 Dt3 = 1 是单凭据客户端的上限;本代理有账号池,截断时 CallKiroAPIContext 返回 nil,不走 endpoint 回退,唯一乘数是账号轮换。最坏 3 账号 × 3 次 = 9 个请求,比预算 1 只多 3 个。收益主要落在三条非流式路径上(永不提前 flush,能吃满预算)。

测试

  • 分类器表驱动(含「仅 reasoning 更严」)。
  • 截断后同账号重试恢复。
  • 已 flush 不重试。
  • 取消不被误判为 integrity 错误。
  • 重试预算守住。
  • 六条路径的截断/失败事件。
  • integrity 错误不封号。
  • 既有夹具补了 metadataEvent stopReason 帧(真实上游会发,不是放宽断言)。
  • gofmt / go vet / go build / go test ./... -count=1 全绿。

依赖

请先合并 #145#145 合并后,本 PR 的 Files 会自动只显示本 PR 的 diff。

…aking success

parseEventStream returns nil on a clean EOF, so a stream that died mid-answer
was indistinguishable from a finished one: no error, OnComplete fired, and the
handlers closed the turn with stop_reason: end_turn. Clients treated a half
answer as done and stopped; non-stream callers returned truncated text as 200.

classifyStreamIntegrity judges a transport-clean stream from the signals the
previous commit made reliable: a non-empty stopReason, or a delivered tool
call, means complete. Content or reasoning without either means truncated.

This is stricter than Kiro IDE on purpose. The IDE's truncation predicate ends
with (contentChars > 0 || !reasoningSeen), so a reasoning-only turn with no
stopReason is complete to the IDE and is not retried. The IDE is its own
client and knows another turn follows; a proxy does not, and shipping thinking
with no answer is exactly the reported symptom. The comment records the
divergence rather than claiming to mirror the IDE.

There is deliberately no separate empty-response error: errEmptyKiroStream
already fires one layer down from the same three signals, so an all-zero
stream never reaches the classifier with a nil error.

runKiroWithIntegrityRetry wires all six chat paths (Claude / OpenAI /
Responses x stream / non-stream) and removes six near-identical copies of the
retry block. Retries reuse the caller's callback; both CallKiroAPIContext and
parseEventStreamTracked copy the struct before wrapping a field, so a retry
cannot double-wrap it, and per-attempt state is cleared by reset instead.

Retry safety follows the boundary the author already established rather than
overturning it:

  - Not flushed yet -> retry the same account twice, then rotate. Truncation
    never reaches the endpoint fallback (CallKiroAPIContext returns nil for
    it), so the only multiplier is account rotation: 9 upstream requests worst
    case versus 6 at a single retry. The budget pays off on the three
    non-stream paths, which never flush early.
  - Already flushed -> propagate the integrity error and emit that path's own
    failure event (SSE error / response.failed). Never forge an end_turn.

Integrity errors are soft failures: callers rotate the account but skip
handleAccountFailure, so an upstream blip cannot mark a healthy account
unhealthy or drain the pool. Cancellation is checked first in every branch and
is never reported as an integrity failure.
@Quorinex
Quorinex force-pushed the fix/stream-integrity-retry branch from e46f3c1 to 851bd20 Compare July 29, 2026 13:05
@Quorinex
Quorinex merged commit f8f6071 into Quorinex:main Jul 29, 2026
1 check passed
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.

2 participants