diff --git a/CHANGELOG.md b/CHANGELOG.md index b16fb33..711e4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,14 @@ - DSH Desktop 0.11.0 将 `dsh-ui` 围栏呈现为 `…` 纯文本标签时,DOM 通道现可识别助手消息中的完整标签文本,渲染卡片并在卸载时恢复原文(#268)。 - `list` 项支持 `label`/`name` 标题字段与 `description`/`content`/`text`/`body`/`detail` 正文字段;只有正文的记录也能显示,无法渲染的记录字段会给出位置明确的警告(#265)。 - **GenUI reasoning-only 回合进入宿主重试**:当前回合调用 `validate_dsh_ui` 后,普通顶层会话若以 `stop` 结束且完整内容块只有 reasoning,插件会将终止结果改写为 `EMPTY_RESPONSE`,交由宿主现有重试策略处理;普通会话、子代理、辅助请求及含正文或工具调用的结果保持原行为。`fenceFeedback: false` 仍只关闭同回合围栏修正(#259)。 +- **完善 GenUI 交付判定与 fence-feedback 重载恢复**:`render_ui` 只有明确返回 `status=rendered` 才计为正式交付,正常返回的 `status=invalid` 会继续进入零交付修正;plugin reload 时主动读取历史会话,恢复当前 turn 的 render fingerprint、delivery turn 和已消费的 correction budget,避免重复发送 delivery reminder 或重新获得修正次数;render fingerprint 去重仅限当前 turn,新 turn 可重新修正相同错误。每 turn 最多两条 correction 的上限由 render failure 与 nothing-delivered reminder 共同使用(#263、#267)。 - **非英文宿主 locale 下围栏永远不渲染**:通用代码块的判定依赖硬编码标题白名单(`Code` / `Code block` / `代码块`),宿主 locale 为其它语言时(如俄语包的 «Код»),本地化标题被当成真实语言,同一份 `dsh-ui` 围栏既不走带标签路径也不进内容识别——界面永远停在代码块。现在判定反转:banner 标签命中**已知真实语言闭集**(语言 id 有限、由插件维护)才视为带语言,空标签与任何本地化通用词一律视为无语言、交给内容校验把关——同一份正文的渲染结果不再因宿主语言而不同(#258)。 -- **围栏自修:每轮两次机会 + 把正文原样交回**。真实会话里「第一次修正(围栏渲染失败)被模型答成又一个只含 reasoning 的回合」出现 4/4 次——单次预算让这些轮次直接空转;同时那条 reasoning-only 修正只描述要求、未给出目标,模型会把上一轮的思考逐字节重放。现在:每轮至多两次修正(第二次专用于该情形)、reasoning-only 修正直接**附上要输出的围栏正文**并要求「只输出这一段、不要先写思考」,渲染失败路径仍受「每个围栏至多一次」约束(#235、#240)。 -- **同一份围栏允许重试**:上面的「每轮两次」曾把第二次机会让「同一围栏只修一次」的账本挡掉——真实卡住的那轮里,模型收到修正后返回了**逐字节相同**的 reasoning(`len=1402 · md5=118fcc9af635`),指纹相同 ⇒ 判定为「已修过」⇒ 不再重试,`turn/end` 后正文仍为空。现在 reasoning-only 路径**不再受该账本约束**(正文本身没问题,问题只是模型不写进正文),只受每轮预算约束;第二次的文案也改为「这是第 2 次要求:你已经连续把回答写在思考里、正文留空」,以便打破原样重放。 -- **思考块里的围栏会被追回正文**:模型有时把 `dsh-ui` 围栏写进 reasoning(思考)块就结束本轮——正文里没有围栏,界面什么都没挂载,用户只看到一张折叠的思考卡(真实会话 1020 turns 里出现 5 次)。现在 `fence-feedback` 在 `agent/turn-stopping` 边界发现「正文无围栏、reasoning 里有完整围栏」时,借宿主的轮内转向把 `next=emit_fence_in_body` 送回同一轮,要求把同一份围栏原样写进回答正文;与渲染失败路径共用同一套上限(子代理不触发、可取消;每轮预算见下条)(#235)。 - **多行文本/代码的复制不再丢换行**:行内渲染曾把每个 `\n` 变成 `
`,而 `
` 对 `textContent` 与 `Selection.toString()` **零贡献**——用户选中表格单元格复制出来的是一整行,`python - <<'PY' … PY` 这类 heredoc 结构被毁、必须手工拼回。现在换行在 DOM 里保持为**真实换行符**(选中/复制原样),由容器的 `white-space` 负责呈现:代码形单元格 `.tdCode` 用 `pre-wrap`(换行 + 行首缩进都保留),散文单元格 `.tdMultiline` 用 `pre-line`(换行呈现、空格照常折叠),单行单元格仍是 `nowrap`;`calloutBody`/`liTitle`/`liDesc`/`kvValue`/`tlDesc`/`detailBody`/`accBody` 同步声明 `pre-line`,因为它们此前正是靠 `
` 硬断行(#233)。 - **多行表格单元格的缩进**:`.table td` 的 `nowrap`(表格的数据语气)会折叠单元格里的空白,模型写进单元格的代码缩进会消失;行内 `` `code` `` 同步改为 `pre-wrap`,不再吞掉自身空格(#233)。 - **「未转义引号 + 尾部杂字符」叠在一起时围栏不再整条失守**:模型常在 JSON 根值之后追加 `

` 或一句解释(真实会话 539 条围栏里 240 条有尾随 `

`),这类正文本来靠 `parsePartialGenuiSpec` 的平衡前缀就能渲染;但一旦**同时**还有字符串内未转义的半角引号,tier-1 因为「必须整体 parse 通过」而放弃、tier-2 同样放弃——两个缺陷叠加就把整条围栏变成代码块 + 红横幅。现在 tier-2 在整体 parse 失败时回退到**平衡前缀**(根值结束处)并采用它;tier-1 保持严格(它也在流式期运行,采用前缀可能发布半截正文)。真实语料 37 条不可渲染 → 22 条(救回 15 条,占失败数 40%)。 - SKILL.md 使用规则第 3 条补充:`}` 之后不要再写任何字符(常见错误是追加 `

` 或解释)。 - **通用代码块的内容识别接受标点级修复**:宿主会隐去它不认识的语言(高亮器不支持 `dsh-ui`),此时同一份围栏在 DOM 里是一个「代码块 / Code block / Code」标签的普通代码块;当 ChatSnapshot 的语言来源在那一行上不可用时,内容识别是唯一出路。此前它要求 `JSON.parse(raw)` **直接**通过,于是「只差一个未转义引号(tier-1 可修)」的围栏永远不会被接管——真实会话(seq 40530)里模型终于把围栏写进正文、正文经 tier-1 修 14 处后可正常渲染,界面却始终停在代码块。现在内容识别跑与带标签路径**相同的 tier-1 修复**,并且在整体 parse 失败时**裁到第一个平衡根值**(`trimToBalancedRoot`,只裁剪、不补全结构)——真实样本里模型把工具调用模板泄漏在 JSON 之后且围栏没闭合,正是这一种;结构级 tier-2 刻意不参与(兜底不该接管只是"长得像 JSON"的普通代码),canonical 规范 / 未知字段 / 归一化等价的检查保持不变。 -- **回合状态的两处漏补救**:① 失败的 `render_ui` 调用曾被算作「已交付」——只要本回合调用过 `render_ui`,回合边界就认定已回答、不再提醒,渲染失败的用户拿不到任何纠正。现在交付只由 `tool/result` 决定(无内部错误且结果块非 `isError` 才算成功),结果未到达时回合边界保持静默、不与晚到结果赛跑。② 同回合的合成上下文消息(`agent.inject()` 通知、成员消息等)会清掉本回合的验证/交付状态——它们走 `user/message` 但并非真实用户提示,回合边界检查时状态早已被清空。现在重置只发生在正式的 `turn/start` 边界与直接用户提示(`source.kind === 'user'`)上(#236 review 确认的两项)。 +- **回合状态的两处漏补救**:① `render_ui` 是否交付由 `tool/result` 决定,只有无内部错误、结果块非 `isError` 且协议明确返回 `status=rendered` 才算成功;结果未到达时回合边界保持静默,不与晚到结果赛跑。② 同回合的合成上下文消息(`agent.inject()` 通知、成员消息等)不会清掉本回合的验证/交付状态;重置只发生在正式的 `turn/start` 边界与直接用户提示(`source.kind === 'user'`)上(#236、#263 review)。 ## [0.11.3] - 2026-09-29 diff --git a/README.md b/README.md index 6959482..baec8bf 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ The following is the detailed capability reference. Every behavior is constraine - **Event loop**: buttons, checkboxes, radio buttons, switches, selects, inputs, textareas, submits, and quizzes send one event immediately per gesture; slider drags keep trailing-edge debounce, sending only the final value per slider, with different `id`s handled independently - **Tool channel**: the `render_ui` tool renders the same spec as a card in the tool row (deliverable-style UI goes through the tool, answer-style UI through the fence) - **Session panel**: a persistent dock above the composer; `render_ui` / `panel: true` fences update the same surface in place; `/panel` opens it from the client (`/panel ` customizes via the model, `/panel clear` clears); the top border is draggable to resize; `append: true` merges incrementally — same-named tabs append content, new tabs get added; the whole panel caps at 200 nodes / 200 appends, after which the model should send `replace` to rebuild -- **Fence auto-repair**: enabled by default; set `fenceFeedback: false` in this plugin's config (under the plugin entry's `config:` in your profile's cordis.patch.yml) to disable same-turn fence corrections. A validated GenUI turn that ends with reasoning only still becomes `EMPTY_RESPONSE` and uses the host's retry policy. When a reply's final dsh-ui fence fails to render, the plugin steers the SAME turn with the per-node diagnosis so the model can resend a fixed fence; at most two corrections per turn (the second is reserved for a first correction that was answered with another reasoning-only turn) and one per fence body, never in subagents, so it cannot loop. A turn whose body has no fence but whose reasoning composed one is corrected the same way, with the body handed back verbatim. +- **Fence auto-repair**: enabled by default; set `fenceFeedback: false` in this plugin's config (under the plugin entry's `config:` in your profile's cordis.patch.yml) to disable same-turn fence corrections. A validated GenUI turn that ends with reasoning only still becomes `EMPTY_RESPONSE` and uses the host's retry policy. When a reply's final dsh-ui fence fails to render, the plugin steers the SAME turn with the per-node diagnosis so the model can resend a fixed fence. At most two corrections are sent per turn, shared by render failures and validated turns with no formal delivery; each fence fingerprint is corrected once per turn, and a nothing-delivered reminder is sent once per turn. A new turn can correct the same failed fence again. Plugin reload restores the current turn's fence fingerprints and correction count, along with delivery reminders, from persisted correction messages. Corrections are never sent in subagent sessions. - **Self-healing & limits**: every fence passes a spec guard — bad nodes are silently dropped (the surviving siblings keep rendering: one bad component no longer degrades the whole fence), numbers clamped, strings truncated; the whole tree is capped at 200 nodes / 8 nesting levels; pathological specs never crash the UI - **Canonical component protocol**: native field aliases such as `card.label` → `title`, `table.data`/`table.items` → `rows`, `callout.kind`/`callout.desc` → `tone`/`content` (tone value `danger` → `error`), `steps.items` → `steps`, `keyvalue.items` → `pairs` (record `label` → `key`), and `file-tree.nodes` → `items` (record `label` → `name`, `type` defaults to `dir` when children exist) are normalized deterministically before validation and rendering. A root-level component array is adopted as `items`, and a double-encoded JSON string is decoded once. `validate_dsh_ui` reports these normalizations and warns about unknown native fields without blocking custom renderer nodes. - **Chart error self-healing**: mermaid failures auto-retry with repairs (strip backticks, quote Chinese/space labels, remove `
`) before degrading to source; a broken chart never hits the screen diff --git a/README.zh-CN.md b/README.zh-CN.md index b6486a9..35503e9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -179,7 +179,7 @@ dsh plugin --profile web add link:$PWD - **事件循环**:按钮、复选框、单选、开关、下拉、输入、文本域、提交和测验等离散交互按一次手势一次事件立即回传模型;`slider` 连续拖动保留尾沿防抖,同一滑块只回传最终值,不同 `id` 的滑块互不合并。 - **工具通道**:`render_ui` 工具把同一份 spec 渲染成工具行卡片(交付物型 UI 走工具、回答型 UI 走围栏) - **会话面板**:composer 上方常驻 dock,`render_ui` / `panel: true` 围栏原地更新同一块界面;`/panel` 命令客户端直开(`/panel <指令>` 转模型定制、`/panel clear` 清空);顶边框可拖拽调高;`append: true` 增量合并——同名标签页追加内容、新标签页新增;整面板默认最多 200 节点 / 200 条追加,达到上限后模型应发送 `replace` 重建 -- **围栏自修**:默认开启;在 profile 的 cordis.patch.yml 中本插件条目的 `config:` 下设置 `fenceFeedback: false` 可以关闭同回合围栏修正。已调用 `validate_dsh_ui` 的 GenUI 回合若只输出 reasoning 并以 `stop` 结束,仍会转换为 `EMPTY_RESPONSE`,交给宿主重试策略处理。回答最终的 dsh-ui 围栏无法渲染时,插件借宿主的轮内转向(steer)把逐节点诊断送回**同一轮**,模型重发修好的围栏;每轮至多两次(第二次专门用于「第一次修正被答成又一个只含思考的回合」)、每个围栏至多一次、子代理不触发,不会循环。正文没有围栏但 reasoning 里写好了成品的回合同样会被修正,并把那段正文原样交回。 +- **围栏自修**:默认开启;在 profile 的 cordis.patch.yml 中本插件条目的 `config:` 下设置 `fenceFeedback: false` 可以关闭同回合围栏修正。已调用 `validate_dsh_ui` 的 GenUI 回合若只输出 reasoning 并以 `stop` 结束,仍会转换为 `EMPTY_RESPONSE`,交给宿主重试策略处理。回答最终的 dsh-ui 围栏无法渲染时,插件借宿主的轮内转向(steer)把逐节点诊断送回**同一轮**,模型重发修好的围栏。每个 turn 最多发送两条 correction,渲染失败与“已验证但没有正式交付”共用该上限;同一 turn 内相同围栏 fingerprint 只修正一次,新 turn 可以重新修正相同错误;同一 turn 的零交付提醒只发送一次。plugin reload 后会从持久化的 correction message 恢复当前 turn 的围栏去重记录、已消费的 correction 次数以及零交付提醒记录。子代理回合不会收到围栏修正。 - **自愈与上限**:每个围栏过规格守卫——坏节点静默丢弃(同围栏其余组件照常渲染,单个坏组件不再拖垮整条围栏)、数值钳位、字符串截断,整树 ≤200 节点 / 8 层嵌套,病态 spec 不会拖垮界面 - **统一组件协议**:`card.label` → `title`、`table.data`/`table.items` → `rows`、`callout.kind`/`callout.desc` → `tone`/`content`(tone 值 `danger` → `error`)、`steps.items` → `steps`、`keyvalue.items` → `pairs`(记录内 `label` → `key`)、`file-tree.nodes` → `items`(记录内 `label` → `name`、有 children 时缺省 `dir`)等原生字段别名会在校验和渲染前确定性归一化;根级组件数组视为 `items`、双重编码的 JSON 字符串解一层;`validate_dsh_ui` 会报告归一化结果,并对原生组件未知字段给出警告,同时保持自定义 renderer 节点的透明兼容。 - **图错误自愈**:mermaid 渲染失败自动修复重试(剥反引号、引号化中文/空格标签、去 `
`),仍失败才降级源码;错误图永不直接上屏 diff --git a/src/plugin/fence-feedback.ts b/src/plugin/fence-feedback.ts index b2b80fa..f8ac6ce 100644 --- a/src/plugin/fence-feedback.ts +++ b/src/plugin/fence-feedback.ts @@ -8,14 +8,10 @@ * * The loop is deliberately narrow, matching the contract agreed on the issue: * - **默认开启。** 插件配置中的 `fenceFeedback: false` 可以关闭回合转向。 - * - **Bounded.** At most two corrections per turn (the second is reserved for - * the case where the first is answered with another reasoning-only turn) AND - * at most one per fence body per process, so a correction that is itself - * wrong cannot loop. - * - **Reasoning-only recovery.** A turn whose body carries no fence but whose - * reasoning block composed one gets a correction that hands the body back - * verbatim; one real session had five such turns (the model kept ending the - * turn with the fence only in its thinking). + * - **修正上限。** 每个 turn 最多发送两条 correction;render failure 和 delivery reminder 共用上限, + * 每个 fence body 在当前回合中最多修正一次,新回合可重新修正。 + * - **仅含 reasoning 的恢复。** 已验证的 GenUI 回合若只以 reasoning block 结束,使用宿主的 + * `EMPTY_RESPONSE` retry policy。 * - **Never for subagents.** A child session's fence belongs to a parent reply. * - **Exact fence matching.** Only an info string of exactly `dsh-ui` opens a * fence, so ` ```dsh-ui-dark `, indented prose, or a mention of the name is @@ -32,8 +28,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import type { UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionStore, UserMessage } from '@deepseek-ai/dsh-session' import { createHash, randomUUID } from 'node:crypto' import { droppedNodeFailure } from './genui-diagnostic.ts' import { resolveFence } from '../shared/fence-resolve.ts' @@ -46,10 +41,8 @@ export const FEEDBACK_SOURCE_KIND = `plugin:${FEEDBACK_PLUGIN_NAME}` as const /** Marker prefix inside the correction text: `[genui-fence-repair #]`. */ const MARKER_PREFIX = '[genui-fence-repair #' /** - * Corrections a single turn may receive. Two, not one: when the first (a fence - * that failed to RENDER) is answered with another reasoning-only turn, the - * second is the only chance to get it into the body. Bounded, and the per-fence - * fingerprint ledger still prevents any repeat for the same fence body. + * 同一个 turn 内 fence-feedback correction 的共享上限。 + * Render failure 和 nothing-delivered reminder 共用该 budget;各自的 ledger 独立阻止重复修正。 */ export const MAX_CORRECTIONS_PER_TURN = 2 @@ -214,7 +207,7 @@ interface SessionFeedback { * steering at the boundary instead of being guessed about. */ pendingRenders: Set - /** Fence fingerprints already corrected for a RENDER failure. */ + /** Fence fingerprints already corrected for a RENDER failure in the current turn. */ correctedSpec: Set /** * Turns already given the "nothing was delivered" reminder. Kept separate from @@ -226,6 +219,10 @@ interface SessionFeedback { correctionsThisTurn: number /** Turn {@link correctionsThisTurn} counts. */ correctionsTurn: number | undefined + /** 从 session event 中恢复的当前 turn。 */ + currentTurn: number | undefined + /** 已计入 correction budget 的 message ID。 */ + accountedCorrectionMessageIds: Set } /** What the pure planner needs to decide whether a correction may be sent. */ @@ -233,7 +230,7 @@ export interface FenceFeedbackPlanInput { /** Latest assistant reply text of the current turn. */ readonly text: string readonly turn: number - /** Fence bodies already corrected for a render failure. */ + /** Fence bodies already corrected for a render failure in the current turn. */ readonly correctedSpec: ReadonlySet /** Turns already given the delivery reminder. */ readonly deliveryRemindedTurns?: ReadonlySet | undefined @@ -296,16 +293,15 @@ export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackP } /** - * Correction for "the fence is in the reasoning block only". + * Correction for a validated GenUI turn that reached the boundary without any + * formal delivery. * - * @param fingerprint - fingerprint of that fence body. + * @param turn - turn that reached the boundary. + * @param attempt - correction number within the shared turn budget. * @returns the message text to steer into the running turn. */ export function missingBodyCorrectionText(turn: number, attempt = 1): string { 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` - // NEVER quote a draft: a fence in the reasoning block is not proof that the - // model chose to deliver it (there may be several candidates, or a later one - // may supersede it). The reminder only says "nothing has been delivered yet". const emphasis = attempt <= 1 ? '' : `(第 ${attempt} 次提醒)` return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏,或明确调用 render_ui。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n` } @@ -364,6 +360,43 @@ function textOfContent(content: unknown): string { .join('\n') } +type RenderResultStatus = 'rendered' | 'invalid' + +/** 读取 render_ui result protocol 中明确返回的 status。 */ +function renderResultStatus(content: unknown): RenderResultStatus | undefined { + const lines = textOfContent(content).split(/\r?\n/u) + if (lines[0]?.trim() !== '[genui-render]') return undefined + for (const line of lines.slice(1)) { + if (line === 'status=rendered') return 'rendered' + if (line === 'status=invalid') return 'invalid' + } + return undefined +} + +interface ObservedToolResult { + readonly callId: string + readonly isError: boolean + readonly content: unknown +} + +/** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */ +function observedToolResult(message: unknown): ObservedToolResult | null { + if (typeof message !== 'object' || message === null) return null + const record = message as { toolCallId?: unknown; isError?: unknown; content?: unknown } + if (typeof record.toolCallId === 'string') { + return { callId: record.toolCallId, isError: record.isError === true, content: record.content } + } + if (!Array.isArray(record.content)) return null + const block = record.content.find(part => typeof part === 'object' && part !== null + && (part as { type?: unknown }).type === 'tool-result') as { + toolCallId?: unknown + isError?: unknown + content?: unknown + } | undefined + if (typeof block?.toolCallId !== 'string') return null + return { callId: block.toolCallId, isError: block.isError === true, content: block.content } +} + /** Tool whose SUCCESSFUL result is a formal delivery. */ const DELIVERY_TOOL = 'render_ui' @@ -389,9 +422,15 @@ function deliveredBodyText(content: unknown): boolean { } -/** Fingerprints this loop already recorded inside a steered correction. */ -function markersIn(text: string): string[] { - const out: string[] = [] +interface FeedbackMarkers { + readonly renderFingerprints: string[] + readonly deliveryTurns: number[] +} + +/** 分类当前与 legacy correction message 中的 marker。 */ +function feedbackMarkersIn(text: string): FeedbackMarkers { + const renderFingerprints: string[] = [] + const deliveryTurns: number[] = [] let cursor = 0 while (cursor < text.length) { const current = text.indexOf(MARKER_PREFIX, cursor) @@ -402,10 +441,23 @@ function markersIn(text: string): string[] { const index = useLegacy ? legacy : current const end = text.indexOf(']', index + prefix.length) if (end < 0) break - out.push(text.slice(index + prefix.length, end)) + const marker = text.slice(index + prefix.length, end) + const turn = /^turn-(\d+)$/u.exec(marker) + if (turn !== null) deliveryTurns.push(Number(turn[1])) + else renderFingerprints.push(marker) cursor = end + 1 } - return out + return { renderFingerprints, deliveryTurns } +} + +/** 恢复一条持久化 correction 消耗的 turn budget。 */ +function accountReplayedCorrection(state: SessionFeedback, turn: number): void { + if (state.correctionsTurn !== turn) { + state.correctionsTurn = turn + state.correctionsThisTurn = 1 + return + } + state.correctionsThisTurn = Math.min(MAX_CORRECTIONS_PER_TURN, state.correctionsThisTurn + 1) } /** Identify this plugin's source across current and migrated session shapes. */ @@ -435,6 +487,8 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void { deliveryRemindedTurns: new Set(), correctionsThisTurn: 0, correctionsTurn: undefined, + currentTurn: undefined, + accountedCorrectionMessageIds: new Set(), } sessions.set(sessionId, state) } @@ -445,24 +499,25 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void { sessions.delete(String(session.id)) }) - const resetTurnState = (state: SessionFeedback): void => { + const resetTurnState = (state: SessionFeedback, turn: number | undefined): void => { state.text = '' state.validatedThisTurn = false state.deliveredThisTurn = false state.pendingRenders.clear() + state.currentTurn = turn } - ctx.on('session/event', (session, event: SessionEvent) => { + const observeEvent = (session: Session, event: SessionEvent): void => { const sessionId = String(session.id) if (event.type === 'turn/start') { // The formal turn boundary: whatever happened in the previous turn is // settled and this turn starts clean. The `user/message` reset below is // only a fallback for direct prompts. - const state = sessions.get(sessionId) - if (state !== undefined) { - state.isSubagent = session.header.parentSession !== undefined - resetTurnState(state) - } + const state = stateOf(sessionId) + state.isSubagent = session.header.parentSession !== undefined + // 去重只约束当前回合,新一轮仍可修正相同错误。 + state.correctedSpec.clear() + resetTurnState(state, (event.data as { turn?: number }).turn) return } if (event.type === 'assistant/message') { @@ -492,35 +547,31 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void { return } if (event.type === 'tool/result') { - const data = event.data as { - message?: { content?: unknown } - error?: unknown - } - const content = data.message?.content - const block = Array.isArray(content) - ? content.find(part => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'tool-result') as { toolCallId?: unknown; isError?: unknown } | undefined - : undefined - const callId = typeof block?.toolCallId === 'string' ? block.toolCallId : undefined - if (callId === undefined || block === undefined) return + const data = event.data as { message?: unknown; error?: unknown } + const result = observedToolResult(data.message) + if (result === null) return const state = stateOf(sessionId) state.isSubagent = session.header.parentSession !== undefined - if (!state.pendingRenders.delete(callId)) return - // Success = no internal failure identity AND the model-facing block is - // not an error. Anything else leaves the turn undelivered so the - // boundary can remind (a failed card is not an answer). - if (data.error === undefined && block.isError !== true) state.deliveredThisTurn = true + if (!state.pendingRenders.delete(result.callId)) return + if (data.error === undefined && result.isError !== true && renderResultStatus(result.content) === 'rendered') { + state.deliveredThisTurn = true + } return } if (event.type !== 'user/message') return - const data = event.data as { content?: unknown; source?: { kind?: unknown; plugin?: unknown } } + const data = event.data as { id?: unknown; content?: unknown; source?: { kind?: unknown; plugin?: unknown } } if (isFeedbackSource(data.source)) { - // Our own correction (re-observed after a plugin reload): adopt its - // render-failure fingerprints so a second boundary cannot repeat it. - const fingerprints = markersIn(textOfContent(data.content)) - if (fingerprints.length === 0) return + const markers = feedbackMarkersIn(textOfContent(data.content)) + if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return const state = stateOf(sessionId) state.isSubagent = session.header.parentSession !== undefined - for (const fingerprint of fingerprints) state.correctedSpec.add(fingerprint) + for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint) + for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn) + if (typeof data.id === 'string' && !state.accountedCorrectionMessageIds.has(data.id)) { + state.accountedCorrectionMessageIds.add(data.id) + const turn = markers.deliveryTurns[0] ?? state.currentTurn + if (turn !== undefined) accountReplayedCorrection(state, turn) + } return } // A DIRECT human prompt starts a new turn: the previous reply is settled. @@ -532,9 +583,29 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void { const state = sessions.get(sessionId) if (state !== undefined) { state.isSubagent = session.header.parentSession !== undefined - resetTurnState(state) + // turn/start 先于用户消息;清掉正文状态时保留正式回合身份。 + resetTurnState(state, state.currentTurn) } + } + + const restoreHistory = (session: Session): void => { + if (sessions.has(String(session.id))) return + for (const event of session.snapshotEvents()) observeEvent(session, event) + } + ctx.on('session/event', (session, event) => { + // 服务注入是异步的;首条实时事件也先接管历史,避免丢失旧纠错次数。 + restoreHistory(session) + observeEvent(session, event) }) + ctx.on('session/created', restoreHistory) + const restoreSessions = (sessionCtx: Context): void => { + const store = sessionCtx.reflect.get('sessions') as SessionStore | undefined + if (store === undefined) return + for (const session of store.list()) restoreHistory(session) + } + // 宿主不重播历史;安装时同步接管已有会话,异步注入负责晚到的服务。 + restoreSessions(ctx) + ctx.inject(['sessions'], restoreSessions) ctx.on('llm/stream', (options, next) => { if (options.sessionId === undefined || options.purpose !== undefined) return next() @@ -550,6 +621,7 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void { if (agent.session.header.parentSession !== undefined) return const state = sessions.get(String(agent.session.id)) if (state === undefined) return + state.currentTurn = turn // A render_ui result still outstanding could deliver (or fail) after this // boundary fires: steering now would race the late result, so stay silent // and let the next boundary decide on settled facts. @@ -573,7 +645,9 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void { state.correctionsTurn = plan.turn state.correctionsThisTurn = usedThisTurn + 1 try { - agent.steer(createFeedbackMessage(plan.text, agent.session.header.version)) + const message = createFeedbackMessage(plan.text, agent.session.header.version) + state.accountedCorrectionMessageIds.add(message.id) + agent.steer(message) } catch (error) { ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`) } diff --git a/tests/fence-feedback.spec.ts b/tests/fence-feedback.spec.ts index dac5785..f735d72 100644 --- a/tests/fence-feedback.spec.ts +++ b/tests/fence-feedback.spec.ts @@ -1,11 +1,10 @@ -// Issue #160: when a reply's ```dsh-ui fence does not render, the model should -// get ONE actionable chance to fix it inside the same turn. These tests pin the -// bounds that keep the loop from becoming a retry storm: exact fence matching, -// one correction per turn, one per fence body, never for subagents, never for -// an aborted turn, and accounting before the steer. +// Issue #160:回复中的 ```dsh-ui 围栏无法渲染时,模型应能在同一 turn 内收到可执行的修正要求。 +// 这些测试固定了修正边界:精确匹配围栏、共享 correction 上限、每个 fence body 在当前回合最多一次, +// 不处理 subagent 与已中止的 turn,并在调用 steer 前完成记账。 import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { markAgentLoopRequest } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createUserMessage, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -15,6 +14,7 @@ import { fenceFailures, fenceFingerprint, installFenceFeedback, + missingBodyCorrectionText, planFenceFeedback, FEEDBACK_PLUGIN_NAME, FEEDBACK_SOURCE_KIND, @@ -83,6 +83,7 @@ function streamHarness(options: { parentSession?: string; enabled?: boolean } = const ctx = new Context() const session = { id: 'sess-1', + snapshotEvents: () => [], header: options.parentSession === undefined ? { id: 'sess-1' } : { id: 'sess-1', parentSession: options.parentSession }, @@ -98,6 +99,8 @@ function harness(options: { parentSession?: string; enabled?: boolean } = {}): H const listeners = new Map unknown>() const ctx = { logger: { warn: vi.fn() }, + reflect: { get: vi.fn() }, + inject: vi.fn(), on: (name: string, handler: (payload: unknown, ...rest: unknown[]) => unknown) => { listeners.set(name, handler) return () => listeners.delete(name) @@ -106,6 +109,7 @@ function harness(options: { parentSession?: string; enabled?: boolean } = {}): H installFenceFeedback(ctx, options.enabled ?? true) const session = { id: 'sess-1', + snapshotEvents: () => [], header: options.parentSession === undefined ? { id: 'sess-1' } : { id: 'sess-1', parentSession: options.parentSession }, } const steer = vi.fn() @@ -129,6 +133,58 @@ function harness(options: { parentSession?: string; enabled?: boolean } = {}): H const assistantEvent = (text: string): unknown => ({ type: 'assistant/message', seq: 3, time: 1, data: { message: { content: [{ type: 'text', text }] } } }) as unknown as SessionEvent const userEvent = (): unknown => ({ type: 'user/message', seq: 2, time: 1, data: { content: [{ type: 'text', text: '问题' }], source: { kind: 'user' } } }) as unknown as SessionEvent +/** 用真实宿主会话持久化消息,并通过插件卸载、重新安装验证恢复。 */ +async function persistedFeedbackHarness() { + const ctx = new Context() + const sessionPlugin = await ctx.plugin(SessionStore) + const session = ctx.sessions.create('persisted-feedback') + const applyFeedback = (feedbackCtx: Context) => installFenceFeedback(feedbackCtx, true) + let feedback = await ctx.plugin(applyFeedback) + let turn = 7 + const steer = vi.fn((message: ReturnType) => { + session.append('user/message', message, { surfaceOp: 'append' }) + }) + const agent = { session, steer } + return { + ctx, + session, + steer, + startTurn: (nextTurn = 7) => { + turn = nextTurn + session.append('turn/start', { turn }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '请展示界面' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + }, + assistant: (body: string) => { + session.append('assistant/message', { + turn, step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: reply(body) }], source: { provider: 'test', model: 'test' }, + }), + }, { surfaceOp: 'append' }) + }, + validate: () => { + session.append('tool/call', { + turn, step: 1, callId: `validate-${session.seq}` as never, name: 'validate_dsh_ui', arguments: '{}', + }) + }, + boundary: () => ctx.emit('agent/turn-stopping', { agent: agent as never, turn, signal: new AbortController().signal }), + reload: async (duringReload?: () => void | Promise) => { + await feedback.dispose() + const reloading = ctx.plugin(applyFeedback) + await Promise.resolve() + const pendingEvent = duringReload?.() + feedback = await reloading + await pendingEvent + }, + dispose: async () => { + await feedback.dispose() + await sessionPlugin.dispose() + }, + } +} + describe('exact fence matching', () => { it('extracts only a fence whose info string is exactly dsh-ui', () => { const text = [ @@ -369,13 +425,36 @@ describe('installFenceFeedback wiring', () => { const toolCallEvent = (name: string, callId?: string): unknown => ({ type: 'tool/call', seq: 4, time: 2, data: callId === undefined ? { name } : { name, callId } }) as unknown as SessionEvent - const toolResultEvent = (callId: string, failure?: { internal?: boolean; blockError?: boolean }): unknown => ({ - type: 'tool/result', - seq: 6, - time: 3, + interface ToolResultOptions { + internal?: boolean + blockError?: boolean + text?: string + format?: 'legacy' | 'v4' + } + const toolResultEvent = (callId: string, options: ToolResultOptions = {}): unknown => { + const text = options.text ?? '[genui-render]\nstatus=rendered\nrendered=1\nreply_language=conversation' + const resultContent = [{ type: 'text', text }] + const message = options.format === 'v4' + ? { toolCallId: callId, isError: options.blockError === true, content: resultContent } + : { content: [{ type: 'tool-result', toolCallId: callId, isError: options.blockError === true, content: resultContent }] } + return { + type: 'tool/result', + seq: 6, + time: 3, + data: { + message, + ...(options.internal === true ? { error: { name: 'ToolError', code: 'render_failed' } } : {}), + }, + } as unknown as SessionEvent + } + const feedbackMessageEvent = (id: string, text: string): unknown => ({ + type: 'user/message', + seq: 4, + time: 1, data: { - message: { content: [{ type: 'tool-result', toolCallId: callId, isError: failure?.blockError === true }] }, - ...(failure?.internal === true ? { error: { name: 'ToolError', code: 'render_failed' } } : {}), + id, + content: [{ type: 'text', text }], + source: { kind: FEEDBACK_SOURCE_KIND, form: 'notice', summary: 'x' }, }, }) as unknown as SessionEvent const contextMessageEvent = (kind: string): unknown => ({ @@ -466,6 +545,36 @@ describe('installFenceFeedback wiring', () => { } }) + it('counts only explicit rendered status as render_ui delivery in legacy and v4 events', () => { + for (const format of ['legacy', 'v4'] as const) { + const h = harness() + h.emitSession(userEvent()) + h.emitSession(toolCallEvent('validate_dsh_ui')) + h.emitSession(toolCallEvent('render_ui', `render-${format}`)) + h.emitSession(toolResultEvent(`render-${format}`, { format })) + h.boundary({ agent: { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer }, turn: 12, signal: new AbortController().signal }) + expect(h.steer).not.toHaveBeenCalled() + } + }) + + it('keeps normally returned invalid and unrecognized render_ui results undelivered', () => { + for (const text of [ + '[genui-render]\nstatus=invalid\nerror=invalid_spec\nnext=fix_and_retry', + 'Tool completed. status=rendered', + '[genui-render]\nstatus=unknown', + ]) { + const h = harness() + h.emitSession(userEvent()) + h.emitSession(toolCallEvent('validate_dsh_ui')) + h.emitSession(toolCallEvent('render_ui', 'render-unrecognized')) + h.emitSession(toolResultEvent('render-unrecognized', { text })) + h.boundary({ agent: { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer }, turn: 13, signal: new AbortController().signal }) + expect(h.steer).toHaveBeenCalledTimes(1) + const correction = h.steer.mock.calls[0]![0] as { content: Array<{ text: string }> } + expect(correction.content[0]!.text).toContain('status=nothing_delivered') + } + }) + it('stays silent while a render_ui result is outstanding, then decides on the result', () => { const h = harness() h.emitSession(userEvent()) @@ -531,6 +640,22 @@ describe('installFenceFeedback wiring', () => { expect(second.content[0]!.text).toContain('next=resend_corrected_fence_only') }) + it('blocks a third render correction after two different fingerprints in one turn', () => { + const h = harness() + h.emitSession(userEvent()) + const agent = { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer } + const replies = [ + reply(BROKEN), + reply('{"items":[{"type":"stat","label":"second"}]}'), + reply('{"items":[{"type":"stat","label":"third"}]}'), + ] + for (const text of replies) { + h.emitSession(assistantEvent(text)) + h.boundary({ agent, turn: 14, signal: new AbortController().signal }) + } + expect(h.steer).toHaveBeenCalledTimes(2) + }) + it('never steers for a subagent session', () => { @@ -556,6 +681,7 @@ describe('installFenceFeedback wiring', () => { it('adopts the fingerprints of its own correction so a reload cannot repeat it', () => { const h = harness() const failures = fenceFailures(reply(BROKEN)) + h.emitSession(turnStartEvent(9)) h.emitSession({ type: 'user/message', seq: 4, @@ -570,6 +696,67 @@ describe('installFenceFeedback wiring', () => { expect(h.steer).not.toHaveBeenCalled() }) + it('restores a replayed delivery reminder, then shares the second slot with render feedback', () => { + const h = harness() + h.emitSession(turnStartEvent(7)) + h.emitSession(feedbackMessageEvent('delivery-7', missingBodyCorrectionText(7))) + h.emitSession(toolCallEvent('validate_dsh_ui')) + const agent = { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer } + h.boundary({ agent, turn: 7, signal: new AbortController().signal }) + expect(h.steer).not.toHaveBeenCalled() + + h.emitSession(assistantEvent(reply(BROKEN))) + h.boundary({ agent, turn: 7, signal: new AbortController().signal }) + expect(h.steer).toHaveBeenCalledTimes(1) + h.emitSession(assistantEvent(reply('{"items":[{"type":"stat","label":"third"}]}'))) + h.boundary({ agent, turn: 7, signal: new AbortController().signal }) + expect(h.steer).toHaveBeenCalledTimes(1) + }) + + it('restores render correction budget for a replayed turn and allows exactly one new fingerprint', () => { + const h = harness() + h.emitSession(turnStartEvent(9)) + h.emitSession(userEvent()) + h.emitSession(feedbackMessageEvent('render-9-a', fenceCorrectionText(fenceFailures(reply(BROKEN))))) + h.emitSession(assistantEvent(reply(BROKEN))) + h.emitSession(toolCallEvent('validate_dsh_ui')) + const agent = { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer } + h.boundary({ agent, turn: 9, signal: new AbortController().signal }) + expect(h.steer).not.toHaveBeenCalled() + + h.emitSession(assistantEvent(reply('{"items":[{"type":"stat","label":"second"}]}'))) + h.boundary({ agent, turn: 9, signal: new AbortController().signal }) + expect(h.steer).toHaveBeenCalledTimes(1) + h.emitSession(assistantEvent(reply('{"items":[{"type":"stat","label":"third"}]}'))) + h.boundary({ agent, turn: 9, signal: new AbortController().signal }) + expect(h.steer).toHaveBeenCalledTimes(1) + }) + + it('does not count a live correction message twice when the session re-observes it', () => { + const h = harness() + h.emitSession(userEvent()) + const agent = { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer } + h.emitSession(assistantEvent(reply(BROKEN))) + h.boundary({ agent, turn: 10, signal: new AbortController().signal }) + const message = h.steer.mock.calls[0]![0] as { id: string; content: Array<{ text: string }>; source: { kind: string; form: 'notice'; summary: string } } + h.emitSession(feedbackMessageEvent(message.id, message.content[0]!.text)) + + h.emitSession(assistantEvent(reply('{"items":[{"type":"stat","label":"second"}]}'))) + h.boundary({ agent, turn: 10, signal: new AbortController().signal }) + expect(h.steer).toHaveBeenCalledTimes(2) + }) + + it('counts two persisted delivery messages by distinct IDs even when their turn marker matches', () => { + const h = harness() + h.emitSession(turnStartEvent(11)) + h.emitSession(feedbackMessageEvent('delivery-11-a', missingBodyCorrectionText(11))) + h.emitSession(feedbackMessageEvent('delivery-11-b', missingBodyCorrectionText(11))) + h.emitSession(toolCallEvent('validate_dsh_ui')) + h.emitSession(assistantEvent(reply('{"items":[{"type":"stat","label":"third"}]}'))) + h.boundary({ agent: { session: { id: 'sess-1', header: { id: 'sess-1', version: 4 } }, steer: h.steer }, turn: 11, signal: new AbortController().signal }) + expect(h.steer).not.toHaveBeenCalled() + }) + it('adopts fingerprints from legacy repair markers', () => { const h = harness() const fingerprint = fenceFingerprint(BROKEN) @@ -733,3 +920,163 @@ describe('GenUI reasoning-only stream recovery', () => { expect((chunks.at(-1) as Extract).reason.kind).toBe('error') }) }) + +describe('persisted fence feedback lifecycle', () => { + it.each([false, true])('allows the same broken fence in a new turn while preserving reload deduplication (reload=%s)', async (reload) => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.assistant(BROKEN) + h.boundary() + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(1) + + h.startTurn(8) + if (reload) await h.reload() + h.assistant(BROKEN) + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + + await h.reload() + h.assistant(BROKEN) + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + h.assistant('{"items":[{"type":"stat","label":"second"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(3) + h.assistant('{"items":[{"type":"stat","label":"third"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(3) + } finally { + await h.dispose() + } + }) + + it('keeps the two-correction cap across a real plugin reload and resets it for the next turn', async () => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.assistant(BROKEN) + h.boundary() + h.assistant('{"items":[{"type":"stat","label":"second"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + await h.reload() + h.assistant('{"items":[{"type":"stat","label":"third"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + + h.startTurn(8) + h.assistant('{"items":[{"type":"stat","label":"new-turn"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(3) + } finally { + await h.dispose() + } + }) + + it('restores the correction cap before a new message arrives during plugin reload', async () => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.assistant(BROKEN) + h.boundary() + h.assistant('{"items":[{"type":"stat","label":"second"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + + await h.reload(() => h.assistant('{"items":[{"type":"stat","label":"third"}]}')) + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + } finally { + await h.dispose() + } + }) + + it('restores pending feedback before a turn boundary arrives during plugin reload', async () => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.assistant(BROKEN) + await h.reload(() => h.boundary()) + expect(h.steer).toHaveBeenCalledTimes(1) + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(1) + } finally { + await h.dispose() + } + }) + + it('keeps reasoning-only host recovery active for a model request during plugin reload', async () => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.validate() + let chunks: StreamChunk[] = [] + await h.reload(async () => { + chunks = await collectChunks(h.ctx.waterfall('llm/stream', agentRequest({ sessionId: h.session.id }), () => streamChunks([ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'private reasoning draft' }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'private reasoning draft' } }, + { type: 'finish', reason: { kind: 'stop' } }, + ]))) + }) + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + reason: { kind: 'error', failure: { code: 'EMPTY_RESPONSE' } }, + }) + } finally { + await h.dispose() + } + }) + + it('keeps a persisted delivery reminder silent after reload and shares the remaining slot with render feedback', async () => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.validate() + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(1) + await h.reload() + h.validate() + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(1) + + h.assistant(BROKEN) + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + await h.reload() + h.assistant('{"items":[{"type":"stat","label":"third"}]}') + h.boundary() + expect(h.steer).toHaveBeenCalledTimes(2) + } finally { + await h.dispose() + } + }) + + it('adopts correction history when the host restores a session from its stored events', async () => { + const h = await persistedFeedbackHarness() + try { + h.startTurn() + h.assistant(BROKEN) + h.boundary() + h.assistant('{"items":[{"type":"stat","label":"second"}]}') + h.boundary() + const restored = h.ctx.sessions.create('restored-feedback', { seed: h.session.snapshotEvents() }) + const steer = vi.fn() + restored.append('assistant/message', { + turn: 7, step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: reply('{"items":[{"type":"stat","label":"third"}]}') }], + source: { provider: 'test', model: 'test' }, + }), + }, { surfaceOp: 'append' }) + h.ctx.emit('agent/turn-stopping', { + agent: { session: restored, steer } as never, turn: 7, signal: new AbortController().signal, + }) + expect(steer).not.toHaveBeenCalled() + } finally { + await h.dispose() + } + }) +}) diff --git a/tests/plugin-genui.spec.ts b/tests/plugin-genui.spec.ts index a476190..1fe7140 100644 --- a/tests/plugin-genui.spec.ts +++ b/tests/plugin-genui.spec.ts @@ -229,7 +229,7 @@ describe('genui:fence section', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) const genui = await ctx.plugin(GenUI) - const session = { id: 'default-feedback', header: { id: 'default-feedback' } } + const session = { id: 'default-feedback', header: { id: 'default-feedback' }, snapshotEvents: () => [] } let steerCalls = 0 const steer = () => { steerCalls += 1 } emitAssistantReply(ctx, session) @@ -242,7 +242,7 @@ describe('genui:fence section', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) const genui = await ctx.plugin(GenUI, { fenceFeedback: false }) - const session = { id: 'disabled-feedback', header: { id: 'disabled-feedback' } } + const session = { id: 'disabled-feedback', header: { id: 'disabled-feedback' }, snapshotEvents: () => [] } let steerCalls = 0 const steer = () => { steerCalls += 1 } emitAssistantReply(ctx, session)