Skip to content

[Bug] 上游省略空字符串字段时流式解码抛 TypeError(reading 'length'),会话随后持续失败 #883

Description

@mycatxl

环境

  • PI-Desktop 0.15.3(Windows,已是最新版本)
  • 本文由 AI 依据本机运行日志 + 发布包产物分析得出,其中"复现"一节是在本机实跑的结果,未经人工逐行复核

现象

某个会话跑到中途后,之后每一轮都固定返回同样一条错误,且与所选服务商无关:

AI 服务返回了错误   PROVIDER_ERROR
Cannot read properties of undefined (reading 'length')
  • 换成另一个第三方中转(协议都不一样:一个 Anthropic Messages 风格、一个 OpenAI Chat Completions 风格)仍是同一条报错 → 与具体上游无关。
  • 从出问题的会话分叉(fork)出的新会话继承同样的失败;新建空白会话一切正常。
  • 出问题的会话上下文很大(单轮请求 290+ 条消息)。
  • 界面上的「继续」重试也无法恢复。

日志证据(已去隐私)

logs/app/session.log 中连续 4 次一模一样的记录:

{"event":"agent.turn.failed","code":"PROVIDER_ERROR",
 "data":{"message":"Cannot read properties of undefined (reading 'length')","retriable":true,
 "details":{"phase":"stream","requestMessages":294,"providerWaitMs":63057,"streamMs":1,"retryAttempt":10}}}

几个关键点:

  1. message 是 V8 的原始 TypeError 文本,不是上游返回的文案。同一个日志里上游真正的报错长这样:404 "model not found"、API error (404)。也就是说:错误是客户端解码流的时候抛的,然后被原样当成 provider 错误上抛了。
  2. providerWaitMs = streamStartedAt - requestStartedAt ≈ 63s(首字节等待),streamMs = endedAt - streamStartedAt = 0~1ms。即崩在"收到第一个 message_start 之后、处理紧随其后的第一个内容帧"的同一时间片内,而不是请求阶段。
  3. 同一会话更早还出现过 STREAM_FAILED: stream stalled: no provider events for 180000ms,所以这一次不是空闲超时。
  4. retryAttempt: 10 已经顶到 PROVIDER_RETRY_MAX_RETRIES = 10,而这个计数是跨轮累计、只有干净结束的那一轮才清零(if (!failed && !aborted) { this.providerTransientRetryAttempt = 0; ... })。所以预算耗尽后再也不重试,直接把原始 TypeError 弹给用户——这解释了两个现象:retryAttempt 永远是 10,以及"一点就报错"。

复现(用发布包里那段代码实跑)

把 resources/agent-runtime/sidecar.js 里 @earendil-works/pi-ai/dist/utils/assistant-message-frame.js 的 AssistantMessageFrameEncoder 单独抽出来,喂入"上游省略了空字符串字段"的帧(很多网关/中转会省略空串字段):

[1] 正常 text_delta                     : 未抛错   -> {"type":"text_delta","contentIndex":0,"delta":"hi"}
[2] text_delta 的 delta 字段缺失         : TypeError: Cannot read properties of undefined (reading 'length')
[3] thinking_delta 的 delta 字段缺失     : TypeError: Cannot read properties of undefined (reading 'length')
[4] toolcall_delta 的 delta 字段缺失     : TypeError: Cannot read properties of undefined (reading 'length')
[5] text_start 的块缺少 text 字段        : TypeError: Cannot read properties of undefined (reading 'length')
[6] thinking_start 的块缺少 thinking 字段 : TypeError: Cannot read properties of undefined (reading 'length')

正常帧完全没事,只要少一个字符串字段就原样吐出线上那条报错。

代码位置(0.15.3 打包产物)

1) @earendil-works/pi-ai/dist/utils/assistant-message-frame.js — AssistantMessageFrameEncoder.encode()
这里只校验了 content.type,没有校验字符串字段本身:

case "text_start": {
  const content = eventBlock(event);
  if (content.type !== "text") throw new Error(`text_start event points to ${content.type} block ...`);
  this.startBlock(event.contentIndex, { kind: "text", coveredChars: content.text.length, deltaChars: 0 });   // ← text 可能是 undefined
  ...
}
case "thinking_start": {
  ...
  this.startBlock(event.contentIndex, { kind: "thinking", coveredChars: content.thinking.length, deltaChars: 0 });  // ← 同上
}
encodeTextDelta(contentIndex, delta, kind) {
  ...
  state3.deltaChars += delta.length;   // ← delta 可能是 undefined
  const covered = Math.max(0, state3.coveredChars - deltaStart);
  if (covered >= delta.length) return void 0;
  ...
}
// toolcall_delta 分支里还有一处: event.delta.length === 0 ? void 0 : {...}

2) @earendil-works/pi-ai/dist/api/anthropic-messages.js — 建块时有 ?? "",delta 路径却是裸透传

// 建块(这里是安全的)
const block = { type: "text", text: event.content_block.text ?? "", index: event.index };

// delta(不安全)
block.text += event.delta.text;
stream12.push({ type: "text_delta", contentIndex: index5, delta: event.delta.text, partial: output });
// thinking_delta  -> delta: event.delta.thinking
// input_json_delta -> delta: event.delta.partial_json

3) 同类漏网(OpenAI 兼容协议的 reasoning_details 兼容解析)

function parseLegacyEncryptedReasoningDetail(signature) {
  ...
  return parsed.type === "reasoning.encrypted" && typeof parsed.id === "string"
      && parsed.id.length > 0 && parsed.data.length > 0 ? parsed : void 0;   // ← parsed.data 没做类型校验
}

对比:同文件的 isOpenAIReasoningDetail() 是有 typeof detail.data === "string" 守卫的,这个 legacy 解析函数漏了。

建议

  • 给上面所有 .length 加 typeof x === "string" 守卫或 ?? "" 兜底。缺字段的帧应当降级为"忽略这个 delta"或一条可读的 STREAM_FRAME_MALFORMED,不该把 V8 的原始 TypeError 当 provider 错误抛给用户——否则用户会一直以为是自己的服务商坏了。
  • retryAttempt 的累计计数建议按轮重置,或在详情里区分「本轮第几次重试」,否则看到 10 无法判断是本次重试还是历史累计。
  • 如果方便,加一个记录原始 SSE 帧的调试开关(类似现有的 PI_DESKTOP_STREAM_IDLE_TIMEOUT_MS)。这类"上游响应形状"问题,有原始帧一次就能定位,否则只能靠猜。
  • 修在 pi-ai 层最合适:frame encoder 是所有 api adapter 的公共出口,一处守卫可以覆盖全部协议。

说明:以上日志取证、代码定位与复现均由 AI 完成;代码片段摘自 0.15.3 的打包产物(sidecar.js),与仓库源码的行号可能不一致。

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions