Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [Unreleased]
### 修复
- **原版 DSH(0.1.0-rc.6)壳上 dsh-ui 围栏全部静默不渲染**:client 入口硬注入声明 `inject: ['slots','sessions','inputTriggers']` 把 `inputTriggers` 当成了激活前置——但 cordis 的 `inject` 是**硬激活门控**:声明的服务永不出现(原版 DSH 壳没有任何插件提供 `inputTriggers` 服务,仅有 vision-toolkit 以 `ctx.inject()` 可选订阅)→ fiber 永久停在 waiting、`apply()` 永不执行 → 渲染器整体未启动:围栏保持代码块、控制台零报错。修复:从硬注入列表移除 `inputTriggers`,`/panel` 改为 `ctx.inject(['inputTriggers'], …)` **可选订阅**(服务与 slots/sessions 由不同 bundle 并发提供,任意到场顺序都能正确注册;缺失时仅不注册 `/panel`,渲染不受影响);带该服务的宿主行为不变,原版壳上 GenUI 恢复渲染
- **单组件根围栏被静默拒绝(JSON 有效却永远保持代码块)**:`parsePartialGenuiSpec` / `repairGenuiSpec` / `validateGenuiSpec` 三处入口都强制根节点必须带 `items` 数组——而注入的围栏词汇表把单组件(`{"type":"callout",…}` 直接作根)列为合法写法 → 这类围栏 JSON 完全有效、渲染器却拒绝:DOM 通道报一次「does not parse」后保持代码块(控制台有告警,页面无效果)。修复:新增 `wrapSingleComponentRoot`(spec.ts),单组件根自动包裹为 `col`(`panel`/`append` 提升到包裹层,面板路由不受影响),解析/修复/校验三条路径统一归一化,渲染器与 `validate_dsh_ui` 工具行为一致
### 测试
- 回归钉更新(数量不变):`dom-fence.spec.tsx` 的注入列表断言改为 `['sessions','slots']`——原断言含 `inputTriggers`,与硬激活门控语义冲突(见上条修复说明),注释附原因;jsdom 端到端补充验证:DOM 通道在无 `inputTriggers` 的宿主上发现围栏并渲染 callout/chart
- 370 → 380(+10:genui-guard +7(单组件根包裹/panel-append 提升/非组件拒绝/幂等/校验通过/parseGenuiSpec 包裹/垃圾拒绝)、genui-partial +3(单组件根包裹/panel-append 提升/非组件拒绝));本地环境其余失败均为宿主源码树依赖(install-script chmod / skill-md yaml 版本),与本次变更无关

## [0.8.5] - 2026-08-16
### 发布
- **发布规范对齐 `plugin_check`(issue #15)**:
Expand Down
6 changes: 3 additions & 3 deletions lib/client.js

Large diffs are not rendered by default.

34 changes: 30 additions & 4 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
//#region src/client/spec.ts
/**
* Wrap a bare component object into a col root. Returns null when `value` is
* not component-shaped (no usable `type`). `panel`/`append` live on the root
* spec, so they are hoisted onto the wrapper.
*/
function wrapSingleComponentRoot(value) {
if (typeof value !== "object" || value === null) return null;
const v = value;
if (typeof v.type !== "string" || v.type === "") return null;
const root = {
type: "col",
items: [value]
};
if (v.panel === true) root.panel = true;
if (v.append === true) root.append = true;
return root;
}
//#endregion
//#region src/client/guard.ts
/** Hard resource limits enforced by repair (and mirrored at render time). */
const GENUI_LIMITS = {
Expand Down Expand Up @@ -813,13 +832,20 @@ function repairQuizOptions(v) {
}
/**
* Deterministically repair a raw spec value into a renderable GenuiSpec.
* Returns null only when the root is not an object with an `items` array;
* every other defect is healed by dropping/clamping/truncating. Idempotent:
* repairing a repaired spec is a no-op.
* Returns null only when the root is not an object with an `items` array
* (a bare component root is wrapped into a col first — the documented fence
* vocabulary allows single-component bodies); every other defect is healed by
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
* no-op.
*/
function repairGenuiSpec(value) {
const v = obj(value);
if (v === void 0 || !Array.isArray(v.items)) return null;
if (v === void 0) return null;
if (!Array.isArray(v.items)) {
const wrapped = wrapSingleComponentRoot(value);
if (wrapped === null) return null;
return repairGenuiSpec(wrapped);
}
const ctx = { remaining: GENUI_LIMITS.maxNodes };
return {
...opt("title", str(v.title, GENUI_LIMITS.maxString)),
Expand Down
8 changes: 5 additions & 3 deletions lib/types/client/guard.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export interface GenuiValidation {
}
/**
* Deterministically repair a raw spec value into a renderable GenuiSpec.
* Returns null only when the root is not an object with an `items` array;
* every other defect is healed by dropping/clamping/truncating. Idempotent:
* repairing a repaired spec is a no-op.
* Returns null only when the root is not an object with an `items` array
* (a bare component root is wrapped into a col first — the documented fence
* vocabulary allows single-component bodies); every other defect is healed by
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
* no-op.
*/
export declare function repairGenuiSpec(value: unknown): GenuiSpec | null;
/**
Expand Down
6 changes: 6 additions & 0 deletions lib/types/client/spec.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,5 +457,11 @@ export interface GenuiQuiz {
}
/** Parse the raw fence body as a GenuiSpec, or null when it is not one. */
export declare function parseGenuiSpec(raw: string): GenuiSpec | null;
/**
* Wrap a bare component object into a col root. Returns null when `value` is
* not component-shaped (no usable `type`). `panel`/`append` live on the root
* spec, so they are hoisted onto the wrapper.
*/
export declare function wrapSingleComponentRoot(value: unknown): GenuiSpec | null;
/** Basic structural guard: is this object a valid GenuiSpec? */
export declare function isGenuiSpec(value: unknown): value is GenuiSpec;
24 changes: 19 additions & 5 deletions src/client/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* are elided.
*/
import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec } from './spec.ts'
import { wrapSingleComponentRoot } from './spec.ts'

/** Hard resource limits enforced by repair (and mirrored at render time). */
export const GENUI_LIMITS = {
Expand Down Expand Up @@ -708,13 +709,20 @@ function repairQuizOptions(v: unknown): Array<{ label: string; correct?: boolean

/**
* Deterministically repair a raw spec value into a renderable GenuiSpec.
* Returns null only when the root is not an object with an `items` array;
* every other defect is healed by dropping/clamping/truncating. Idempotent:
* repairing a repaired spec is a no-op.
* Returns null only when the root is not an object with an `items` array
* (a bare component root is wrapped into a col first — the documented fence
* vocabulary allows single-component bodies); every other defect is healed by
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
* no-op.
*/
export function repairGenuiSpec(value: unknown): GenuiSpec | null {
const v = obj(value)
if (v === undefined || !Array.isArray(v.items)) return null
if (v === undefined) return null
if (!Array.isArray(v.items)) {
const wrapped = wrapSingleComponentRoot(value)
if (wrapped === null) return null
return repairGenuiSpec(wrapped)
}
const ctx: RepairCtx = { remaining: GENUI_LIMITS.maxNodes }
return {
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
Expand Down Expand Up @@ -777,7 +785,13 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
const errors: string[] = []
const v = obj(value)
if (v === undefined) return { ok: false, errors: ['spec root must be an object'] }
if (!Array.isArray(v.items)) return { ok: false, errors: ['spec.items must be an array'] }
if (!Array.isArray(v.items)) {
// Single-component root: validate through the wrapped form so the tool
// agrees with the renderer about what is a valid fence body.
const wrapped = wrapSingleComponentRoot(value)
if (wrapped !== null) return validateGenuiSpec(wrapped)
return { ok: false, errors: ['spec.items must be an array'] }
}
if (v.title !== undefined && typeof v.title !== 'string') errors.push('spec.title must be a string')
if (v.gap !== undefined && (typeof v.gap !== 'number' || !Number.isFinite(v.gap))) errors.push('spec.gap must be a finite number')
let count = 0
Expand Down
42 changes: 30 additions & 12 deletions src/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,25 +150,43 @@ export function apply(ctx: Context): () => void {
// opens the panel dock (publishes the default spec + expand request),
// clears it (/panel clear), or relays an instruction to the model
// (/panel <指令>) so the panel gets tailored content.
const slash = ctx.get('inputTriggers') as InputTriggerServiceContract | undefined
if (slash !== undefined) {
disposers.push(ctx.effect(() => slash.registerSource(
//
// inputTriggers is subscribed via cordis OPTIONAL injection (ctx.inject),
// NOT a one-shot ctx.get() at apply time: the service is typically
// provided by a different bundle than slots/sessions, so it can arrive
// AFTER apply() runs — a one-shot lookup would silently disable /panel
// even on hosts that DO ship the service (service arrival order race).
// ctx.inject activates the callback only when the service arrives (any
// order) and disposes with the subscription fiber; hosts without the
// service simply never register /panel, and rendering is unaffected
// either way.
ctx.inject(['inputTriggers'], (scope) => {
const slash = scope.get('inputTriggers') as InputTriggerServiceContract | undefined
if (slash === undefined) return
scope.effect(() => slash.registerSource(
createPanelSlashSource((sessionId, instruction) => sendPanelInstruction(ctx, sessionId, instruction)),
), 'genui: /panel'))
} else {
console.warn('[genui] inputTriggers service unavailable; /panel command disabled')
}
), 'genui: /panel')
})
return () => {
for (const dispose of disposers) dispose()
}
}

// Browser services the client entry needs: the slots registry (toolview +
// dock), sessions (scoped conversation send behind actions), and
// inputTriggers (the /panel command source). This declaration is what the
// host's fiber inject waiting uses — without it apply() runs before the
// services bind and the whole plugin tree fails the boot sweep.
export const inject = ['slots', 'sessions', 'inputTriggers']
// dock) and sessions (scoped conversation send behind actions). This
// declaration is what the host's fiber inject waiting uses — without it
// apply() runs before the services bind and the whole plugin tree fails the
// boot sweep.
//
// inputTriggers (the /panel command source) is deliberately NOT declared:
// cordis `inject` is a hard activation gate — a declared service that is
// never provided (pristine DSH shells ship no inputTriggers provider) parks
// the fiber in waiting forever and apply() never runs, silently killing all
// GenUI rendering. Instead the apply() body subscribes via ctx.inject
// (optional injection): hosts with the service get /panel registered in any
// arrival order, hosts without it never register /panel — and the renderer
// itself never depends on the service either way.
export const inject = ['slots', 'sessions']

// Re-export the registry renderer for the test suite (setup.ts registers it
// exactly like apply() does on contract hosts).
Expand Down
7 changes: 6 additions & 1 deletion src/client/parse-partial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/
import { GENUI_LIMITS } from './guard.ts'
import { isGenuiSpec, type GenuiSpec } from './spec.ts'
import { wrapSingleComponentRoot } from './spec.ts'

/** Default repair-candidate budget (adjustable; see the design doc). */
export const MAX_PARTIAL_REPAIR_ATTEMPTS = 32
Expand Down Expand Up @@ -124,7 +125,11 @@ function closeSuffix(stack: string[]): string {
function trySpec(candidate: string): GenuiSpec | null {
try {
const value: unknown = JSON.parse(candidate)
return isGenuiSpec(value) ? value : null
if (isGenuiSpec(value)) return value
// Single-component roots are part of the documented fence vocabulary
// (e.g. a bare {"type":"callout",…} body) — wrap into a col so the
// items-gated pipeline renders them (panel/append hoisted).
return wrapSingleComponentRoot(value)
} catch {
return null
}
Expand Down
28 changes: 27 additions & 1 deletion src/client/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,33 @@ export function parseGenuiSpec(raw: string): GenuiSpec | null {
} catch {
return null
}
return isGenuiSpec(value) ? value : null
if (isGenuiSpec(value)) return value
// Single-component roots are part of the documented fence vocabulary
// (e.g. {"type":"callout","tone":"info","title":"…","content":"…"} as the
// whole body) — wrap them into a col so the items-gated pipeline renders
// them. panel/append hoist onto the wrapper so panel routing keeps working.
return wrapSingleComponentRoot(value)
}

/**
* Wrap a bare component object into a col root. Returns null when `value` is
* not component-shaped (no usable `type`). `panel`/`append` live on the root
* spec, so they are hoisted onto the wrapper.
*/
export function wrapSingleComponentRoot(value: unknown): GenuiSpec | null {
if (typeof value !== 'object' || value === null) return null
const v = value as { type?: unknown; panel?: unknown; append?: unknown }
if (typeof v.type !== 'string' || v.type === '') return null
// GenuiCol is structurally a GenuiSpec (items + optional gap); panel and
// append are GenuiSpec-only root flags, added after construction.
const wrapped: GenuiCol = {
type: 'col',
items: [value as GenuiNode],
}
const root: GenuiSpec = wrapped
if (v.panel === true) root.panel = true
if (v.append === true) root.append = true
return root
}

/** Basic structural guard: is this object a valid GenuiSpec? */
Expand Down
5 changes: 4 additions & 1 deletion tests/dom-fence.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ describe('installDomFenceRenderer', () => {
it('declares its cordis service injects (boot sweep depends on it)', () => {
// 回归钉:曾丢失 inject 导出 → 宿主 fiber inject waiting 失效 →
// apply 早于 slots 服务运行 → 整页 "Failed to load plugins"。
expect([...inject].sort()).toEqual(['inputTriggers', 'sessions', 'slots'])
// inputTriggers 刻意不在硬注入列表里:cordis `inject` 是硬激活门控,
// 原版 DSH 壳不提供该服务 → fiber 永久 waiting、apply 永不执行 →
// 全部 dsh-ui 围栏静默保持代码块。apply() 体内已用 ctx.get() 可选降级。
expect([...inject].sort()).toEqual(['sessions', 'slots'])
})

it('renders a settled dsh-ui fence into its own root and hides the stock block', async () => {
Expand Down
52 changes: 51 additions & 1 deletion tests/genui-guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// `repairGenuiSpec` before rendering, so these invariants protect the UI.
import { describe, expect, it } from 'vitest'
import { GENUI_LIMITS, repairGenuiSpec, validateGenuiSpec } from '../src/client/guard.ts'
import { isGenuiSpec } from '../src/client/spec.ts'
import { isGenuiSpec, parseGenuiSpec } from '../src/client/spec.ts'

const text = (content: string) => ({ type: 'text', content })

Expand Down Expand Up @@ -43,6 +43,56 @@ describe('repairGenuiSpec: root shape', () => {
})
})

describe('repairGenuiSpec: single-component roots', () => {
it('wraps a bare component root into a col (documented fence vocabulary)', () => {
const spec = repairGenuiSpec({ type: 'callout', tone: 'info', title: '核心观察', content: '你好' })
expect(spec).not.toBeNull()
// The repaired GenuiSpec carries no `type` (root spec field set) — the
// observable wrap effect is the items array holding the bare component.
expect(spec?.items).toHaveLength(1)
expect((spec?.items[0] as { type: string }).type).toBe('callout')
expect(isGenuiSpec(spec)).toBe(true)
})

it('hoists panel/append from the bare component onto the wrapper', () => {
const spec = repairGenuiSpec({ type: 'text', content: 'x', panel: true, append: true })
expect(spec?.panel).toBe(true)
expect(spec?.append).toBe(true)
const inner = spec?.items[0] as { panel?: unknown; append?: unknown }
expect(inner.panel).toBeUndefined()
expect(inner.append).toBeUndefined()
})

it('still rejects non-component objects without an items array', () => {
expect(repairGenuiSpec({ title: 'x' })).toBeNull()
expect(repairGenuiSpec({ foo: 1 })).toBeNull()
})

it('idempotent: a wrapped single root repairs to itself', () => {
const once = repairGenuiSpec({ type: 'stat', label: 'L', value: '1' })
const twice = repairGenuiSpec(once)
expect(twice).toEqual(once)
})
})

describe('validateGenuiSpec / parseGenuiSpec: single-component roots', () => {
it('accepts a bare component as valid', () => {
const result = validateGenuiSpec({ type: 'callout', tone: 'info', title: 'T', content: 'c' })
expect(result.ok).toBe(true)
})

it('parseGenuiSpec wraps a single-component fence body', () => {
const spec = parseGenuiSpec(JSON.stringify({ type: 'keyvalue', pairs: [{ key: 'a', value: 'b' }] }))
expect(spec?.type).toBe('col')
expect((spec?.items[0] as { type: string }).type).toBe('keyvalue')
})

it('parseGenuiSpec still rejects non-component junk', () => {
expect(parseGenuiSpec('{"foo":1}')).toBeNull()
expect(parseGenuiSpec('not json')).toBeNull()
})
})

describe('repairGenuiSpec: node-level healing', () => {
it('drops nodes with missing required fields', () => {
const spec = repairGenuiSpec({ items: [
Expand Down
20 changes: 20 additions & 0 deletions tests/genui-partial.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ describe('parsePartialGenuiSpec', () => {
expect(spec?.items).toHaveLength(1)
})

it('wraps a single-component root into a col (documented fence vocabulary)', () => {
// Regression: bare component bodies (no root `items`) were rejected —
// the DOM channel kept the code block with a parse-failure warning.
const spec = parsePartialGenuiSpec('{"type":"callout","tone":"info","title":"T","content":"c"}')
expect(spec).not.toBeNull()
expect(spec!.items).toHaveLength(1)
expect((spec!.items[0] as { type: string }).type).toBe('callout')
})

it('hoists panel/append from a single-component root onto the wrapper', () => {
const spec = parsePartialGenuiSpec('{"type":"text","content":"x","panel":true,"append":true}')
expect(spec?.panel).toBe(true)
expect(spec?.append).toBe(true)
})

it('still rejects non-component roots without an items array', () => {
expect(parsePartialGenuiSpec('{"title":"x"}')).toBeNull()
expect(parsePartialGenuiSpec('{"foo":1}')).toBeNull()
})

it('extracts finished components while the array is still growing', () => {
// 第 1 个元素完成,第 2 个未写完 —— 应只返回第 1 个
const spec = parsePartialGenuiSpec('{"items":[{"type":"text","content":"A"},{"type":"stat","labe')
Expand Down