refactor(experimental): drop the dsh-action answer block and its chat filter

Personalized pages no longer proposes cards through a fenced block in the
answer, so the chat stops hiding dsh- fences and the block parser package
goes away with its registrations.
This commit is contained in:
ZiyaZhang committed 2026-10-22 06:28:09 -07:00
1 parent 62351681da
commit a07485bdc7
27 files changed
+7 -693

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-action-block|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
+2 -2
View File
@@ -18,8 +18,8 @@
en: a05d85ff4949b526
zh: 7261ffc98665e9e7
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
en: e60db44c8a6a016e
zh: d563bd5d34da9807
en: cbf1f0a2205046a5
zh: 2e59d0815351349f
/deepseek-ai-dsh-client-ui-chat/command-and-failure-rows:
en: 41dcd70e14ebe9cc
zh: ededead572b59037
-2
View File
@@ -56,8 +56,6 @@ Chat omits system-prompt, ordinary Context injection, and `permission` command r
When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start.
Chat hides top-level `dsh-` directive fences, as found by [`dsh-util-action-block`](../../util/action-block/README.md), from rendered answers and from the answer text the Turn footer copies; Trajectory keeps the raw text.
<a id="command-and-failure-rows"></a>
## Command and failure rows
-2
View File
@@ -56,8 +56,6 @@ Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
Chat 在渲染的回答和轮次页脚复制的回答文本中隐藏 [`dsh-util-action-block`](../../util/action-block/README.zh.md) 识别出的顶层 `dsh-` 指令围栏;Trajectory 保留原始文本。
<a id="command-and-failure-rows"></a>
## 指令与失败行
-1
View File
@@ -92,7 +92,6 @@
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-tool-executor-ptc": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-action-block": "workspace:*",
"@tanstack/react-virtual": "3.14.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -9,7 +9,6 @@ import type { AssistantBlock } from '../contract/snapshot.ts'
import { markdownLabels } from '../markdown-labels.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
import { hideDirectiveFences } from './directive-fences.ts'
/**
* Standalone fallback for image destinations (query/fragment suffixes are ignored).
@@ -86,7 +85,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
rendered.push(
<MarkdownText
key={i}
text={hideDirectiveFences(block.text, { streaming })}
text={block.text}
streaming={streaming}
labels={labels}
fileMentions={mentions}
@@ -4,7 +4,6 @@ import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps }
import { MessageIconActions } from './MessageIconActions.tsx'
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
import { assistantText } from './turn-assistant.ts'
import { hideDirectiveFences } from './directive-fences.ts'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
@@ -63,7 +62,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
>
{tail}
<MessageIconActions
text={hideDirectiveFences(assistantText(closing.blocks))}
text={assistantText(closing.blocks)}
time={closing.time}
clock="end"
// The branch action owns boundary resolution: it sends the real
@@ -1,12 +0,0 @@
/** Fences in the `dsh-` language namespace carry Host-interpreted blocks, not prose; the chat hides them. */
import { hideDirectiveFences as withoutDirectiveFences } from '@deepseek-ai/dsh-util-action-block'
/** Remove top-level `dsh-` fences from answer text by the shared CommonMark scan.
* @param text - assistant text.
* @param options - `streaming` also removes an unclosed last fence; a finished answer keeps it as text.
* @returns the text unchanged when nothing is hidden; otherwise without the fences, leading line breaks or trailing whitespace.
*/
export function hideDirectiveFences(text: string, options?: { readonly streaming?: boolean }): string {
const shown = withoutDirectiveFences(text, options)
return shown === text ? text : shown.replace(/^\n+/u, '').trimEnd()
}
@@ -2543,17 +2543,6 @@ describe('ChatView', () => {
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
})
it('copies the final answer without its dsh- directive blocks', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```'
const h = makeHarness({ nodes: [user(1, 'hi'), assistant(2, `方案在下方。\n\n${block}`)], turnEnds: new Map([[1, 2]]) })
const view = render(<h.ChatView {...h.props} />)
const tail = view.container.querySelector<HTMLElement>('[data-turn-tail]')
fireEvent.click(within(tail!).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('方案在下方。')
})
it('keeps every Assistant reply visible while the whole Turn folds its process', () => {
const progress = {
...assistant(2, 'stage reply', 1, 1),
@@ -1,33 +0,0 @@
import { expect, it } from 'vitest'
import { hideDirectiveFences } from '../src/client/chat/directive-fences.ts'
const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```'
it('hides closed top-level dsh- fences and trims the edges they leave', () => {
expect(hideDirectiveFences(`方案在下方。\n\n${block}`)).toBe('方案在下方。')
expect(hideDirectiveFences(`${block}\n\n后记`)).toBe('后记')
expect(hideDirectiveFences(`前\n\n${block}\n\n后`)).toBe('前\n\n后')
expect(hideDirectiveFences('```dsh-action json\n{}\n``` \n尾')).toBe('尾')
})
it('keeps an unclosed fence in a finished answer and hides it while streaming', () => {
const unclosed = '说明\n\n```dsh-action\n{"action":"create_pers'
expect(hideDirectiveFences(unclosed)).toBe(unclosed)
expect(hideDirectiveFences(unclosed, { streaming: false })).toBe(unclosed)
expect(hideDirectiveFences(unclosed, { streaming: true })).toBe('说明')
})
it('follows CommonMark fences for nested examples, longer fences and fence characters after text', () => {
const nested = '示例:\n\n````markdown\n' + block + '\n````'
expect(hideDirectiveFences(nested)).toBe(nested)
expect(hideDirectiveFences('前\n\n````dsh-action\n{"note":"```"}\n```\n````\n\n后')).toBe('前\n\n后')
const glued = '见下方:```dsh-action\n{}\n```'
expect(hideDirectiveFences(glued)).toBe(glued)
})
it('keeps other text as written, including blank lines inside other code blocks', () => {
const code = '看代码:\n\n```ts\nconst a = 1\n\n\n\nconst b = 2\n```\n\n完'
expect(hideDirectiveFences(code)).toBe(code)
expect(hideDirectiveFences(`${code}\n\n${block}`)).toBe(code)
expect(hideDirectiveFences('\n没有围栏 \n')).toBe('\n没有围栏 \n')
})
@@ -15,9 +15,6 @@
{
"path": "../../util/values"
},
{
"path": "../../util/action-block"
},
{
"path": "../../../vendor/cordis"
},
+2 -2
View File
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/util-shared-utilities/packages:
en: adc65a3296c4e168
zh: 6330fdd05d734360
en: 6f788656548ee4e0
zh: 227695dafaa9476d
/util-shared-utilities/related-documentation:
en: 432ae81110914571
zh: c5bf4efc007311f3
-1
View File
@@ -37,7 +37,6 @@ Each package provides one primitive; open a package page for how to use it.
| [`home-paths/`](home-paths/README.md) | Resolves the single Harness home and joins shared user-data paths |
| [`http-proxy/`](http-proxy/README.md) | Resolves one outbound proxy policy and installs it for `fetch`, SDK agents, and spawned children |
| [`launch-environment/`](launch-environment/README.md) | Frozen launch environment that remembers which layer supplied each value |
| [`action-block/`](action-block/README.md) | Finds the fenced `dsh-` directive blocks of an answer, parses its `dsh-action` proposals and hides the fences |
| [`atomic-write/`](atomic-write/README.md) | Atomic file replacement and cross-process writer locking |
| [`lazy-require/`](lazy-require/README.md) | Loads CommonJS-compatible Host dependencies on first use with caller-relative resolution |
| [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string |
-1
View File
@@ -37,7 +37,6 @@ kind: "package-group"
| [`home-paths/`](home-paths/README.zh.md) | 解析统一的 Harness 主目录并拼接共享的用户数据路径 |
| [`http-proxy/`](http-proxy/README.zh.md) | 解析出唯一的出站代理策略,并为 `fetch`、SDK agent(智能体)与 spawn 的子进程安装它 |
| [`launch-environment/`](launch-environment/README.zh.md) | 冻结的启动环境,记住每个值来自哪一层 |
| [`action-block/`](action-block/README.zh.md) | 找出回答里的 `dsh-` 指令围栏块,解析其中的 `dsh-action` 提案并隐藏围栏 |
| [`atomic-write/`](atomic-write/README.zh.md) | 原子文件替换与跨进程写锁 |
| [`lazy-require/`](lazy-require/README.zh.md) | 在首次使用时按调用方位置加载兼容 CommonJS 的 Host 依赖 |
| [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 |
@@ -1,46 +0,0 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/action-block/README.md
/:
en: d942382afd33ad60
zh: f0fe3965e4e805ba
/deepseek-ai-dsh-util-action-block:
en: cf7b74342b6e13e6
zh: 88f591b07862f6fb
/deepseek-ai-dsh-util-action-block/summary:
en: eb6aeedb6b9bae3b
zh: 15447c41ce7f263f
/deepseek-ai-dsh-util-action-block/table-of-contents:
en: 80b31a934611cb4a
zh: 6195f049e8537637
/deepseek-ai-dsh-util-action-block/use-this-package:
en: bbff250eba0f1b89
zh: 0a618f05cc88ee2b
/deepseek-ai-dsh-util-action-block/use-this-package/read-the-blocks-of-an-answer:
en: 3730850518c3a045
zh: dfba80dc702b83b6
/deepseek-ai-dsh-util-action-block/use-this-package/tell-actions-apart:
en: 9273b5e38fc76f93
zh: 0541d64b400b4bcf
/deepseek-ai-dsh-util-action-block/use-this-package/hide-the-fences:
en: 1833a4630b920fec
zh: 96a84dc769618a5a
/deepseek-ai-dsh-util-action-block/use-this-package/find-the-fences:
en: 45d195d19e321da4
zh: fc043d4de7786552
/deepseek-ai-dsh-util-action-block/understand-the-implementation:
en: c9f50262baddbe27
zh: e1118e6e75b162d6
/deepseek-ai-dsh-util-action-block/understand-the-implementation/source-map:
en: b804b505aa3e6440
zh: d3ec0f2efa14027a
/deepseek-ai-dsh-util-action-block/further-exploration:
en: dc70f3690d30b00a
zh: 6bc40d00bcd797cc
/deepseek-ai-dsh-util-action-block/known-limitations-and-deferred-work:
en: 370a7953af60f8e7
zh: 6203a54a761fe993
/deepseek-ai-dsh-util-action-block/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
-105
View File
@@ -1,105 +0,0 @@
---
description: "Scanner for the fenced dsh- directive blocks of assistant answers: dsh-action proposal parsing and fence hiding, shared by the card experiments and the chat renderer."
kind: "package-library"
---
# @deepseek-ai/dsh-util-action-block
English | [中文](README.zh.md)
## Summary
Callers can find the `dsh-` directive fences of an assistant answer, read its `dsh-action` blocks as parsed JSON objects, tell which action each block names, and render the answer without those fences. The package owns only the fence grammar; every card experiment validates its own block fields and decides when a block becomes a card.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
### Read the blocks of an answer
`actionBlocksOf(text)` returns every well-formed JSON object inside a closed ```` ```dsh-action ```` fence, in answer order. Fences with malformed JSON, arrays or scalars produce nothing, and so does an unclosed fence; other fence languages are ignored. Validate the fields of each object with the schema of the action that owns it.
```ts
import { actionBlocksOf, actionOf } from '@deepseek-ai/dsh-util-action-block'
declare const answerText: string
const blocks = actionBlocksOf(answerText)
const mine = blocks.filter(block => actionOf(block) === 'create_personalized_page')
```
### Tell actions apart
`actionOf(block)` returns the non-blank `action` string of a parsed block, or `undefined`. A projection uses it to find its own blocks and to notice that an answer also carries another experiment's block, without knowing that experiment's schema.
### Hide the fences
`hideDirectiveFences(text, { streaming })` removes every fence whose language starts with `dsh-`, from its opening line through its closing line, and keeps all other text as written. When a blank line precedes a removed fence, a blank line directly after it is removed too, so one blank line remains; when nothing follows the fence, the blank line before it is removed. An unclosed last fence is removed only while `streaming` is true, because the rest of the block has not arrived; a finished answer keeps it as text. Transcript views keep the original answer.
```ts
import { hideDirectiveFences } from '@deepseek-ai/dsh-util-action-block'
declare const answerText: string
declare const streaming: boolean
const rendered = hideDirectiveFences(answerText, { streaming })
```
### Find the fences
`scanDirectiveFences(text)` returns each directive fence with its language, the offsets of its first line and of the end of its closing line, whether it is closed, and the body lines between the fence lines. `ACTION_BLOCK_LANGUAGE` is the `dsh-action` language.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The scanner splits the text into lines ending in `\n` or `\r\n` and follows CommonMark fenced code blocks outside any other fence. An opening line has at most three spaces of indentation and three or more backticks or tildes; the first word of its info string is the language, and a backtick fence's info string contains no backtick. The first later line with at most three spaces of indentation and at least as many of the same character, followed only by spaces or tabs, closes it. Every line inside an open fence of any language is its content, so a `dsh-action` example inside a `````` ````markdown `````` or `~~~` fence is neither parsed nor hidden, and fence characters after other text on a line open nothing. Block bodies are parsed with `JSON.parse` after trimming. The scanner keeps no state and has no dependency, so a Host projection and a browser bundle read the same answer identically.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Fence scan, block parsing, the action discriminator and fence hiding |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [Utility package map](../README.md) — adjacent stateless helpers.
- [Personalized pages](../../experimental/creation-recommendations/README.md) — proposes new pages through these blocks.
-----
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **Block quotes and list items are not parsed as containers** — a fence after a `>` marker is plain text, and a fence indented by up to three spaces inside a list item counts as a top-level fence.
- **Hiding can join paragraphs** — a removed fence written without blank lines around it leaves the lines before and after it adjacent, so Markdown renders them as one paragraph.
- **No shared card rules yet** — one card per turn, replacement and status notices are still folded by each experiment; a shared action-card layer is deferred until a second card experiment reads these blocks.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
-105
View File
@@ -1,105 +0,0 @@
---
description: "扫描助手回答里的 dsh- 指令围栏块:解析 dsh-action 提案并隐藏围栏,供卡片实验和聊天渲染共用。"
kind: "package-library"
---
# @deepseek-ai/dsh-util-action-block
[English](README.md) | 中文
## 概述
调用方可以找出助手回答里的 `dsh-` 指令围栏,把其中的 `dsh-action` 块读成已解析的 JSON 对象,辨认每个块声明的动作,并在渲染回答时去掉这些围栏。本包只拥有围栏语法;每个卡片实验各自校验自己的块字段,并决定一个块何时成为卡片。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [延伸阅读](#further-exploration)
- [已知限制与待办](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
### 读取回答里的块
`actionBlocksOf(text)` 按回答顺序返回每个已闭合的 ```` ```dsh-action ```` 围栏内格式正确的 JSON 对象。JSON 错误、数组或标量的围栏不产生任何块,未闭合的围栏也不产生;其他围栏语言被忽略。每个对象的字段由拥有该动作的 schema 校验。
```ts
import { actionBlocksOf, actionOf } from '@deepseek-ai/dsh-util-action-block'
declare const answerText: string
const blocks = actionBlocksOf(answerText)
const mine = blocks.filter(block => actionOf(block) === 'create_personalized_page')
```
### 区分动作
`actionOf(block)` 返回已解析块的非空 `action` 字符串,否则返回 `undefined`。投影用它找出自己的块,并察觉同一回答里还带着另一个实验的块,而不必知道那个实验的 schema。
### 隐藏围栏
`hideDirectiveFences(text, { streaming })` 删除每个语言以 `dsh-` 开头的围栏,从开始行一直到结束行,其余文本保持原样。被删围栏前面是空行时,紧跟在它后面的一个空行也一并删除,只留下一个空行;围栏后面没有任何内容时,删除它前面的空行。未闭合的末尾围栏只在 `streaming` 为 true 时删除,因为块的其余部分还没到达;已完成的回答把它保留为文本。轨迹视图保留原始回答。
```ts
import { hideDirectiveFences } from '@deepseek-ai/dsh-util-action-block'
declare const answerText: string
declare const streaming: boolean
const rendered = hideDirectiveFences(answerText, { streaming })
```
### 查找围栏
`scanDirectiveFences(text)` 返回每个指令围栏:它的语言、开始行的偏移、结束行末尾的偏移、是否闭合,以及两条围栏行之间的正文行。`ACTION_BLOCK_LANGUAGE` 是 `dsh-action` 这个语言名。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内部 — 点击展开</summary>
扫描器把文本切成以 `\n` 或 `\r\n` 结尾的行,并按 CommonMark 识别不在其他围栏内的围栏代码块。开始行最多缩进三个空格,接着是三个或更多反引号或波浪号;信息串的第一个词是语言,反引号围栏的信息串里不能有反引号。之后第一个最多缩进三个空格、由至少同样多的同种字符组成、后面只跟空格或制表符的行结束这个围栏。任何语言的围栏打开期间,其中每一行都是它的内容,因此 `````` ````markdown `````` 或 `~~~` 围栏里的 `dsh-action` 示例既不解析也不隐藏;一行里跟在其他文字后面的围栏字符不开启任何围栏。块正文去掉首尾空白后交给 `JSON.parse`。扫描器没有状态也没有依赖,因此 Host 投影和浏览器包对同一回答的读法完全一致。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 围栏扫描、块解析、动作判别和围栏隐藏 |
</details>
-----
<a id="further-exploration"></a>
## 延伸阅读
- [工具包地图](../README.zh.md) — 相邻的无状态辅助包。
- [个性化页面](../../experimental/creation-recommendations/README.zh.md) — 通过这些块提案新页面。
-----
## 已知限制与待办
<a id="known-limitations-and-deferred-work"></a>
- **不把块引用和列表项当作容器解析** — `>` 标记后面的围栏是普通文本,列表项里缩进不超过三个空格的围栏算作顶层围栏。
- **隐藏可能合并段落** — 前后没有空行的围栏被删除后,它前后的两行直接相邻,Markdown 会把它们渲染成同一段。
- **还没有共享的卡片规则** — 一轮一张、替换和状态通知仍由各实验自己折叠;共享的动作卡层推迟到第二个卡片实验读取这些块之后。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文 — 点击展开</summary>
无。
</details>
-35
View File
@@ -1,35 +0,0 @@
{
"name": "@deepseek-ai/dsh-util-action-block",
"description": "CommonMark fence scan for the dsh- directive blocks of assistant answers: dsh-action proposal parsing and fence hiding for the DeepSeek Harness",
"version": "0.2.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/util/action-block"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~"
}
}
-173
View File
@@ -1,173 +0,0 @@
/** Fenced `dsh-` directive blocks of assistant answers: the top-level fence scan, the `dsh-action` JSON
* blocks an answer ends with to propose a card, and the answer text without directive fences.
* @module @deepseek-ai/dsh-util-action-block */
/** Fence language that marks a proposal block. */
export const ACTION_BLOCK_LANGUAGE = 'dsh-action'
/** Language prefix of every directive fence; chat renderers hide these fences. */
const DIRECTIVE_PREFIX = 'dsh-'
/** One top-level fenced code block whose language starts with `dsh-`. */
export interface DirectiveFence {
/** First word of the info string, such as `dsh-action`. */
readonly language: string
/** Offset of the opening fence line. */
readonly start: number
/** Offset just past the closing fence line and its line ending, or the text length when the fence is unclosed. */
readonly end: number
/** False when no closing fence line follows, so the block runs to the end of the text. */
readonly closed: boolean
/** Lines between the fence lines without their line endings, joined with `\n`. */
readonly body: string
}
interface Line {
readonly start: number
/** Offset just past the line ending, or the text length for an unterminated last line. */
readonly end: number
/** Line content without `\n` or `\r\n`. */
readonly text: string
}
interface Fence {
readonly language: string
readonly marker: string
readonly opening: Line
readonly closing: Line | undefined
readonly content: readonly Line[]
/** Line indexes of the opening line and of the closing line, or of the last line when unclosed. */
readonly first: number
readonly last: number
}
const OPENING = /^ {0,3}(?:`{3,}|~{3,})/u
const CLOSING = /^ {0,3}(?:`{3,}|~{3,})[ \t]*$/u
const BLANK = /^[ \t]*$/u
function linesOf(text: string): Line[] {
const lines: Line[] = []
let start = 0
while (start < text.length) {
const newline = text.indexOf('\n', start)
const end = newline === -1 ? text.length : newline + 1
const content = text.slice(start, newline === -1 ? end : newline)
lines.push({ start, end, text: content.endsWith('\r') ? content.slice(0, -1) : content })
start = end
}
return lines
}
function closes(text: string, marker: string): boolean {
const closing = CLOSING.exec(text)
if (closing === null) return false
const run = closing[0].trim()
return run.charAt(0) === marker.charAt(0) && run.length >= marker.length
}
/** CommonMark fenced code blocks outside any other fence, of every language, in text order. */
function fencesOf(lines: readonly Line[]): Fence[] {
const fences: Fence[] = []
let open: Omit<Fence, 'closing' | 'content' | 'last'> & { readonly content: Line[] } | undefined
for (const [index, line] of lines.entries()) {
if (open === undefined) {
const opening = OPENING.exec(line.text)
if (opening === null) continue
const marker = opening[0].trimStart()
const info = line.text.slice(opening[0].length).trim()
// A backtick fence's info string cannot contain a backtick; such a line is inline code.
if (marker.startsWith('`') && info.includes('`')) continue
const space = info.search(/[ \t]/u)
open = { language: space === -1 ? info : info.slice(0, space), marker, opening: line, content: [], first: index }
} else if (closes(line.text, open.marker)) {
fences.push({ ...open, closing: line, last: index })
open = undefined
} else {
open.content.push(line)
}
}
if (open !== undefined) fences.push({ ...open, closing: undefined, last: lines.length - 1 })
return fences
}
/**
* Find the fenced code blocks whose language starts with `dsh-`, following CommonMark for fences outside any
* other fence. An opening line has at most three spaces of indentation, three or more backticks or tildes, and an
* info string whose first word is the language; a backtick fence's info string contains no backtick. The first
* later line with at most three spaces of indentation, at least as many of the same character and nothing else
* but spaces or tabs closes it. Lines inside a fence of any language are its content, and fence characters after
* other text on a line open nothing. Block quotes and list items are not parsed as containers. Lines may end with
* `\n` or `\r\n`.
* @param text - assistant answer text.
* @returns the directive fences in text order, including an unclosed last fence.
*/
export function scanDirectiveFences(text: string): readonly DirectiveFence[] {
return fencesOf(linesOf(text)).flatMap(fence => fence.language.startsWith(DIRECTIVE_PREFIX) ? [{
language: fence.language, start: fence.opening.start,
end: fence.closing === undefined ? text.length : fence.closing.end, closed: fence.closing !== undefined,
body: fence.content.map(line => line.text).join('\n'),
}] : [])
}
function objectOf(body: string): object | undefined {
let value: unknown
try {
value = JSON.parse(body)
} catch {
// JSON.parse throws SyntaxError for a malformed block, which creates no block; the answer stays as written.
return undefined
}
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined
}
/**
* Parse every JSON object inside a closed `dsh-action` fence found by {@link scanDirectiveFences}, in order.
* Malformed JSON, arrays, scalars and unclosed fences create no block; callers validate the fields of each object.
* @param text - assistant answer text.
* @returns parsed objects, in answer order.
*/
export function actionBlocksOf(text: string): unknown[] {
return scanDirectiveFences(text).flatMap((fence) => {
const value = fence.closed && fence.language === ACTION_BLOCK_LANGUAGE ? objectOf(fence.body.trim()) : undefined
return value === undefined ? [] : [value]
})
}
/**
* Read the action a parsed block names, so a card experiment can tell its own blocks from another's.
* @param block - a value returned by {@link actionBlocksOf}.
* @returns the non-blank `action` string, or undefined when the block names none.
*/
export function actionOf(block: unknown): string | undefined {
if (typeof block !== 'object' || block === null || !('action' in block)) return undefined
const { action } = block
return typeof action === 'string' && action.trim() !== '' ? action : undefined
}
/**
* Remove the lines of each fence found by {@link scanDirectiveFences}, from the opening line through the closing
* line and its line ending. Other text is unchanged with two exceptions at a removed fence preceded by a blank
* line: a blank line directly after the fence is removed too, and when nothing follows the fence, the blank line
* before it is removed.
* @param text - assistant answer text.
* @param options - `streaming` also removes an unclosed last fence, whose rest has not arrived yet; otherwise an
* unclosed fence stays in the text.
* @returns the text to render.
*/
export function hideDirectiveFences(text: string, options: { readonly streaming?: boolean } = {}): string {
const lines = linesOf(text)
const hidden = new Set<number>()
for (const fence of fencesOf(lines)) {
if (!fence.language.startsWith(DIRECTIVE_PREFIX) || fence.closing === undefined && options.streaming !== true) continue
for (let index = fence.first; index <= fence.last; index++) hidden.add(index)
let before = fence.first - 1
while (hidden.has(before)) before--
const previous = lines[before]
if (previous === undefined || !BLANK.test(previous.text)) continue
const next = lines[fence.last + 1]
if (next === undefined) hidden.add(before)
else if (BLANK.test(next.text)) hidden.add(fence.last + 1)
}
if (hidden.size === 0) return text
return lines.flatMap((line, index) => hidden.has(index) ? [] : [text.slice(line.start, line.end)]).join('')
}
@@ -1,126 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ACTION_BLOCK_LANGUAGE, actionBlocksOf, actionOf, hideDirectiveFences, scanDirectiveFences } from '../src/index.ts'
const page = { action: 'create_personalized_page', title: 'A', brief: 'B', slot: 'page' }
const json = JSON.stringify(page)
const block = ['```dsh-action', json, '```'].join('\n')
describe('actionBlocksOf', () => {
it('reads well-formed dsh-action objects in order and skips malformed or non-object fences', () => {
const text = [
'先回答问题。', '',
`\`\`\`${ACTION_BLOCK_LANGUAGE}`, json, '```', '',
'```dsh-action json', '[1, 2]', '```', '',
'```dsh-action', '"text"', '```', '',
'```dsh-action', 'null', '```', '',
'```dsh-action', '{not json', '```', '',
'```ts', '{"action":"ignored"}', '```', '',
'```dsh-note', '{"action":"ignored"}', '```', '',
'```dsh-action', '{"action":"other","title":"C"}', '```',
].join('\n')
expect(actionBlocksOf(text)).toEqual([page, { action: 'other', title: 'C' }])
expect(actionBlocksOf('没有围栏')).toEqual([])
})
it('reads the valid block after a broken one', () => {
expect(actionBlocksOf(['```dsh-action', '{"action":', '```', '', block].join('\n'))).toEqual([page])
})
it('recognizes fences indented by up to three spaces but not by four', () => {
for (const indent of [' ', ' ', ' ']) {
expect(actionBlocksOf([`${indent}\`\`\`dsh-action`, json, `${indent}\`\`\``].join('\n'))).toEqual([page])
}
expect(actionBlocksOf([' ```dsh-action', json, ' ```'].join('\n'))).toEqual([])
})
it('closes a fence only with a line of at least as many of the same character and nothing but trailing spaces', () => {
expect(actionBlocksOf(['````dsh-action', json, '````'].join('\n'))).toEqual([page])
expect(actionBlocksOf(['````dsh-action', json, '````` '].join('\n'))).toEqual([page])
expect(actionBlocksOf(['```dsh-action ', json, '``` \t'].join('\n'))).toEqual([page])
expect(actionBlocksOf(['~~~dsh-action `quoted`', json, '~~~'].join('\n'))).toEqual([page])
expect(actionBlocksOf(['````dsh-action', json, '```', '````'].join('\n'))).toEqual([])
expect(actionBlocksOf(['```dsh-action', json, '~~~', '```'].join('\n'))).toEqual([])
expect(actionBlocksOf(['```dsh-action', json, '``` and more text'].join('\n'))).toEqual([])
expect(actionBlocksOf(['```dsh-action', json, '``` and more text', '```'].join('\n'))).toEqual([])
})
it('treats a dsh-action example inside another fence as that fence’s content', () => {
const markdown = ['````markdown', block, '````'].join('\n')
const tilde = ['~~~', block, '~~~'].join('\n')
const unclosedCode = ['```ts', 'const a = 1', block].join('\n')
for (const text of [markdown, tilde, unclosedCode]) expect(actionBlocksOf(text)).toEqual([])
expect(actionBlocksOf([markdown, '', tilde, '', block].join('\n'))).toEqual([page])
})
it('ignores an unclosed fence, a fence glued to a sentence and an inline code span', () => {
expect(actionBlocksOf(['```dsh-action', json].join('\n'))).toEqual([])
expect(actionBlocksOf(['方案如下:```dsh-action', json, '```'].join('\n'))).toEqual([])
expect(actionBlocksOf(['```dsh-action``` 是标记', json, '```'].join('\n'))).toEqual([])
})
it('reads answers with CRLF line endings', () => {
expect(actionBlocksOf(['回答', '', '```dsh-action', json, '``` ', ''].join('\r\n'))).toEqual([page])
})
})
describe('scanDirectiveFences', () => {
it('reports the offsets, closure and body of each top-level dsh- fence', () => {
const text = 'Intro\n\n```dsh-action\n{"a":1}\n```\n\n```ts\ncode\n```\n```dsh-note extra\nline 1\nline 2\n'
expect(scanDirectiveFences(text)).toEqual([
{ language: 'dsh-action', start: text.indexOf('```dsh-action'), end: text.indexOf('\n\n```ts') + 1, closed: true, body: '{"a":1}' },
{ language: 'dsh-note', start: text.indexOf('```dsh-note'), end: text.length, closed: false, body: 'line 1\nline 2' },
])
const crlf = '```dsh-action\r\n{}\r\n```\r\nafter'
expect(scanDirectiveFences(crlf)).toEqual([{ language: 'dsh-action', start: 0, end: crlf.indexOf('after'), closed: true, body: '{}' }])
expect(scanDirectiveFences('```dsh-action\n```')).toEqual([{ language: 'dsh-action', start: 0, end: 17, closed: true, body: '' }])
expect(scanDirectiveFences('``` dsh-action\n{}\n```')).toMatchObject([{ language: 'dsh-action', closed: true }])
expect(scanDirectiveFences('```\n{}\n```\n```python\n```')).toEqual([])
})
})
describe('hideDirectiveFences', () => {
it('removes a closed fence and leaves one blank line where the block was', () => {
expect(hideDirectiveFences(`A\n\n${block}\n\nB`)).toBe('A\n\nB')
expect(hideDirectiveFences(`A\n\n${block}\nB`)).toBe('A\n\nB')
expect(hideDirectiveFences(`A\n${block}\n\nB`)).toBe('A\n\nB')
expect(hideDirectiveFences(`A\n${block}\nB`)).toBe('A\nB')
expect(hideDirectiveFences(`${block}\n\n后记`)).toBe('\n后记')
expect(hideDirectiveFences(`A\n\n${block}\n\n\`\`\`dsh-note\nx\n\`\`\`\n\nB`)).toBe('A\n\nB')
})
it('drops the blank line a removed last fence leaves', () => {
expect(hideDirectiveFences(`方案在下方。\n\n${block}`)).toBe('方案在下方。\n')
expect(hideDirectiveFences(`方案在下方。\n\n${block}\n`)).toBe('方案在下方。\n')
expect(hideDirectiveFences(`方案在下方。\n${block}`)).toBe('方案在下方。\n')
expect(hideDirectiveFences(block)).toBe('')
})
it('removes an unclosed last fence only while it is streaming', () => {
const streaming = '说明\n\n```dsh-action\n{"action":"create_pers'
expect(hideDirectiveFences(streaming, { streaming: true })).toBe('说明\n')
expect(hideDirectiveFences(streaming)).toBe(streaming)
expect(hideDirectiveFences(streaming, { streaming: false })).toBe(streaming)
expect(hideDirectiveFences(`${block}\n\n${streaming}`)).toBe(`\n${streaming}`)
})
it('keeps nested examples, glued fences and the whitespace around other code blocks', () => {
const prose = 'Intro\n\n\n```ts\n const a = 1\n\n```\n\n\nOutro '
expect(hideDirectiveFences(prose)).toBe(prose)
expect(hideDirectiveFences(`${prose}\n${block}`)).toBe(`${prose}\n`)
for (const text of [['````markdown', block, '````'].join('\n'), ['~~~', block, '~~~'].join('\n'), ['方案:```dsh-action', json, '```'].join('\n')]) {
expect(hideDirectiveFences(text, { streaming: true })).toBe(text)
}
expect(hideDirectiveFences('没有围栏')).toBe('没有围栏')
})
it('keeps CRLF line endings around a removed fence', () => {
expect(hideDirectiveFences(['A', '', '```dsh-action', json, '```', '', 'B'].join('\r\n'))).toBe('A\r\n\r\nB')
})
})
describe('actionOf', () => {
it('names the action of a block only when it is a non-blank string', () => {
expect(actionOf({ action: 'enable_official_plugin', name: 'x' })).toBe('enable_official_plugin')
for (const value of [{ action: ' ' }, { action: 1 }, { title: 'A' }, null, 'text', 3]) expect(actionOf(value)).toBeUndefined()
})
})
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}
-9
View File
@@ -3921,9 +3921,6 @@ importers:
'@deepseek-ai/dsh-tool-executor-ptc':
specifier: workspace:*
version: link:../../core/tool-executor-ptc
'@deepseek-ai/dsh-util-action-block':
specifier: workspace:*
version: link:../../util/action-block
'@deepseek-ai/dsh-util-values':
specifier: workspace:*
version: link:../../util/values
@@ -16201,12 +16198,6 @@ importers:
specifier: workspace:*
version: link:../protocol
packages/util/action-block:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
version: link:../../../vendor/cordis
packages/util/atomic-write:
devDependencies:
'@deepseek-ai/cordis':
-4
View File
@@ -234,10 +234,6 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
})
it('inlines the zero-dependency dsh-action fence utility', () => {
expect(resolveId('@deepseek-ai/dsh-util-action-block')).toBeNull()
})
it('inlines the stateless file-drag entry without admitting its subpaths or UI plugin', () => {
expect(resolveId('@deepseek-ai/dsh-util-file-drag')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-util-file-drag/internal')).toThrow(/purity/)
-1
View File
@@ -90,7 +90,6 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
'packages/test-support/remote-mock': 'Browser-side test infrastructure; mounts nothing into a product composition.',
'packages/typert/generator': 'Build-time generator run outside any agent runtime.',
'packages/typert/protocol': 'Compiler-independent protocol declarations.',
'packages/util/action-block': 'Zero-dependency fenced-block parser; exports helpers without a plugin entry.',
'packages/util/atomic-write': 'Zero-dependency filesystem write utility.',
'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.',
'packages/util/crypto': 'Zero-dependency identifier minting utility.',
@@ -36,7 +36,6 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.',
'packages/util/action-block': 'The package only parses and hides fenced blocks in answer text; the card experiments that consume it own the model-facing contract.',
'packages/util/values': 'The package only validates, snapshots, compares, freezes, or rejects caller-owned values; consumers own every model-facing use.',
'packages/boot/official-bundles': 'The package only lists bundle package names and their display metadata; the loaded plugin tree owns model context.',
'packages/util/code-language': 'The package only maps a filename suffix to a syntax-highlighting language id; the read consumer owns the persisted hint.',
-1
View File
@@ -368,7 +368,6 @@
"@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"],
"@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"],
"@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"],
"@deepseek-ai/dsh-util-action-block": ["./packages/util/action-block/src"],
"@deepseek-ai/dsh-util-values": ["./packages/util/values/src"],
"@deepseek-ai/dsh-util-code-language": ["./packages/util/code-language/src"],
// util/ folders are role-named without the util- prefix their npm names carry,
-1
View File
@@ -326,7 +326,6 @@
{ "path": "./packages/util/crypto" },
{ "path": "./packages/util/deque" },
{ "path": "./packages/util/chunked-list" },
{ "path": "./packages/util/action-block" },
{ "path": "./packages/util/values" },
{ "path": "./packages/util/workspace-path" },
{ "path": "./packages/util/file-drag" },