Skip to content

ApproveOutcome/RejectOutcome 是 spec approve/reject 响应的手写镜像,已漂移(id 根本不在响应里),且因改名躲过 spec-symbol 守卫 #3783

Description

@yinlianghui

发现于 #3293 的核验过程(那张单的前提已失效,详见其上的报告)。这是同一个文件里残留的一处手写 wire 类型,与 #3293 已解决的那两个是同一失败类,但因为本地名字和 spec 名字不同,scripts/check-spec-symbol-derivation.mjs 这类基于名字碰撞的守卫对它完全没有抓手。

事实

packages/plugin-chatbot/src/usePendingActions.ts:69-80(origin/main 00b9451d8)手写:

export interface ApproveOutcome {
  id: string;
  status: 'executed' | 'failed' | string;
  result?: unknown;
  error?: string;
  [k: string]: unknown;
}

export interface RejectOutcome {
  id: string;
  status: 'rejected' | string;
}

spec 侧已经声明了这两个响应(objectstack origin/main 6029cc1bb,packages/spec/src/api/protocol.zod.ts:1453-1462,类型导出在 :1680-1681):

export const ApproveAiPendingActionResponseSchema = lazySchema(() => z.object({
  status: z.enum(['executed', 'failed']),
  result: z.unknown().optional(),
  error: z.string().optional(),
}));

export const RejectAiPendingActionResponseSchema = lazySchema(() => z.object({
  status: z.literal('rejected'),
  id: z.string(),
}));

漂移有三处,其中一处是当下就在跑的错类型

  1. ApproveOutcome.id 是必填,但 approve 响应根本不带 id。 spec 的 approve 响应只有 status/result/error;reject 响应才有 id。而 useHitlInChat.ts:237 把原始 payload 直接断言过去:

    onDecided?.(toolCallId, payload as ApproveOutcome | RejectOutcome);

    于是交给 onDecided 的对象在编译期承诺 id: string,运行期是 undefined。onDecided 是 plugin-chatbot 的公开回调(src/index.tsx:348-349 导出这两个类型),外部消费者读 outcome.id 拿到 undefined 而编译器不报。这一条不是休眠的。

  2. | string 吸收字面量。 'executed' | 'failed' | string 就是 string,注解不携带任何信息 —— 与 refactor(data-objectstack,plugin-chatbot,plugin-list)!: 台账燃尽批次 6 —— 12 个符号不再顶着 spec 的名字 (#3160) #3220 从同文件另两个类型上删掉的 Drift 1 一模一样。下游后果在 useHitlInChat.ts:217-235:代码手写 status 字符串比较,并且 else 兜底分支把认不出的 status 当成成功(succeeded = true),进而继续对话。若用闭合枚举,这个兜底分支要么可证死、要么必须显式处理。(诚实标注:今天服务端只返回代码已处理的那几个值,所以这一条的行为后果目前不可达;可达的是类型层面已经零信息。)

  3. [k: string]: unknown 索引签名。 objectstack#4075 那个机制:有它在,任何结构比较都答"一致",无论抄本漂多远 —— 也就是说,就算给这两个类型补一条 parity 测试,索引签名也会让它恒绿。

为什么现有守卫抓不到

check-spec-symbol-derivation.mjs 判的是"本地声明占用了 spec 导出的名字"。ApproveOutcome ≠ ApproveAiPendingActionResponse,名字不碰撞,守卫按设计放行。这正是名字型守卫的残留洞:换个名字手抄对它隐形。#3720 是同类(本地名字下手写第三套拼写)。

建议修法(契约优先,零新依赖)

plugin-chatbot 已经依赖 @objectstack/spec(package.json 里 ^17.0.0-rc.5,#3220 引入),所以派生是免费的:把两个类型改成从 spec 派生(z.infer/re-export),删掉 | string 与索引签名;id 按 spec 归位到 reject 一侧。随后 useHitlInChat.ts:217 的 else 兜底需要重新裁决 —— 是显式处理未知 status,还是按闭合枚举证死。

请 PM triage 分级:第 1 条我判为具体缺陷(公开 API 类型承诺了 wire 不返回的字段,今天就在跑),第 2、3 条按现状更接近潜伏/观察类。

Refs: #3293(核验来源)、#3220(同文件另两个类型的处置范式)、#3720(同类)、objectstack#4075、objectstack#4115。

Activity

  1. yinlianghui commented on Aug 8, 2026

    @yinlianghui
    CollaboratorAuthor

    分诊判级(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt):bug + pm:queue,可派。

    判据:第 1 条是当下在跑的具体缺陷 —— plugin-chatbot 公开回调 onDecided 的类型面(ApproveOutcome.id: string 必填)承诺了 approve wire 响应根本不返回的字段(spec protocol.zod.ts:1453-1456 只有 status/result/error),外部消费者读 outcome.id 编译期无告警、运行期 undefined。落点明确(usePendingActions.ts:69-80 + useHitlInChat.ts:237)、修法免费(@objectstack/spec 已是运行时依赖,派生即可)、有同文件处置范式(#3220)。

    派发时须点名的行为裁决边界:useHitlInChat.ts:217-235 的 else 兜底把未知 status 当成功继续对话 —— 类型闭合后该分支的处置(显式处理 vs 证死)是行为变更,dev 须在 PR 中单独论证并默认保持现行为(仅类型收紧),要改行为先停手上报。第 2、3 条(| string 吸收、索引签名)随派生修法一并消失,不另立单。


    Generated by Claude Code

  2. self-assigned this
    on Aug 8, 2026
  3. yinlianghui commented on Aug 8, 2026

    @yinlianghui
    CollaboratorAuthor

    🔒 认领(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)

    即将派发 os-dev 实施。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions