refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783) - #3800

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive
Aug 8, 2026
Merged

refactor(plugin-chatbot): ApproveOutcome/RejectOutcome 改为从 spec 派生,id 归位到 reject 一侧 (#3783)#3800
yinlianghui merged 1 commit into
mainfrom
claude/issue-3783-hitl-outcome-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3783

packages/plugin-chatbot/src/usePendingActions.ts 里手写的 ApproveOutcome / RejectOutcome 改为从 @objectstack/spec 派生。与 #3220同一文件清掉 PendingActionRow / PendingActionStatus 是同一失败类;不同之处在于这两个穿的是本地名字,所以 scripts/check-spec-symbol-derivation.mjs(按"spec 导出名被本地声明占用"触发)对它们天生没有抓手 —— 换个名字手抄,对名字型守卫是隐形的

派生形态(#3220 范式,零新依赖)

@objectstack/spec 已是本包运行时依赖(package.json,#3220 引入),且本次只用 import type,编译期即擦除,不进 bundle:

importtype{ApproveAiPendingActionResponse,RejectAiPendingActionResponse,}from'@objectstack/spec/api';exporttypeApproveOutcome=ApproveAiPendingActionResponse;exporttypeRejectOutcome=RejectAiPendingActionResponse;

派生源实读(不凭 issue 转述),objectstack origin/maind42a92fc6:

  • packages/spec/src/api/protocol.zod.ts:1453-1462 —— ApproveAiPendingActionResponseSchema 只有 status: z.enum(['executed','failed']) / result? / error?;RejectAiPendingActionResponseSchemastatus: z.literal('rejected') + id: z.string()
  • :1680-1681 导出这两个类型名;packages/spec/src/api/index.ts:44export * from './protocol.zod',故 @objectstack/spec/api 子路径可达(装在本 worktree 的 17.0.0-rc.5 dist 里两个名字都实测在导出清单内)。
  • 第二个独立信源:packages/spec/src/contracts/ai-service.ts:298-301IAIService.approvePendingAction 返回 Promise< { status: 'executed' | 'failed'; result?: unknown; error?: string } > —— 同样没有 id
  • 第三个:packages/client/src/index.ts:4109-4127,ai.pendingActions.approve() / .reject() 正是用这两个 spec 类型标注返回值。也就是说本 PR 之后,objectui 与 objectstack client 对同一条 wire 的读法收敛到同一个声明。

公开导出名保持 ApproveOutcome / RejectOutcome 不变(src/index.tsx 的已发布 API 面),只改形状。

三处漂移的逐条处置

漂移处置证据
ApproveOutcome.id: string必填,而 approve 响应根本不带 id删除;id 按 spec 归位到 RejectOutcome(reject 响应确实带)下游探针:改前 outcome.id 编译通过(运行期 undefined),改后 TS2339
status: 'executed' | 'failed' | string / 'rejected' | string收敛为 'executed' | 'failed''rejected'下游探针:改前 outcome.status === 'quarantined' 编译通过,改后 TS2367
[k: string]: unknown 索引签名删除(objectstack#4075:有它在,任何结构比较恒答"一致",给旧类型补 parity 测试也会从第一天就是绿的)sabotage:HasIndexSignature 钉子在恢复手写形态后变红

第 1 条是当下在跑的那条:公开回调 onDecided 编译期承诺 id: string,运行期给 undefined,编译器全程沉默。

顺手改正的第四处漂移(prose,不是类型)

被替换掉的注释写着 approve 失败是 HTTP 500。这与 spec 的注释(status: 'failed'带原因的 200 —— 审批成功、执行失败)相反,也与本文件自身设计自相矛盾:call()!res.ok 时抛错,若真是 500,AiPendingActionsInbox.tsx:204-206out.error 的那条 resolved-value 路径就永远不可达。新注释按 spec 写。这类"声称 canonical 的错注释"正是本守卫家族要消灭的东西(它会成为下一个 agent 的既定前提),所以在替换该声明时一并订正,而非留给下一张单。

行为零变更论证(PM 判级边界指定的一节)

默认仅类型收紧。 逐处交代:

1. useHitlInChat.tselse 兜底(未知 status)—— 保留

}else{setDecision(toolCallId,{state: 'success',message: `Status: ${status}`});succeeded=true;}
  • 类型层压力:零。status 来自 const status = (payload.status as string) ?? …,而 payloadparseJson() 返回的 Record< string, unknown > —— 未经任何解析。它是 string,不是闭合枚举,所以 ApproveOutcome['status'] 收紧到两个字面量对这条分支施加不了穷尽性检查,tsc 也不会把它判死。也就是说:不改行为完全能过 type-check(实测绿),不存在"exhaustiveness 逼死 else"的情形,无需停手。
  • 运行期可达性: 仅当服务端返回 spec 词表之外的 status 时可达 —— 即不合规服务端或未来新增 status。对今天合规的服务端不可达。
  • 核实后修正 issue 正文一处描述: 该分支不会"继续对话"。succeeded 确实置 true,但 buildContinuationPromptexecuted/rejected/failed 之外的 status 返回 undefined,if (prompt) 不成立,continueConversation 不被调用。所以后果只是一个乐观的 UI chip,不是"把假成功喂给模型"。已用测试钉住(keeps treating an unrecognised status as a success chip, without continuing)。
  • 保留理由: 关掉它需要先回答"未知 status 该显示成错误、还是抛错、还是静默忽略",那是行为裁决而非实现细节;而且真要闭合,正确做法是在此处用 spec schema safeParse(producer 说话),那会把 zod 拖进这个以 tiny bundle 为卖点的包 —— 属政策问题。已连同下面两条一起记入 观察类:useHitlInChat 决策结果的三处消费侧容忍(失败信封是本地虚构 / status 兜底默认 / 未知 status 当成功) #3790

2. 非 2xx 时本地虚构的失败信封 —— 保留,id 也保留

onDecided?.(toolCallId,{ id,status: 'failed',error: message}asApproveOutcome);

这条路径上没有决策响应(服务端返回的是错误体),所以这个信封是本地造的通知。它自带 id 是既有行为,外部消费者在这条路径上今天确实能读到,删掉即运行期回归 —— 所以留着。类型层面 ApproveOutcome 不再声明 id(approve wire 从来没有),断言允许多余属性,故不需要改代码形状。已用测试钉住(still synthesizes the locally-built failure envelope, id included, on a non-2xx)。

3. useHitlInChat.ts:237 的断言 —— 核对通过,无需改写

payload as ApproveOutcome | RejectOutcome:payloadRecord< string, unknown >,而收紧后的两个类型都是匿名对象类型(z.infer 别名),带隐式索引签名,可比较关系成立,断言合法。实测 tsc 绿,未退化成 as unknown as

sabotage 自证(方向:红。改动即钉子,所以是常规方向)

把派生临时改回手写漂移形态(id: string + | string + 索引签名),只跑 typetests 工程:

$ pnpm --filter @object-ui/plugin-chatbot exec tsc -p tsconfig.typetests.json
src/__tests__/spec-symbol-batch6.test.ts(270,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/spec-symbol-batch6.test.ts(271,33): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(280,35): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(288,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(291,7): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(293,38): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(294,37): error TS2344: ...
src/__tests__/spec-symbol-batch6.test.ts(298,44): error TS2344: ...
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command failed with exit code 2

八条红对应:_ApproveIsSpec(270)、_RejectIsSpec(271)、_ApproveHasNoId(280,即那条在跑的缺陷)、_ApproveStatusNotString(288)、_RejectStatusNotString(291)、_ApproveVocabulary(293)、_RejectVocabulary(294)、_ApproveNoIndexSignature(298)。

诚实标注:_RejectNoIndexSignature(299)在 sabotage 下仍是绿的 —— 因为 reject 那份手抄本来就没有索引签名。这条钉子钉的是"以后别加",不是"当时有"。

还原后 pnpm --filter @object-ui/plugin-chatbot type-check 全绿(sabotage 未入 commit)。

下游消费者探针(证明收紧穿透到已发布的 .d.ts,且没有擦成 any)

vite build 后拿 dist/index.d.ts 当真实下游编译(探针文件已删除,未入 commit)。dist/usePendingActions.d.ts 首行保留了 import { ApproveAiPendingActionResponse, RejectAiPendingActionResponse } from '@objectstack/spec/api';,类型未擦除(objectstack#4171 是"spec 类型在 dist 里擦成 any"的先例,这里实测没有发生)。

改后(期望:两条错,其余静默):

probe-3783.ts(28,18): error TS2339: Property 'id' does not exist on type
'{ status: "failed" | "executed"; result?: unknown; error?: string | undefined; }'.
probe-3783.ts(33,10): error TS2367: This comparison appears to be unintentional
because the types '"failed" | "executed"' and '"quarantined"' have no overlap.

改前(把 origin/main 4028adfc3 的手写形态原文抄进探针,同样两处读法):exit 0,零错。这就是本单要消灭的东西:一个 wire 从未兑现的承诺,和一个任何拼写都能通过的 status 比较。IsAny / IsUnknown 探针、以及 outcome.status === 'executed'RejectOutcome.id 这些合法读法在改后依然无错。

消费半径清单(逐个确认)

全仓 grep ApproveOutcome|RejectOutcome|onDecided(排除 node_modules)命中三个文件,全在本包内:

消费方读什么结论
packages/plugin-chatbot/src/usePendingActions.tsapprove()/reject() 的返回标注定义方,已改
packages/plugin-chatbot/src/useHitlInChat.ts两处 as 断言 + onDecided/ContinueContext 类型面已核对,注释补齐,无代码形状变更
packages/plugin-chatbot/src/index.tsx只 re-export 两个名字名字不变,无改动
packages/plugin-chatbot/src/AiPendingActionsInbox.tsxout.status === 'executed'out.error两处在收紧后仍合法(词表内的字面量 + 可选 error);type-check 绿
packages/app-shell/src/console/ai/AiChatPage.tsx:1637useHitlInChat({ messages, apiBase, continueConversation })使用 onDecided、不读 outcome;options 接口未变。已单独跑 pnpm --filter @object-ui/app-shell type-check(先 build 其全部依赖,避免 TS2307 假红)→ 绿
apps/console/src/pages/system/AiPendingActionsPage.tsx只渲染 AiPendingActionsInboxAiPendingActionsInboxProps 不含任何 outcome 类型,零影响

本仓没有读 outcome.id 的外部消费者。仓外消费者若读了,那读的就是 undefined,现在会在编译期显形 —— changeset 正文写了替代取值处(ContinueContext.pendingActionId)。

守卫配套

src/__tests__/spec-symbol-batch6.test.ts 的既有范式(Assert< Equal< … > >,由本包 tsconfig.typetests.json 编译,已在 type-check 执行面内)扩了一个 describe:the decision outcomes ARE the spec wire responses,含

  • 派生同一性(_ApproveIsSpec / _RejectIsSpec)与 IsAny/IsUnknown 探针;
  • 三条漂移各自的钉子(id 键不存在 / status 不被 string 吸收 + 词表精确 / 无索引签名);
  • 两条反向钉:spec 仍导出被派生的两个名字(retire 即在编译期炸,而非派生悄悄腐烂);两个本地名字仍不与 spec 撞名 —— 这正是名字型守卫看不见它们的前提,前提变了这段注释就该重读。

node scripts/check-spec-symbol-derivation.mjs 通过(1210 文件 / 4862 个 spec 名,13 declared dialects,3 untriaged collisions in 1 package —— 与 main 同数,本 PR 未新增也未消除该计数:两个本地名字不撞名,守卫按设计不计)。

验证

$ pnpm vitest run packages/plugin-chatbot --maxWorkers=2
Test Files 17 passed (17)
Tests 258 passed (258)
$ pnpm --filter @object-ui/plugin-chatbot type-check # tsc --noEmit && tsc -p tsconfig.typetests.json
(无输出,exit 0)
$ pnpm --filter @object-ui/app-shell type-check
(无输出,exit 0)
$ pnpm --filter @object-ui/plugin-chatbot lint
✖ 106 problems (0 errors, 106 warnings) # 全部为既有 warning,0 error
$ node scripts/check-spec-symbol-derivation.mjs
✅ spec symbol derivation: 1210 files scanned against 4862 spec export names; ...
$ node scripts/check-changeset-no-major.mjs
✅ No changeset declares a `major` bump.
$ node scripts/check-control-bytes.mjs
✅ check-control-bytes: OK (scanned 3718 tracked text file(s); skipped 85 binary).
$ pnpm vitest run scripts/__tests__/check-changeset-no-major.test.ts scripts/__tests__/check-changeset-presence.test.ts
Test Files 2 passed (2) Tests 38 passed (38)

新增测试(useHitlInChat.test.tsx,4 条)与 fixture 处置:

  • 三个 approve mock 重新拼写为真正的 approve wire(去掉 id)—— 顺带证明 [HITL pa_42] 提示语里的 id 来自 message 索引而非 payload,这也解释了那条假承诺为何能长期无人察觉;reject mock 保留 id(按 spec 它就在那儿)。
  • hands onDecided the approve payload verbatim — which carries no id:钉住 approve 侧运行期确实没有 id
  • hands onDecided the reject payload verbatim — where id IS the wire:镜像。
  • still synthesizes the locally-built failure envelope, id included, on a non-2xx:钉住行为零变更。
  • keeps treating an unrecognised status as a success chip, without continuing:钉住刻意保留的 else 行为(含"不继续对话"这一被更正的事实)。

changeset 档位

minor(@object-ui/plugin-chatbot)。依据 AGENTS.md §版本号策略:固定版本组的 major 跟随 @objectstack,objectui 自身的破坏性变更一律标 minor,在正文里写清 breaking 语义;scripts/check-changeset-no-major.mjs 机械强制。#3220 当时标了 major,但那正是该守卫因之诞生的四张单之一(17.x 期间会把 39 个包发成 18.0.0),所以此处不沿用它的档位,沿用的是它的处置范式。changeset 正文点名了收紧本身、以及 outcome.id 读法的替代取值处。

关联


Generated by Claude Code

… 归位到 reject 一侧 (#3783)
`usePendingActions.ts` 里这两个类型是 spec approve/reject 响应的手写镜像,与
#3220 从同一文件清掉的 `PendingActionRow`/`PendingActionStatus` 同一失败类;
不同的是它们穿的是**本地名字**,所以 `check-spec-symbol-derivation.mjs`(按
spec 导出名被占用触发)对它们完全没有抓手 —— 换名手抄对名字型守卫天生隐形。
两者现在 re-export spec 的决策响应(`@objectstack/spec/api` 的
`ApproveAiPendingActionResponse` / `RejectAiPendingActionResponse`,也正是
`@objectstack/client` 的 `ai.pendingActions.approve()/.reject()` 用来标注返回值
的同一批 schema)。公开导出名不变,形状变三处:
- `ApproveOutcome` 不再声明 `id`。approve 响应从来不带 `id`,`id` 在 reject 侧。
这是唯一一条不休眠的漂移:公开回调 `onDecided` 编译期承诺 `id: string`,运行期
给的是 `undefined`,编译器一声不响;
- `status` 闭合:`'executed' | 'failed' | string` 与 `'rejected' | string` 都只是
`string`(与 `string` 的联合吸收字面量),现为 `'executed' | 'failed'` 与
`'rejected'`;
- 去掉 `[k: string]: unknown`(objectstack#4075 机制:索引签名让任何结构比较恒答
"一致",给旧类型补 parity 测试也会从第一天就是绿的)。
**运行期行为零变更**,包括两处刻意保留的:非 2xx 时本地虚构的失败信封仍带 `id`
(它不是 wire 响应,而是本地通知),以及 `decide()` 对 spec 词表之外的 status 仍
渲染成功 chip —— 后者的类型压力为零,因为 `status` 是从未解析的
`Record<string, unknown>` 上读出的 `string`,闭合枚举施加不了穷尽性检查。两者都新
补了测试钉住。`useHitlInChat` 剩下的消费侧容忍(该失败信封的契约、status 兜底
默认、未知 status 当成功)记入 #3790 交 maintainer 裁决。
守卫配套:`spec-symbol-batch6.test.ts` 补 `Assert<Equal<…>>` 钉子(该文件已进
`tsconfig.typetests.json` 执行面),另加两条反向钉 —— spec 仍导出被派生的两个名字、
两个本地名字仍不与 spec 撞名(后者正是名字型守卫看不见它们的前提)。
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 8, 2026 4:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-BdI7fVmU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.20KB106.20KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.85KB3.18KB
data-objectstack (index.js)139.51KB35.97KB
fields (index.js)230.82KB56.70KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)117.06KB30.24KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.99KB57.58KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.71KB49.68KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

git 实物核验(5cc94a895):净 diff 5 文件;派生按 #3220 同文件范式(spec 类型 import + 别名 re-export,公开名 ApproveOutcome/RejectOutcome 不变),三处漂移全消。行为零变更的论证质量高于要求:类型层压力实测为零(status 经 as string 不受穷尽性检查),两处刻意容忍(非 2xx 本地失败信封仍带 id、未知 status 仍成功 chip)各补测试钉住而非顺手改掉 —— 行为边界执行到位。

采纳的核验更正:issue 正文「else 兜底继续对话」被 dev 证伪 —— buildContinuationPrompt 对未知 status 返回 undefined,continueConversation 不被调用,后果仅是乐观 chip,已用测试钉住这条被更正的事实。sabotage 8 条 TS2344 红点对位、_RejectNoIndexSignature 在 sabotage 下仍绿的诚实标注(钉「以后别加」非「当时有」)、下游 dist 探针改前零错/改后 TS2339 —— 证据链完整。

changeset minor 依据成立:AGENTS.md fixed-group 策略 + check-changeset-no-major 机械强制,沿用 #3220 的处置范式而非其档位(那正是该守卫因之诞生的单子)—— 判断正确。顺手订正的第四处 prose 漂移(approve 失败 HTTP 500 之说与 spec 相反)属同族消灭面,不算越界。19 项 CI 全部完成 0 失败(PM 独立复核)。衍生 #3790(三处消费侧容忍的 A/B/C)为 finding 归分诊席,不回抛本单。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 16:46
@yinlianghui
yinlianghui added this pull request to the merge queueAug 8, 2026
Merged via the queue into main with commit d9ce385Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3783-hitl-outcome-derive branch August 8, 2026 16:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@yinlianghui@claude