@zhushanwen/pi-ask-user 7.1.3 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +16 -4
- package/README.md +2 -2
- package/package.json +4 -3
- package/src/__tests__/answer-codec.test.ts +2 -2
- package/src/__tests__/channel-handler.test.ts +1 -1
- package/src/__tests__/index.test.ts +1 -1
- package/src/__tests__/validate.test.ts +27 -0
- package/src/answer-codec.ts +1 -1
- package/src/channel-handler.ts +8 -7
- package/src/channel-registry-register.ts +3 -3
- package/src/index.ts +7 -6
- package/src/validate.ts +9 -1
package/ARCHITECTURE.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# ask-user — Architecture
|
|
2
2
|
|
|
3
|
+
> 功能分级:P0(依据见 [docs/FEATURE-PRIORITIES.md](../../../docs/FEATURE-PRIORITIES.md) §6 边界判例 #3——agent↔用户交互闭环的唯一通道,2026-09-12 用户裁决升 P0)。
|
|
4
|
+
|
|
3
5
|
Internals reference for maintainers. For the usage contract (what the tool does, when an agent should call it), see [README.md](./README.md). This document covers how the code is structured, the state machine, the defensive execute flow, and where each design invariant is enforced — so a change does not silently break an invariant.
|
|
4
6
|
|
|
5
|
-
Source: 10 files in `src/`, ~
|
|
7
|
+
Source: 10 files in `src/`, ~2085 lines total.
|
|
6
8
|
|
|
7
9
|
## File dependency graph
|
|
8
10
|
|
|
@@ -55,13 +57,23 @@ Source: 10 files in `src/`, ~1970 lines total.
|
|
|
55
57
|
|
|
56
58
|
`AnswerValue` (`types.ts`) is the single structured answer model — `{ selected: string[]; other: string | null }`. The old internal/proto double model is gone: proto `AskUserOption.value` equaled `label` (both were the same string), so the proto layer consumes the same single model (`Result.answers: Record<string, AnswerValue>`, key = question text).
|
|
57
59
|
|
|
58
|
-
Serialization happens **once, at the protocol boundary**: `encodeAnswer(value, { key, multiSelect })` in `answer-codec.ts` converts an `AnswerValue` into proto answers entries, byte-aligned with `@
|
|
60
|
+
Serialization happens **once, at the protocol boundary**: `encodeAnswer(value, { key, multiSelect })` in `answer-codec.ts` converts an `AnswerValue` into proto answers entries, byte-aligned with `@taiji/extension-protocol` helpers' decode contract (`getAskUserAnswer` / `getAskUserOther`):
|
|
59
61
|
|
|
60
62
|
- 单选:`answers[key] = selected[0]`
|
|
61
63
|
- 多选:`answers[key] = JSON.stringify(selected)`
|
|
62
64
|
- Other:`answers[`${key}__other`] = other`(仅在 other 非空时写入)
|
|
63
65
|
|
|
64
|
-
Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was deleted with the double model; there is no decode counterpart inside the extension. `channel-handler.ts` reads `AnswerValue` directly and calls `encodeAnswer` when forwarding to subagents.
|
|
66
|
+
Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was deleted with the double model; there is no decode counterpart inside the extension. `channel-handler.ts` reads `AnswerValue` directly and calls `encodeAnswer` when forwarding to subagents. (`encodeAnswer` is the only encode implementation **inside this extension**; the renderer frontend components cannot import extension packages and implement the encoding independently, aligned to the same decode contract.)
|
|
67
|
+
|
|
68
|
+
## Channel registry handshake (subagent passthrough)
|
|
69
|
+
|
|
70
|
+
`channel-handler.ts` + `channel-registry-register.ts` implement the subagent passthrough: the host-process ask-user extension registers an `"ask_user"` channel handler through a versioned `globalThis[Symbol.for]` slot handshake (`CHANNEL_HANDSHAKE_KEY`). The skeleton (versioned slot + pending/flush; this side never creates the registry instance) is deliberate and must not be simplified away — the M4 incident (a simplified self-made registry hijacking the canonical slot) is the anchor, see the header comment of `channel-registry-register.ts`. A second consumer of the same handshake *pattern* exists: `extensions/universal/permission/src/footer-provider.ts` (own slot key `FOOTER_HANDSHAKE_KEY`, same versioned-slot shape).
|
|
71
|
+
|
|
72
|
+
Registry outreach facts (ext-simplify-11, D4):
|
|
73
|
+
|
|
74
|
+
- **Single registrant on this slot.** The `CHANNEL_HANDSHAKE_KEY` slot currently has exactly one registrant: ask-user (`"ask_user"`). The permission footer-provider handshake above uses its own separate slot — same pattern, different slot, not a registrant here.
|
|
75
|
+
- **`"gui_widget"` route reserved but vacant.** The engine-sdk ui-channels parsing (`parseChannel`, re-exported via `packages/subagent-core/src/execution/ui-channels.ts`) reserves a `"gui_widget"` channel name, but no package registers a handler for it; the core-side `ui-request-handler-factory` special-cases the unregistered `"gui_widget"` request to `{ack:true}` without forwarding. A future registrant needs no ask-user change — registration is keyed by channel name and orthogonal per extension.
|
|
76
|
+
- **Known gap, accepted (version-mismatch slot overwrite).** On `slot.version !== 1`, `readSlot` discards the slot and `registerAskUserChannelHandler` rebuilds it via `ensureSlot` — if a hypothetical v2 registry ever held the slot, this would drop its reference. Ruled not-worth-fixing (ext-simplify-11 finding 6): both sides pin `HANDSHAKE_VERSION = 1`, so the mismatch path is unreachable today; fixing it would be defensive code for an imagined v2. Re-review trigger: any PR that bumps either side's handshake version.
|
|
65
77
|
|
|
66
78
|
## `execute` defensive flow (6 steps)
|
|
67
79
|
|
|
@@ -164,7 +176,7 @@ Constants: `SPLIT_PANE_MIN_WIDTH = 84`, `SPLIT_PANE_LEFT_MIN = 32`, `SPLIT_PANE_
|
|
|
164
176
|
|
|
165
177
|
## Spec cross-reference
|
|
166
178
|
|
|
167
|
-
The original spec files (`.
|
|
179
|
+
The original spec files (`.taiji-harness/2026-06-15-ask-user/`) are no longer in the repo — this table is self-contained (FR = functional requirement from the original spec). Implementation anchors:
|
|
168
180
|
|
|
169
181
|
| Spec | Implemented in |
|
|
170
182
|
|------|----------------|
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The tool's primary caller is the LLM. This README covers both **how an agent sho
|
|
|
10
10
|
pi install npm:@zhushanwen/pi-ask-user
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
> **Dev-only symlink**: during local development you may symlink this package into `~/.pi/agent/extensions/` for debugging, but **never use the symlinked copy for daily work**. Local directory discovery has an `index.ts` fallback that masks a missing `pi` manifest field — npm-installed copies then silently fail to load. See
|
|
13
|
+
> **Dev-only symlink**: during local development you may symlink this package into `~/.pi/agent/extensions/` for debugging, but **never use the symlinked copy for daily work**. Local directory discovery has an `index.ts` fallback that masks a missing `pi` manifest field — npm-installed copies then silently fail to load. See `docs/extensions/extension-conventions.md` "扩展安装红线".
|
|
14
14
|
|
|
15
15
|
## When to use
|
|
16
16
|
|
|
@@ -138,7 +138,7 @@ All three are consistent and point the same direction. If you tune behavior, edi
|
|
|
138
138
|
|
|
139
139
|
## Spec reference
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
Design spec files are not kept in this repo. The FR cross-reference table in ARCHITECTURE.md is self-contained: each entry names the behavior and where it is implemented in this codebase.
|
|
142
142
|
|
|
143
143
|
## License
|
|
144
144
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "7.1
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview and an inline free-text editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
|
-
"
|
|
7
|
+
"taiji": {
|
|
8
8
|
"role": "universal"
|
|
9
9
|
},
|
|
10
10
|
"pi": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"ARCHITECTURE.md"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@
|
|
32
|
+
"@taiji/extension-protocol": "0.11.0",
|
|
33
|
+
"@zhushanwen/pi-ext-guards": "0.4.0",
|
|
33
34
|
"@zhushanwen/pi-extension-logger": "0.6.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// src/__tests__/answer-codec.test.ts
|
|
2
2
|
// encodeAnswer 单向序列化 round-trip 测试(TC-09)。
|
|
3
|
-
// 反向验证用 @
|
|
3
|
+
// 反向验证用 @taiji/extension-protocol 的解码 helper(getAskUserAnswer / getAskUserOther)
|
|
4
4
|
// ——该 helper 是 proto answers 格式的唯一解码 SSOT,encode 输出必须与其字节级对齐。
|
|
5
5
|
// m1 增量(TC-01):property-based describe 作为 5 个确定性用例的随机化超集补充。
|
|
6
|
-
import { getAskUserAnswer, getAskUserOther } from "@
|
|
6
|
+
import { getAskUserAnswer, getAskUserOther } from "@taiji/extension-protocol";
|
|
7
7
|
import fc from "fast-check";
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (single/multi/Other 三种答案形态,经 encodeAnswer 序列化)。
|
|
11
11
|
// - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
|
|
12
12
|
// - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
|
|
13
|
-
import type { AskUserQuestion } from "@
|
|
13
|
+
import type { AskUserQuestion } from "@taiji/extension-protocol";
|
|
14
14
|
import { describe, expect, it } from "vitest";
|
|
15
15
|
|
|
16
16
|
import { createAskUserChannelHandler } from "../channel-handler";
|
|
@@ -522,7 +522,7 @@ describe("execute — inline render (FR-3)", () => {
|
|
|
522
522
|
});
|
|
523
523
|
});
|
|
524
524
|
|
|
525
|
-
// ── RPC 模式(
|
|
525
|
+
// ── RPC 模式(taiji GUI 富交互协议)──────────────────
|
|
526
526
|
// hasUI=false + ui.select 存在 → 走 askUserInteract(select 通道 + ASK_USER_MARKER)。
|
|
527
527
|
// select 的返回值是前端 JSON.stringify 的 AskUserAnswers,index.ts 做格式转换。
|
|
528
528
|
describe("execute — RPC mode (askUserInteract via select channel)", () => {
|
|
@@ -166,6 +166,33 @@ describe("validateInput", () => {
|
|
|
166
166
|
expect(result).toContain("Correct:");
|
|
167
167
|
expect(result).toContain('"header"');
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
// V-20/M23: label 恰为保留字 "Other" → 拦截。Other 自由输入行由 extension 自动追加,
|
|
171
|
+
// LLM 自带会渲染出同名双行;文案含 reserved 与改名指引(rename + e.g. 示例)。
|
|
172
|
+
it("rejects option label 'Other' as reserved with rename guidance", () => {
|
|
173
|
+
const result = validateInput([
|
|
174
|
+
q({ options: [{ label: "Postgres" }, { label: "Other" }] }),
|
|
175
|
+
]);
|
|
176
|
+
expect(result).not.toBeNull();
|
|
177
|
+
expect(result).toContain("reserved");
|
|
178
|
+
expect(result).toContain("rename");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// V-21/D2: 空白变体 " Other " 与合成 Other 行视觉不可分辨(仅差不可见空白),同样拦截
|
|
182
|
+
it("rejects whitespace variants of the reserved label (' Other ')", () => {
|
|
183
|
+
const result = validateInput([
|
|
184
|
+
q({ options: [{ label: "Postgres" }, { label: " Other " }] }),
|
|
185
|
+
]);
|
|
186
|
+
expect(result).toContain("reserved");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// V-22/D2: 含 Other 子串的合法标签不被拦截(精确匹配不误杀)——catch-all 语义可经命名传达
|
|
190
|
+
it("allows labels containing 'Other' as a substring (e.g. 'Other database')", () => {
|
|
191
|
+
const result = validateInput([
|
|
192
|
+
q({ options: [{ label: "Postgres" }, { label: "Other database" }] }),
|
|
193
|
+
]);
|
|
194
|
+
expect(result).toBeNull();
|
|
195
|
+
});
|
|
169
196
|
});
|
|
170
197
|
|
|
171
198
|
// ── options 字符串「下沉」机制集成证明 ──────────────────
|
package/src/answer-codec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/answer-codec.ts
|
|
2
2
|
// AnswerValue → proto answers 条目的单向序列化(协议边界 SSOT)。
|
|
3
|
-
// 与 @
|
|
3
|
+
// 与 @taiji/extension-protocol helpers.ts 的解码契约字节级对齐:
|
|
4
4
|
// - 单选:answers[key] = selected[0]
|
|
5
5
|
// - 多选:answers[key] = JSON.stringify(selected)
|
|
6
6
|
// - Other:answers[`${key}__other`] = other
|
package/src/channel-handler.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI 渲染。
|
|
4
4
|
//
|
|
5
|
-
// 设计(关键决策):askUserInteract(@
|
|
5
|
+
// 设计(关键决策):askUserInteract(@taiji/extension-protocol)只在 RPC 模式可用
|
|
6
6
|
// (内部 isGuiCapable 检查 mode==='rpc',TUI 下抛错)。所以 handler 按 ctx.mode 分流:
|
|
7
7
|
// - RPC:转发器——调 askUserInteract(guiCtx, protoQuestions),复用 select 通道 +
|
|
8
8
|
// ASK_USER_MARKER 契约,主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// (key=header/question,单选=string,多选=JSON 数组,Other→__other),让子进程 decode 一致。
|
|
14
14
|
//
|
|
15
15
|
// handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
|
|
16
|
-
// 由子进程 askUserInteract 编码、packages/subagent-core 的 parseChannel(execution/ui-channels.ts)
|
|
16
|
+
// 由子进程 askUserInteract 编码、packages/subagent-core 的 parseChannel(execution/ui/ui-channels.ts)
|
|
17
17
|
// 解析 options[0] JSON 得到)。
|
|
18
18
|
|
|
19
19
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type AskUserAnswers,
|
|
22
22
|
askUserInteract,
|
|
23
23
|
type AskUserQuestion,
|
|
24
|
-
} from "@
|
|
24
|
+
} from "@taiji/extension-protocol";
|
|
25
25
|
|
|
26
26
|
import { AskUserComponent } from "./component";
|
|
27
27
|
import { encodeAnswer } from "./answer-codec";
|
|
@@ -29,13 +29,13 @@ import { type AnswerValue, type Option, type Question, type Result, type ThemeLi
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* channel handler 签名——与 packages/subagent-core 的 UiChannelRegistry.ChannelHandler 一致
|
|
32
|
-
*(execution/ui-channels.ts 定义,(req: unknown) => Promise<unknown>)。本文件不静态 import
|
|
32
|
+
*(execution/ui/ui-channels.ts 定义,(req: unknown) => Promise<unknown>)。本文件不静态 import
|
|
33
33
|
* packages/subagent-core(host 侧包,非本包依赖——两侧扩展经 globalThis slot 握手协作,
|
|
34
34
|
* 见 index.ts 工厂注释);handler 签名用本地等价类型,运行时结构兼容。
|
|
35
35
|
*/
|
|
36
36
|
export type ChannelHandler = (req: unknown) => Promise<unknown>;
|
|
37
37
|
|
|
38
|
-
/** handler 返回给 packages/subagent-core 的 UiResponse 形状(execution/dialog-queue.ts 定义)。
|
|
38
|
+
/** handler 返回给 packages/subagent-core 的 UiResponse 形状(execution/ui/dialog-queue.ts 定义)。
|
|
39
39
|
* - {value}: select 的回传值(子进程 JSON.parse(value) 得 answers)
|
|
40
40
|
* - {cancelled}: 用户取消 / 子进程 close / handler 抛错 */
|
|
41
41
|
type ChannelResponse = { value: string } | { cancelled: true };
|
|
@@ -71,13 +71,14 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
|
|
|
71
71
|
* 内部 Result.answers:key = question 全文,value = 结构化 AnswerValue
|
|
72
72
|
* (selected = option label 数组,other = Other 自由文本)。
|
|
73
73
|
*
|
|
74
|
-
* proto AskUserAnswers 契约(@
|
|
74
|
+
* proto AskUserAnswers 契约(@taiji/extension-protocol):
|
|
75
75
|
* - key = question.header ?? question 全文
|
|
76
76
|
* - 单选:value = 选中项 label string
|
|
77
77
|
* - 多选:value = JSON.stringify(选中项 label 数组)
|
|
78
78
|
* - Other 自由文本:单独 key `${header}__other`
|
|
79
79
|
*
|
|
80
|
-
* 序列化走 encodeAnswer(answer-codec.ts
|
|
80
|
+
* 序列化走 encodeAnswer(answer-codec.ts 是本扩展内的唯一 encode 实现,与协议包解码
|
|
81
|
+
* helper 对齐;renderer 前端组件无法 import extension 包,独立实现对齐同一解码契约)。
|
|
81
82
|
*/
|
|
82
83
|
function encodeTuiResultToProto(
|
|
83
84
|
protoQuestions: AskUserQuestion[],
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// 设计动机(PR #85 #M4 修复):原实现 ask-user 先 session_start 时会自建简化 Map-based
|
|
6
6
|
// registry 占据 canonical 槽位,劫持后续 getOrCreateChannelRegistry 拿到的实例
|
|
7
|
-
// (packages/subagent-core 的 createUiChannelRegistry(execution/ui-channels.ts)才是
|
|
7
|
+
// (packages/subagent-core 的 createUiChannelRegistry(execution/ui/ui-channels.ts)才是
|
|
8
8
|
// canonical——它带排队、dialog 队列等完整能力)。修复后的握手协议改为「带 version 的 slot」:
|
|
9
9
|
// - 承载 subagent-core 的 subagent-workflow 扩展 session_start 时往 slot 写
|
|
10
10
|
// {version, registry, pending:[]}(registry 由 subagent-core 创建)
|
|
@@ -23,7 +23,7 @@ const logger = getLogger("ask-user");
|
|
|
23
23
|
/**
|
|
24
24
|
* 进程级 channel registry 握手的 globalThis key(Symbol.for 跨模块共享)。
|
|
25
25
|
*
|
|
26
|
-
* ⚠️ 必须与 packages/subagent-core/src/execution/channel-registry-access.ts 的字面量
|
|
26
|
+
* ⚠️ 必须与 packages/subagent-core/src/execution/assembly/channel-registry-access.ts 的字面量
|
|
27
27
|
* 完全一致——两边用同一字符串确保拿到同一 slot 实例。改名必须两侧同步。
|
|
28
28
|
*/
|
|
29
29
|
export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
|
|
@@ -38,7 +38,7 @@ const ASK_USER_CHANNEL = "ask_user";
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* channel registry 的本地等价接口(与 packages/subagent-core 的 UiChannelRegistry 形状一致,
|
|
41
|
-
* execution/ui-channels.ts 定义)。本模块不静态 import packages/subagent-core(host 侧包,
|
|
41
|
+
* execution/ui/ui-channels.ts 定义)。本模块不静态 import packages/subagent-core(host 侧包,
|
|
42
42
|
* 非本包依赖——两侧经 globalThis slot 握手协作);运行时结构兼容即可。
|
|
43
43
|
*/
|
|
44
44
|
interface ChannelRegistry {
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
type AskUserQuestion,
|
|
9
9
|
getAskUserAnswer,
|
|
10
10
|
getAskUserOther,
|
|
11
|
-
} from "@
|
|
11
|
+
} from "@taiji/extension-protocol";
|
|
12
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
12
13
|
|
|
13
14
|
import { createAskUserChannelHandler } from "./channel-handler";
|
|
14
15
|
import { registerAskUserChannelHandler } from "./channel-registry-register";
|
|
@@ -175,7 +176,7 @@ function protoAnswersToResult(
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
/**
|
|
178
|
-
* RPC 模式(
|
|
179
|
+
* RPC 模式(taiji GUI)交互入口。
|
|
179
180
|
*
|
|
180
181
|
* 走 askUserInteract(select 通道 + ASK_USER_MARKER),前端 AskUserOverlay 渲染富交互 UI。
|
|
181
182
|
* 返回 Result(正常/取消),或抛错(select 异常 / 非 RPC 模式调用了此函数)。
|
|
@@ -211,7 +212,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
211
212
|
// 注册 ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
|
|
212
213
|
//
|
|
213
214
|
// 跨扩展握手协议(PR #85 #M4):通过 globalThis Symbol.for 约定 slot 形状
|
|
214
|
-
//(CHANNEL_HANDSHAKE_KEY,与 packages/subagent-core/src/execution/channel-registry-access.ts
|
|
215
|
+
//(CHANNEL_HANDSHAKE_KEY,与 packages/subagent-core/src/execution/assembly/channel-registry-access.ts
|
|
215
216
|
// 用同一字符串 key),不依赖 dynamic import npm 包名(两个扩展都通过
|
|
216
217
|
// ~/.pi/agent/extensions/ symlink 加载,互相之间无法用 npm 包名 import)。
|
|
217
218
|
//
|
|
@@ -220,7 +221,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
220
221
|
// 2. slot.registry 就绪(承载 packages/subagent-core 的 subagent-workflow 扩展先到)→ 直接调 registry.register
|
|
221
222
|
// 3. slot.registry 未就绪 → handler 入 pending,等 subagent-core flush
|
|
222
223
|
// ask-user 永不创建 registry 实例——canonical registry 仅 packages/subagent-core 创建
|
|
223
|
-
//(execution/ui-channels.ts 的 createUiChannelRegistry)。
|
|
224
|
+
//(execution/ui/ui-channels.ts 的 createUiChannelRegistry)。
|
|
224
225
|
pi.on("session_start", (_event, ctx) => {
|
|
225
226
|
registerAskUserChannelHandler(createAskUserChannelHandler(ctx));
|
|
226
227
|
});
|
|
@@ -294,7 +295,7 @@ Don't:
|
|
|
294
295
|
);
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
// 4. 交互执行:TUI 走 ctx.ui.custom,RPC(
|
|
298
|
+
// 4. 交互执行:TUI 走 ctx.ui.custom,RPC(taiji GUI)走 askUserInteract。
|
|
298
299
|
// 注意:hasUI 在 TUI 和 RPC 模式都为 true(dialog-capable),不能用于区分——
|
|
299
300
|
// 用 ctx.mode === 'rpc' 判定 GUI 渲染通道。
|
|
300
301
|
const useRpc = ctx.mode === "rpc";
|
|
@@ -307,7 +308,7 @@ Don't:
|
|
|
307
308
|
// RPC 通道不可用(真 headless / select 缺失)→ 禁用工具(spec FR-8)。
|
|
308
309
|
// TUI 分支不禁用——custom 抛错通常是组件临时故障,允许 LLM 重试。
|
|
309
310
|
// 禁用收尾先行,再 throw(W4):pi catch 后文案原样成为 toolResult content。
|
|
310
|
-
const message =
|
|
311
|
+
const message = toErrorMessage(err);
|
|
311
312
|
if (useRpc) disableAskUser(pi);
|
|
312
313
|
throw new Error(
|
|
313
314
|
useRpc
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/validate.ts
|
|
2
|
-
import { HEADER_MAX_CHARS, type InputQuestion, QUESTION_MAX_CHARS } from "./types";
|
|
2
|
+
import { HEADER_MAX_CHARS, type InputQuestion, OTHER_LABEL, QUESTION_MAX_CHARS } from "./types";
|
|
3
3
|
|
|
4
4
|
/** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
|
|
5
5
|
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
|
|
@@ -18,6 +18,8 @@ const ERROR_PREVIEW_CHARS = 20;
|
|
|
18
18
|
* - options 元素必须是 {label, description} 对象,不能是 string(弱模型高频误用)
|
|
19
19
|
* - question 文本长度上限与无控制字符(保证 answers key 有界、可预测)
|
|
20
20
|
* - question 文本在数组内唯一
|
|
21
|
+
* - option label 不得为保留字 Other(trim 后精确匹配)——Other 自由输入行由 extension
|
|
22
|
+
* 自动追加,LLM 自带会在渲染层产生同名双行(M23)
|
|
21
23
|
* - 同问题内 option label 唯一
|
|
22
24
|
* - 多问题(questions.length > 1)时每个 question 必须有非空 header
|
|
23
25
|
*
|
|
@@ -77,6 +79,12 @@ function checkOptionLabels(qt: string, options: InputQuestion["options"]): strin
|
|
|
77
79
|
if (opt.label.trim() === "") {
|
|
78
80
|
return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
|
|
79
81
|
}
|
|
82
|
+
// 保留字 Other(trim 口径:带空白变体与合成 Other 行视觉不可分辨,同样要拦)——
|
|
83
|
+
// 两条渲染路径都会无条件追加合成 Other 自由输入行,LLM 自带的 "Other" 选项会与它
|
|
84
|
+
// 同名并存:一行是普通选项、一行是自由输入,UI 自相矛盾且答案通道歧义(M23)。
|
|
85
|
+
if (opt.label.trim() === OTHER_LABEL) {
|
|
86
|
+
return `Option label "${OTHER_LABEL}" is reserved in question "${qt}" — the Other free-text option is added automatically. Remove it; to offer a catch-all choice, rely on the built-in Other, or rename the option (e.g. "Other database").`;
|
|
87
|
+
}
|
|
80
88
|
if (seenLabels.has(opt.label)) {
|
|
81
89
|
return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
|
|
82
90
|
}
|