@changfenhuang/dsh-genui 0.11.3 → 0.12.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +21 -6
  3. package/README.zh-CN.md +21 -6
  4. package/SKILL.md +18 -6
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +10 -9
  7. package/lib/client.js +31 -33
  8. package/lib/index.js +1060 -371
  9. package/lib/types/client/blocks/forms.d.ts +5 -6
  10. package/lib/types/client/blocks/state.d.ts +2 -11
  11. package/lib/types/client/dom-fence.d.ts +2 -1
  12. package/lib/types/client/file-address.d.ts +4 -0
  13. package/lib/types/client/file-link-context.d.ts +41 -0
  14. package/lib/types/client/file-link.d.ts +17 -0
  15. package/lib/types/client/i18n/en.d.ts +5 -1
  16. package/lib/types/client/inline.d.ts +1 -1
  17. package/lib/types/client/mermaid-colors.d.ts +2 -0
  18. package/lib/types/client/panel.d.ts +6 -1
  19. package/lib/types/client/spec.d.ts +8 -11
  20. package/lib/types/client/submission-registry.d.ts +51 -0
  21. package/lib/types/client/table-details.d.ts +20 -0
  22. package/lib/types/client/walk-spec.d.ts +3 -0
  23. package/lib/types/plugin/fence-feedback.d.ts +55 -12
  24. package/lib/types/plugin/index.d.ts +7 -3
  25. package/lib/types/shared/fence-repair.d.ts +19 -7
  26. package/package.json +1 -12
  27. package/src/client/GenuiBlock.module.css +100 -4
  28. package/src/client/GenuiBlock.tsx +7 -14
  29. package/src/client/artifact/ArtifactExport.module.css +34 -3
  30. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  31. package/src/client/blocks/charts.tsx +63 -14
  32. package/src/client/blocks/forms.tsx +26 -48
  33. package/src/client/blocks/render-node.tsx +11 -8
  34. package/src/client/blocks/state.ts +2 -12
  35. package/src/client/dom-fence.tsx +190 -50
  36. package/src/client/file-address.ts +37 -0
  37. package/src/client/file-link-context.tsx +114 -0
  38. package/src/client/file-link.ts +46 -0
  39. package/src/client/genui-runtime/diagnostics.ts +20 -0
  40. package/src/client/genui-runtime/normalize.ts +28 -9
  41. package/src/client/guard.ts +98 -153
  42. package/src/client/i18n/en.ts +5 -1
  43. package/src/client/i18n/zh.ts +5 -1
  44. package/src/client/index.tsx +23 -5
  45. package/src/client/inline.ts +28 -10
  46. package/src/client/mermaid-colors.ts +39 -0
  47. package/src/client/mermaid-core.ts +7 -3
  48. package/src/client/panel.tsx +18 -5
  49. package/src/client/spec.ts +8 -11
  50. package/src/client/submission-registry.ts +150 -0
  51. package/src/client/table-details.ts +151 -0
  52. package/src/client/walk-spec.ts +43 -0
  53. package/src/plugin/fence-feedback.ts +384 -53
  54. package/src/plugin/index.ts +20 -9
  55. package/src/plugin/tool.ts +8 -2
  56. package/src/shared/fence-repair.ts +345 -128
  57. package/tsdown.config.ts +3 -7
  58. package/lib/invariant.js +0 -19
  59. package/lib/types/plugin/invariant.d.ts +0 -15
  60. package/src/plugin/invariant.ts +0 -30
@@ -0,0 +1,43 @@
1
+ import type { GenuiNode, GenuiSpec } from './spec.ts'
2
+ import { GENUI_LIMITS } from './genui-runtime/index.ts'
3
+ import { isTableDetailReachable, tableRowsForDetails } from './table-details.ts'
4
+
5
+ /** 遍历完整 GenUI 组件树,并提供每个组件在 spec 中的路径。 */
6
+ export function walkGenuiNodes(spec: GenuiSpec, visitor: (node: GenuiNode, path: string) => void): void {
7
+ const walk = (nodes: GenuiNode[], path: string, depth: number): void => {
8
+ if (depth > GENUI_LIMITS.maxDepth) return
9
+ nodes.forEach((node, index) => visit(node, `${path}[${index}]`, depth))
10
+ }
11
+ const visit = (node: GenuiNode, at: string, depth: number): void => {
12
+ if (depth > GENUI_LIMITS.maxDepth) return
13
+ visitor(node, at)
14
+ if (depth >= GENUI_LIMITS.maxDepth) return
15
+ switch (node.type) {
16
+ case 'row':
17
+ case 'col':
18
+ case 'grid':
19
+ case 'card':
20
+ walk(node.items, `${at}.items`, depth + 1)
21
+ break
22
+ case 'tabs':
23
+ node.tabs.forEach((tab, tabIndex) => walk(tab.items, `${at}.tabs[${tabIndex}].items`, depth + 1))
24
+ break
25
+ case 'accordion':
26
+ node.items.forEach((item, itemIndex) => walk(item.items, `${at}.items[${itemIndex}].items`, depth + 1))
27
+ break
28
+ case 'list':
29
+ node.items.forEach((item, itemIndex) => {
30
+ if (item !== null && typeof item === 'object' && 'type' in item) visit(item, `${at}.items[${itemIndex}]`, depth + 1)
31
+ })
32
+ break
33
+ case 'table':
34
+ node.details?.slice(0, tableRowsForDetails(node).length).forEach((detail, rowIndex) => {
35
+ if (detail !== null && isTableDetailReachable(node, rowIndex)) {
36
+ walk(detail, `${at}.details[${rowIndex}]`, depth + 1)
37
+ }
38
+ })
39
+ break
40
+ }
41
+ }
42
+ walk(spec.items, 'items', 0)
43
+ }
@@ -8,8 +8,10 @@
8
8
  *
9
9
  * The loop is deliberately narrow, matching the contract agreed on the issue:
10
10
  * - **默认开启。** 插件配置中的 `fenceFeedback: false` 可以关闭回合转向。
11
- * - **Bounded.** At most one correction per turn AND at most one per fence
12
- * body per process, so a correction that is itself wrong cannot loop.
11
+ * - **修正上限。** 每个 turn 最多发送两条 correction;render failure 和 delivery reminder 共用上限,
12
+ * 每个 fence body 在当前回合中最多修正一次,新回合可重新修正。
13
+ * - **仅含 reasoning 的恢复。** 已验证的 GenUI 回合若只以 reasoning block 结束,使用宿主的
14
+ * `EMPTY_RESPONSE` retry policy。
13
15
  * - **Never for subagents.** A child session's fence belongs to a parent reply.
14
16
  * - **Exact fence matching.** Only an info string of exactly `dsh-ui` opens a
15
17
  * fence, so ` ```dsh-ui-dark `, indented prose, or a mention of the name is
@@ -25,8 +27,8 @@
25
27
 
26
28
  import type { Context } from '@deepseek-ai/cordis'
27
29
  import type {} from '@deepseek-ai/dsh-agent'
28
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
29
- import type { UserMessage } from '@deepseek-ai/dsh-session'
30
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
31
+ import type { Session, SessionEvent, SessionStore, UserMessage } from '@deepseek-ai/dsh-session'
30
32
  import { createHash, randomUUID } from 'node:crypto'
31
33
  import { droppedNodeFailure } from './genui-diagnostic.ts'
32
34
  import { resolveFence } from '../shared/fence-resolve.ts'
@@ -38,6 +40,12 @@ export const FEEDBACK_SOURCE_KIND = `plugin:${FEEDBACK_PLUGIN_NAME}` as const
38
40
 
39
41
  /** Marker prefix inside the correction text: `[genui-fence-repair #<fingerprint>]`. */
40
42
  const MARKER_PREFIX = '[genui-fence-repair #'
43
+ /**
44
+ * 同一个 turn 内 fence-feedback correction 的共享上限。
45
+ * Render failure 和 nothing-delivered reminder 共用该 budget;各自的 ledger 独立阻止重复修正。
46
+ */
47
+ export const MAX_CORRECTIONS_PER_TURN = 2
48
+
41
49
  /** Marker prefix written by older plugin versions. */
42
50
  const LEGACY_MARKER_PREFIX = '[genui 自修 #'
43
51
 
@@ -115,9 +123,10 @@ export function fenceFailures(text: string): FenceFailure[] {
115
123
 
116
124
  /** `null` when this fence renders; otherwise the reason it does not. */
117
125
  function fenceFailureDetail(fence: ExtractedFence): string | null {
118
- if (!fence.closed) return 'error=unterminated_fence\nrequired=closing_fence'
119
126
  const resolution = resolveFence(fence.raw, { settled: true })
120
127
  if (resolution.spec !== null) return null
128
+ // 缺少 Markdown 收尾不等于渲染失败;先复用 renderer 判断,避免把已显示的卡片重发(#288)。
129
+ if (!fence.closed) return 'error=unterminated_fence\nrequired=closing_fence'
121
130
  if (resolution.processed !== null) {
122
131
  const dropped = droppedNodeFailure(resolution.processed, resolution.value)
123
132
  return dropped?.join('\n')
@@ -177,21 +186,66 @@ export function createFeedbackMessage(text: string, sessionFormatVersion: number
177
186
 
178
187
  /** Per-session bookkeeping for the loop (process lifetime). */
179
188
  interface SessionFeedback {
180
- /** Latest assistant reply text of the current turn. */
189
+ /** Latest assistant reply text of the current turn (fences are read from here). */
181
190
  text: string
182
- /** Fence fingerprints already corrected in this process. */
183
- corrected: Set<string>
184
- /** Turn that already received its one correction. */
185
- lastCorrectedTurn: number | undefined
191
+ /**
192
+ * A `validate_dsh_ui` call happened in this turn. This is the FORMAL signal
193
+ * that the turn is GenUI-related — the loop never guesses intent by scanning
194
+ * the reasoning block.
195
+ */
196
+ validatedThisTurn: boolean
197
+ /** 子代理会话的输出不会直接交给用户。 */
198
+ isSubagent: boolean
199
+ /**
200
+ * The turn delivered something formal: a non-empty body text, or a
201
+ * `render_ui` call whose RESULT reports success. A failed render is not an
202
+ * answer, and a call whose result has not arrived yet proves nothing.
203
+ */
204
+ deliveredThisTurn: boolean
205
+ /**
206
+ * `render_ui` calls of this turn whose result has not arrived yet. The
207
+ * result — not the call — decides delivery, so an outstanding call blocks
208
+ * steering at the boundary instead of being guessed about.
209
+ */
210
+ pendingRenders: Set<string>
211
+ /** Fence fingerprints already corrected for a RENDER failure in the current turn. */
212
+ correctedSpec: Set<string>
213
+ /**
214
+ * Turns already given the "nothing was delivered" reminder. Kept separate from
215
+ * {@link correctedSpec}: a missing delivery must not consume the render-failure
216
+ * ledger (nor the other way round).
217
+ */
218
+ deliveryRemindedTurns: Set<number>
219
+ /** Corrections already steered in {@link correctionsTurn}. */
220
+ correctionsThisTurn: number
221
+ /** Turn {@link correctionsThisTurn} counts. */
222
+ correctionsTurn: number | undefined
223
+ /** 从 session event 中恢复的当前 turn。 */
224
+ currentTurn: number | undefined
225
+ /** 已计入 correction budget 的 message ID。 */
226
+ accountedCorrectionMessageIds: Set<string>
186
227
  }
187
228
 
188
229
  /** What the pure planner needs to decide whether a correction may be sent. */
189
230
  export interface FenceFeedbackPlanInput {
231
+ /** Latest assistant reply text of the current turn. */
190
232
  readonly text: string
191
233
  readonly turn: number
192
- readonly lastCorrectedTurn: number | undefined
193
- readonly corrected: ReadonlySet<string>
234
+ /** Fence bodies already corrected for a render failure in the current turn. */
235
+ readonly correctedSpec: ReadonlySet<string>
236
+ /** Turns already given the delivery reminder. */
237
+ readonly deliveryRemindedTurns?: ReadonlySet<number> | undefined
238
+ /** A `validate_dsh_ui` call happened this turn (formal GenUI signal). */
239
+ readonly validatedThisTurn?: boolean | undefined
240
+ /** The turn already delivered a body or a successful `render_ui` result. */
241
+ readonly deliveredThisTurn?: boolean | undefined
194
242
  readonly aborted: boolean
243
+ /** Corrections already steered in this turn (shared hard cap). */
244
+ readonly correctionsThisTurn?: number | undefined
245
+ /** Turn {@link correctionsThisTurn} counts (stale counts are ignored). */
246
+ readonly correctionsTurn?: number | undefined
247
+ /** Whether the host has registered the `render_ui` tool. Defaults to enabled. */
248
+ readonly renderUiToolEnabled?: boolean | undefined
195
249
  }
196
250
 
197
251
  /** A correction the caller must account for before steering. */
@@ -199,26 +253,107 @@ export interface FenceFeedbackPlan {
199
253
  readonly text: string
200
254
  readonly fingerprints: readonly string[]
201
255
  readonly turn: number
256
+ /**
257
+ * `render` corrects a fence that failed to resolve; `delivery` reminds the
258
+ * model that the turn produced nothing formal. They share the per-turn budget
259
+ * but keep separate ledgers.
260
+ */
261
+ readonly kind: 'render' | 'delivery'
202
262
  }
203
263
 
204
264
  /**
205
- * Decide whether this turn boundary should steer a fence correction — the pure
206
- * core of the loop, so every bound (one per turn, one per fence, cancellation)
207
- * is testable without a host.
265
+ * Decide whether this turn boundary should steer a correction — the pure core of
266
+ * the loop, so every bound (per-turn cap, per-fence ledger, cancellation) is
267
+ * testable without a host.
208
268
  *
209
- * @param input - reply text, turn identity, and the session's accounting.
269
+ * The decision is driven by FORMAL events only: fences in the reply body, a
270
+ * `validate_dsh_ui` call, a delivered body text or a successful `render_ui`
271
+ * result. The reasoning block is
272
+ * never read here — a draft inside the thinking block is not proof that the model
273
+ * chose to deliver it, so it must not change any decision.
274
+ *
275
+ * @param input - reply text, turn identity, formal signals, and the accounting.
210
276
  * @returns the correction to send, or null when the loop must stay silent.
211
277
  */
212
278
  export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackPlan | null {
213
279
  if (input.aborted) return null
214
- if (input.lastCorrectedTurn === input.turn) return null
215
- if (input.text.trim() === '') return null
216
- const failures = fenceFailures(input.text).filter(failure => !input.corrected.has(failure.fingerprint))
217
- if (failures.length === 0) return null
280
+ const used = input.correctionsTurn === input.turn ? (input.correctionsThisTurn ?? 0) : 0
281
+ if (used >= MAX_CORRECTIONS_PER_TURN) return null
282
+ const failures = fenceFailures(input.text).filter(failure => !input.correctedSpec.has(failure.fingerprint))
283
+ if (failures.length > 0) {
284
+ return {
285
+ text: fenceCorrectionText(failures),
286
+ fingerprints: failures.map(failure => failure.fingerprint),
287
+ turn: input.turn,
288
+ kind: 'render',
289
+ }
290
+ }
291
+ // No un-corrected render failure. If the turn formally validated a spec and
292
+ // then delivered nothing, remind the model once for this turn.
293
+ if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null
294
+ if (input.deliveryRemindedTurns?.has(input.turn) === true) return null
218
295
  return {
219
- text: fenceCorrectionText(failures),
220
- fingerprints: failures.map(failure => failure.fingerprint),
296
+ text: missingBodyCorrectionText(input.turn, used + 1, input.renderUiToolEnabled ?? true),
297
+ fingerprints: [],
221
298
  turn: input.turn,
299
+ kind: 'delivery',
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Correction for a validated GenUI turn that reached the boundary without any
305
+ * formal delivery.
306
+ *
307
+ * @param turn - turn that reached the boundary.
308
+ * @param attempt - correction number within the shared turn budget.
309
+ * @param renderUiToolEnabled - whether the correction may recommend `render_ui`.
310
+ * @returns the message text to steer into the running turn.
311
+ */
312
+ export function missingBodyCorrectionText(turn: number, attempt = 1, renderUiToolEnabled = true): string {
313
+ const head = `${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`
314
+ const emphasis = attempt <= 1 ? '' : `(第 ${attempt} 次提醒)`
315
+ const renderUiInstruction = renderUiToolEnabled ? ',或明确调用 render_ui' : ''
316
+ return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏${renderUiInstruction}。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`
317
+ }
318
+
319
+ /**
320
+ * 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
321
+ *
322
+ * @param options - LLM stream waterfall 拦截的请求。
323
+ * @param source - 本次请求的 provider stream。
324
+ * @returns 原始 stream;仅符合条件的终止 stop 会被改写。
325
+ */
326
+ async function* retryReasoningOnlyGenuiStream(
327
+ options: GenerateOptions,
328
+ source: AsyncIterable<StreamChunk>,
329
+ ): AsyncGenerator<StreamChunk> {
330
+ const { EMPTY_RESPONSE_CODE, isAgentLoopRequest } = await import('@deepseek-ai/dsh-llm')
331
+ if (!isAgentLoopRequest(options)) {
332
+ yield* source
333
+ return
334
+ }
335
+
336
+ let hasReasoningBlock = false
337
+ let hasOtherBlock = false
338
+ for await (const chunk of source) {
339
+ if (chunk.type === 'block-end') {
340
+ if (chunk.block.type === 'reasoning') hasReasoningBlock = true
341
+ else hasOtherBlock = true
342
+ }
343
+ if (chunk.type === 'finish' && chunk.reason.kind === 'stop' && hasReasoningBlock && !hasOtherBlock) {
344
+ yield {
345
+ type: 'finish',
346
+ reason: {
347
+ kind: 'error',
348
+ failure: {
349
+ message: 'GenUI turn completed with reasoning only and no deliverable response',
350
+ code: EMPTY_RESPONSE_CODE,
351
+ },
352
+ },
353
+ }
354
+ } else {
355
+ yield chunk
356
+ }
222
357
  }
223
358
  }
224
359
 
@@ -235,9 +370,77 @@ function textOfContent(content: unknown): string {
235
370
  .join('\n')
236
371
  }
237
372
 
238
- /** Fingerprints this loop already recorded inside a steered correction. */
239
- function markersIn(text: string): string[] {
240
- const out: string[] = []
373
+ type RenderResultStatus = 'rendered' | 'invalid'
374
+
375
+ /** 读取 render_ui result protocol 中明确返回的 status。 */
376
+ function renderResultStatus(content: unknown): RenderResultStatus | undefined {
377
+ const lines = textOfContent(content).split(/\r?\n/u)
378
+ if (lines[0]?.trim() !== '[genui-render]') return undefined
379
+ for (const line of lines.slice(1)) {
380
+ if (line === 'status=rendered') return 'rendered'
381
+ if (line === 'status=invalid') return 'invalid'
382
+ }
383
+ return undefined
384
+ }
385
+
386
+ interface ObservedToolResult {
387
+ readonly callId: string
388
+ readonly isError: boolean
389
+ readonly content: unknown
390
+ }
391
+
392
+ /** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */
393
+ function observedToolResult(message: unknown): ObservedToolResult | null {
394
+ if (typeof message !== 'object' || message === null) return null
395
+ const record = message as { toolCallId?: unknown; isError?: unknown; content?: unknown }
396
+ if (typeof record.toolCallId === 'string') {
397
+ return { callId: record.toolCallId, isError: record.isError === true, content: record.content }
398
+ }
399
+ if (!Array.isArray(record.content)) return null
400
+ const block = record.content.find(part => typeof part === 'object' && part !== null
401
+ && (part as { type?: unknown }).type === 'tool-result') as {
402
+ toolCallId?: unknown
403
+ isError?: unknown
404
+ content?: unknown
405
+ } | undefined
406
+ if (typeof block?.toolCallId !== 'string') return null
407
+ return { callId: block.toolCallId, isError: block.isError === true, content: block.content }
408
+ }
409
+
410
+ /** Tool whose SUCCESSFUL result is a formal delivery. */
411
+ const DELIVERY_TOOL = 'render_ui'
412
+
413
+ /**
414
+ * Whether one assistant message delivered a non-empty body text.
415
+ *
416
+ * Deliberately narrow: a non-empty text block. A `render_ui` call is decided
417
+ * by its `tool/result`, not by the call appearing in a message — a call that
418
+ * failed (or whose result has not arrived) is not a delivery. Other tools
419
+ * (validate_dsh_ui, bash, …) may succeed without producing any answer, so
420
+ * they are NOT counted as delivery.
421
+ *
422
+ * @param content - assistant content blocks.
423
+ * @returns true when this message delivered a body text.
424
+ */
425
+ function deliveredBodyText(content: unknown): boolean {
426
+ if (!Array.isArray(content)) return false
427
+ return content.some(block => {
428
+ if (typeof block !== 'object' || block === null) return false
429
+ const record = block as { type?: unknown; text?: unknown }
430
+ return record.type === 'text' && typeof record.text === 'string' && record.text.trim() !== ''
431
+ })
432
+ }
433
+
434
+
435
+ interface FeedbackMarkers {
436
+ readonly renderFingerprints: string[]
437
+ readonly deliveryTurns: number[]
438
+ }
439
+
440
+ /** 分类当前与 legacy correction message 中的 marker。 */
441
+ function feedbackMarkersIn(text: string): FeedbackMarkers {
442
+ const renderFingerprints: string[] = []
443
+ const deliveryTurns: number[] = []
241
444
  let cursor = 0
242
445
  while (cursor < text.length) {
243
446
  const current = text.indexOf(MARKER_PREFIX, cursor)
@@ -248,10 +451,23 @@ function markersIn(text: string): string[] {
248
451
  const index = useLegacy ? legacy : current
249
452
  const end = text.indexOf(']', index + prefix.length)
250
453
  if (end < 0) break
251
- out.push(text.slice(index + prefix.length, end))
454
+ const marker = text.slice(index + prefix.length, end)
455
+ const turn = /^turn-(\d+)$/u.exec(marker)
456
+ if (turn !== null) deliveryTurns.push(Number(turn[1]))
457
+ else renderFingerprints.push(marker)
252
458
  cursor = end + 1
253
459
  }
254
- return out
460
+ return { renderFingerprints, deliveryTurns }
461
+ }
462
+
463
+ /** 恢复一条持久化 correction 消耗的 turn budget。 */
464
+ function accountReplayedCorrection(state: SessionFeedback, turn: number): void {
465
+ if (state.correctionsTurn !== turn) {
466
+ state.correctionsTurn = turn
467
+ state.correctionsThisTurn = 1
468
+ return
469
+ }
470
+ state.correctionsThisTurn = Math.min(MAX_CORRECTIONS_PER_TURN, state.correctionsThisTurn + 1)
255
471
  }
256
472
 
257
473
  /** Identify this plugin's source across current and migrated session shapes. */
@@ -261,18 +477,30 @@ function isFeedbackSource(source: { kind?: unknown; plugin?: unknown } | undefin
261
477
  }
262
478
 
263
479
  /**
264
- * 根据插件配置启用围栏反馈流程。
480
+ * 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
265
481
  *
266
- * @param ctx - the host context.
267
- * @param enabled - the plugin config flag; the loop is inert when false.
482
+ * @param ctx - 宿主 Context。
483
+ * @param enabled - 是否启用同回合围栏修正。
484
+ * @param renderUiToolEnabled - 是否已注册 `render_ui` 工具。
268
485
  */
269
- export function installFenceFeedback(ctx: Context, enabled: boolean): void {
270
- if (!enabled) return
486
+ export function installFenceFeedback(ctx: Context, enabled: boolean, renderUiToolEnabled = true): void {
271
487
  const sessions = new Map<string, SessionFeedback>()
272
488
  const stateOf = (sessionId: string): SessionFeedback => {
273
489
  let state = sessions.get(sessionId)
274
490
  if (state === undefined) {
275
- state = { text: '', corrected: new Set(), lastCorrectedTurn: undefined }
491
+ state = {
492
+ text: '',
493
+ validatedThisTurn: false,
494
+ isSubagent: false,
495
+ deliveredThisTurn: false,
496
+ pendingRenders: new Set(),
497
+ correctedSpec: new Set(),
498
+ deliveryRemindedTurns: new Set(),
499
+ correctionsThisTurn: 0,
500
+ correctionsTurn: undefined,
501
+ currentTurn: undefined,
502
+ accountedCorrectionMessageIds: new Set(),
503
+ }
276
504
  sessions.set(sessionId, state)
277
505
  }
278
506
  return state
@@ -282,53 +510,156 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
282
510
  sessions.delete(String(session.id))
283
511
  })
284
512
 
285
- ctx.on('session/event', (session, event: SessionEvent) => {
513
+ const resetTurnState = (state: SessionFeedback, turn: number | undefined): void => {
514
+ state.text = ''
515
+ state.validatedThisTurn = false
516
+ state.deliveredThisTurn = false
517
+ state.pendingRenders.clear()
518
+ state.currentTurn = turn
519
+ }
520
+
521
+ const observeEvent = (session: Session, event: SessionEvent): void => {
286
522
  const sessionId = String(session.id)
523
+ if (event.type === 'turn/start') {
524
+ // The formal turn boundary: whatever happened in the previous turn is
525
+ // settled and this turn starts clean. The `user/message` reset below is
526
+ // only a fallback for direct prompts.
527
+ const state = stateOf(sessionId)
528
+ state.isSubagent = session.header.parentSession !== undefined
529
+ // 去重只约束当前回合,新一轮仍可修正相同错误。
530
+ state.correctedSpec.clear()
531
+ resetTurnState(state, (event.data as { turn?: number }).turn)
532
+ return
533
+ }
287
534
  if (event.type === 'assistant/message') {
288
- const text = textOfContent((event.data as { message?: { content?: unknown } }).message?.content)
289
- if (extractDshUiFences(text).length === 0) {
290
- const state = sessions.get(sessionId)
291
- if (state !== undefined) state.text = ''
292
- return
535
+ const content = (event.data as { message?: { content?: unknown } }).message?.content
536
+ const text = textOfContent(content)
537
+ const state = stateOf(sessionId)
538
+ state.isSubagent = session.header.parentSession !== undefined
539
+ // Fences are read from the BODY only: a draft in the reasoning block is not
540
+ // a delivery and must not become one.
541
+ state.text = extractDshUiFences(text).length > 0 ? text : ''
542
+ // Delivery is sticky for the turn: once a body text exists, the turn has
543
+ // answered and must never be corrected into publishing again. A
544
+ // `render_ui` call in the content proves nothing here — its result does.
545
+ state.deliveredThisTurn = state.deliveredThisTurn || deliveredBodyText(content)
546
+ return
547
+ }
548
+ if (event.type === 'tool/call') {
549
+ const data = event.data as { name?: unknown; callId?: unknown }
550
+ const state = stateOf(sessionId)
551
+ state.isSubagent = session.header.parentSession !== undefined
552
+ // validate_dsh_ui is the FORMAL signal that this turn is GenUI-related.
553
+ if (data.name === 'validate_dsh_ui') state.validatedThisTurn = true
554
+ // The result — not the call — decides whether render_ui delivered.
555
+ if (data.name === DELIVERY_TOOL && typeof data.callId === 'string') {
556
+ state.pendingRenders.add(data.callId)
557
+ }
558
+ return
559
+ }
560
+ if (event.type === 'tool/result') {
561
+ const data = event.data as { message?: unknown; error?: unknown }
562
+ const result = observedToolResult(data.message)
563
+ if (result === null) return
564
+ const state = stateOf(sessionId)
565
+ state.isSubagent = session.header.parentSession !== undefined
566
+ if (!state.pendingRenders.delete(result.callId)) return
567
+ if (data.error === undefined && result.isError !== true && renderResultStatus(result.content) === 'rendered') {
568
+ state.deliveredThisTurn = true
293
569
  }
294
- stateOf(sessionId).text = text
295
570
  return
296
571
  }
297
572
  if (event.type !== 'user/message') return
298
- const data = event.data as { content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
573
+ const data = event.data as { id?: unknown; content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
299
574
  if (isFeedbackSource(data.source)) {
300
- // Our own correction (re-observed after a plugin reload): adopt its
301
- // fingerprints so a second boundary cannot repeat it.
302
- const fingerprints = markersIn(textOfContent(data.content))
303
- if (fingerprints.length === 0) return
575
+ const markers = feedbackMarkersIn(textOfContent(data.content))
576
+ if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return
304
577
  const state = stateOf(sessionId)
305
- for (const fingerprint of fingerprints) state.corrected.add(fingerprint)
578
+ state.isSubagent = session.header.parentSession !== undefined
579
+ for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint)
580
+ for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn)
581
+ if (typeof data.id === 'string' && !state.accountedCorrectionMessageIds.has(data.id)) {
582
+ state.accountedCorrectionMessageIds.add(data.id)
583
+ const turn = markers.deliveryTurns[0] ?? state.currentTurn
584
+ if (turn !== undefined) accountReplayedCorrection(state, turn)
585
+ }
306
586
  return
307
587
  }
308
- // A genuine user prompt starts a new turn: the previous reply is settled.
588
+ // A DIRECT human prompt starts a new turn: the previous reply is settled.
589
+ // Synthetic context that rides the same user surface — `agent.inject()`
590
+ // notices, team member messages — carries a different source kind and
591
+ // arrives MID-TURN: resetting here used to wipe the turn's validation and
592
+ // delivery state before the boundary could use it.
593
+ if (data.source?.kind !== 'user') return
309
594
  const state = sessions.get(sessionId)
310
- if (state !== undefined) state.text = ''
595
+ if (state !== undefined) {
596
+ state.isSubagent = session.header.parentSession !== undefined
597
+ // turn/start 先于用户消息;清掉正文状态时保留正式回合身份。
598
+ resetTurnState(state, state.currentTurn)
599
+ }
600
+ }
601
+
602
+ const restoreHistory = (session: Session): void => {
603
+ if (sessions.has(String(session.id))) return
604
+ for (const event of session.snapshotEvents()) observeEvent(session, event)
605
+ }
606
+ ctx.on('session/event', (session, event) => {
607
+ // 服务注入是异步的;首条实时事件也先接管历史,避免丢失旧纠错次数。
608
+ restoreHistory(session)
609
+ observeEvent(session, event)
311
610
  })
611
+ ctx.on('session/created', restoreHistory)
612
+ const restoreSessions = (sessionCtx: Context): void => {
613
+ const store = sessionCtx.reflect.get('sessions') as SessionStore | undefined
614
+ if (store === undefined) return
615
+ for (const session of store.list()) restoreHistory(session)
616
+ }
617
+ // 宿主不重播历史;安装时同步接管已有会话,异步注入负责晚到的服务。
618
+ restoreSessions(ctx)
619
+ ctx.inject(['sessions'], restoreSessions)
620
+
621
+ ctx.on('llm/stream', (options, next) => {
622
+ if (options.sessionId === undefined || options.purpose !== undefined) return next()
623
+ const state = sessions.get(String(options.sessionId))
624
+ if (state?.validatedThisTurn !== true || state.isSubagent) return next()
625
+ return retryReasoningOnlyGenuiStream(options, next())
626
+ }, { global: true })
312
627
 
313
628
  ctx.on('agent/turn-stopping', ({ agent, turn, signal }): void => {
629
+ if (!enabled) return
314
630
  // A child session's fence belongs to a parent reply, and an aborted turn is
315
631
  // on its way out: never steer into either.
316
632
  if (agent.session.header.parentSession !== undefined) return
317
633
  const state = sessions.get(String(agent.session.id))
318
634
  if (state === undefined) return
635
+ state.currentTurn = turn
636
+ // A render_ui result still outstanding could deliver (or fail) after this
637
+ // boundary fires: steering now would race the late result, so stay silent
638
+ // and let the next boundary decide on settled facts.
639
+ if (state.pendingRenders.size > 0) return
640
+ const usedThisTurn = state.correctionsTurn === turn ? state.correctionsThisTurn : 0
319
641
  const plan = planFenceFeedback({
320
642
  text: state.text,
321
643
  turn,
322
- lastCorrectedTurn: state.lastCorrectedTurn,
323
- corrected: state.corrected,
644
+ correctedSpec: state.correctedSpec,
645
+ deliveryRemindedTurns: state.deliveryRemindedTurns,
646
+ validatedThisTurn: state.validatedThisTurn,
647
+ deliveredThisTurn: state.deliveredThisTurn,
324
648
  aborted: signal.aborted,
649
+ correctionsThisTurn: usedThisTurn,
650
+ correctionsTurn: state.correctionsTurn,
651
+ renderUiToolEnabled,
325
652
  })
326
653
  if (plan === null) return
327
654
  // Account BEFORE sending: a re-entrant boundary must not deliver twice.
328
- for (const fingerprint of plan.fingerprints) state.corrected.add(fingerprint)
329
- state.lastCorrectedTurn = plan.turn
655
+ for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint)
656
+ if (plan.kind === 'delivery') state.deliveryRemindedTurns.add(plan.turn)
657
+ state.correctionsTurn = plan.turn
658
+ state.correctionsThisTurn = usedThisTurn + 1
330
659
  try {
331
- agent.steer(createFeedbackMessage(plan.text, agent.session.header.version))
660
+ const message = createFeedbackMessage(plan.text, agent.session.header.version)
661
+ state.accountedCorrectionMessageIds.add(message.id)
662
+ agent.steer(message)
332
663
  } catch (error) {
333
664
  ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`)
334
665
  }