feat(web): split context injection into producer-declared forms

Injected context reached the transcript as one anonymous shape whose
expanded body was the whole message serialized as inline JSON, so the
escaping collapsed the only readable part — the model-facing prose —
into a single line.

`MessageSource` gains an optional producer-declared `form`, a small
semantic vocabulary of information shapes independent of `kind`: kind
says who produced the context, form says what shape it is, so several
producers may share one presentation. Two values ship.

`instructions` (workspace-context) lists the reconciled files above
their text and keeps the `<system-reminder>` framing verbatim, because
the framing is part of what the model read. `catalog` moves dsh-tool-skill
off the shared plugin kind onto a `skill-catalog` source carrying the
published name/description entries, and the body lists those instead of
re-parsing `<available_skills>` out of the prose. Catalog identity moves
with it: the republish digest now covers the durable entries, deleting
the text-slicing that recovered them from a logged message.

Everything else renders the opaque body — the model-facing text with its
real line breaks, then the remaining provenance as fields. That is the
documented default, not a leftover: a resumed, forked, or foreign log
must render whether or not its producer is mounted here, which is why
the classification lives in the durable source rather than a client-side
table keyed by producer.
This commit is contained in:
creatixchu
2026-08-05 12:15:03 +08:00
parent e5d4ae72fc
commit 340c9ba576
51 changed files with 779 additions and 174 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 17b3e73b7c2efa4aa71ff24c9e5a45eaf81771de
README.zh.md: 5fc5981ea3a1f896dddb008271c323e759505dad
README.md: 0d9935571bf84a3ce299a2dd37a81894412ab536
README.zh.md: 6b6c129ee24ee6a82a21e50924b170c4b9725445

View File

@@ -28,7 +28,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.

View File

@@ -28,7 +28,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript(文本记录)
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。

View File

@@ -53,7 +53,9 @@ export type {
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type { ContextProvenanceView, ContextRole } from './sessions/context-provenance.ts'
export type {
ContextProvenanceView, ContextRole, KnownContextForm,
} from './sessions/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'

View File

@@ -11,7 +11,7 @@ import type {
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import { contextProvenance } from '../sessions/context-provenance.ts'
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
@@ -128,6 +128,7 @@ function materializeNode(
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
return {

View File

@@ -89,3 +89,28 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
return { role: 'inject', label: kind }
}
}
/**
* Context forms this UI version renders with a dedicated presentation. The
* durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
* unrecognized or absent value degrades to the opaque presentation rather than
* dropping the row, so a log written by a newer or foreign producer still
* renders.
*/
const KNOWN_FORMS = ['instructions', 'catalog'] as const
/** One durable context form this UI version knows how to present. */
export type KnownContextForm = typeof KNOWN_FORMS[number]
/**
* Read the producer-declared form off one durable message source.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the form when this UI version presents it, otherwise null (opaque).
*/
export function contextForm(source: unknown): KnownContextForm | null {
const record = asRecord(source)
const form = record === null ? null : readString(record, 'form')
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
? form as KnownContextForm
: null
}

View File

@@ -12,7 +12,7 @@ import type {
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView } from './context-provenance.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -126,6 +126,8 @@ export interface ContextMessageNode {
source: unknown
/** Role and producer name projected from `source` ({@link contextProvenance}). */
provenance: ContextProvenanceView
/** Producer-declared information form ({@link contextForm}); null presents as opaque. */
form: KnownContextForm | null
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */

View File

@@ -22,7 +22,7 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextProvenance } from './context-provenance.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
@@ -62,6 +62,7 @@ function materializeNode(
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
return {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: f4cb4617f98a43c2a1789954eb794a1a0720be21
README.zh.md: bf092073f59b971fc71de94106a5108cef86aada
README.md: c5f655c671fd426a82b71d0c55b46f7060a9463a
README.zh.md: 1a502c02b1dfa074cd691153e0b24f3abed0d33b

View File

@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).

View File

@@ -12,7 +12,7 @@
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。

View File

@@ -0,0 +1,96 @@
/* Expanded context bodies: one code-block surface shared by every form, so the
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
.text {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Provenance beneath the text: dimmer than the content it describes. */
.fields {
display: flex;
flex-direction: column;
gap: 2px;
margin: 8px 0 0;
padding-top: 8px;
border-top: 1px solid var(--dsw-alias-line-secondary);
}
.field {
display: flex;
gap: 8px;
min-width: 0;
}
.fieldKey {
flex: none;
min-width: 96px;
color: var(--dsw-alias-label-caption);
}
.fieldValue {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
/* instructions: the reconciled files, above their text. */
.files {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.file {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.filePath {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.fileAction {
color: var(--dsw-alias-label-caption);
}
/* catalog: one row per published entry. */
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 8px;
min-width: 0;
}
.entryName {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.entryDescription {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,227 @@
// Expanded bodies for the context disclosure, one per durable context form.
// The producer declares the form; this module only chooses a presentation for
// it. Every form falls back to OpaqueBody, which is the documented default for
// an absent, unknown, or malformed form — a resumed or foreign log must render
// even when this UI version has never seen its producer.
import type { ReactNode } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
type Translate = ChatViewSlotProps['t']
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/**
* Concatenated text of the content blocks, with the non-text blocks kept aside.
* Context is text in practice (every producer injects one text block), but the
* block union is merge-extensible, so an unknown block keeps its own fallback
* rather than vanishing.
*/
function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } {
const texts: string[] = []
const rest: unknown[] = []
for (const block of content) {
if (block.type === 'text') texts.push(block.text)
else rest.push(block)
}
return { text: texts.join('\n'), rest }
}
/** The model-facing text, truncated to the display bound. */
function boundedText(text: string, t: Translate): string {
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}
/** One source field rendered as a value row; nested shapes stay compact JSON. */
function fieldValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}
/**
* Provenance fields as a key/value list. `kind` is omitted because the row
* header already names the producer, and `form` because the presentation the
* reader is looking at IS that value.
*/
function SourceFields({ source }: { source: unknown }): ReactNode {
const record = asRecord(source)
if (record === null) return null
const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form')
if (rows.length === 0) return null
return (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value)}</dd>
</div>
))}
</dl>
)
}
/**
* Default presentation: the model-facing text as text, with its real line
* breaks, and the remaining provenance beneath it. This is what every form
* this UI version does not recognize renders as.
* @param props - Durable content, its source, and the locale seat.
* @returns The opaque context body.
*/
export function OpaqueBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const { text, rest } = partitionContent(content)
return (
<>
{text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
{rest.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
<SourceFields source={source} />
</>
)
}
/** One reconciled instruction file, as the durable source records it. */
interface InstructionChange {
action: string
path: string
digest?: string
}
/** Instruction changes read off the source; empty when the shape is unusable. */
function instructionChanges(source: unknown): InstructionChange[] {
const record = asRecord(source)
const list = record === null ? undefined : record['changes']
if (!Array.isArray(list)) return []
const changes: InstructionChange[] = []
for (const entry of list) {
const change = asRecord(entry)
if (change === null) continue
const path = change['path']
if (typeof path !== 'string' || path === '') continue
const action = change['action']
const digest = change['digest']
changes.push({
action: typeof action === 'string' ? action : '',
path,
...typeof digest === 'string' ? { digest } : {},
})
}
return changes
}
/**
* `instructions` form: the files this context reconciled, then their text.
*
* The text keeps its `<system-reminder>` framing verbatim — the framing is part
* of what the model read, so hiding it would misreport the request.
* @param props - Durable content, its source, and the locale seat.
* @returns The instructions context body.
*/
export function InstructionsBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const changes = instructionChanges(source)
const baseline = asRecord(source)?.['baseline'] === true
const { text, rest } = partitionContent(content)
return (
<>
{changes.length > 0 && (
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)}
</span>
</li>
))}
</ul>
)}
{text !== '' && <pre className={css.text} data-context-text>{boundedText(text, t)}</pre>}
{rest.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
</>
)
}
/** One catalog entry, as the durable source records it. */
interface CatalogEntry {
name: string
description: string
}
/** Catalog entries read off the source; empty when the shape is unusable. */
function catalogEntries(source: unknown): CatalogEntry[] {
const record = asRecord(source)
const list = record === null ? undefined : record['entries']
if (!Array.isArray(list)) return []
const entries: CatalogEntry[] = []
for (const item of list) {
const entry = asRecord(item)
if (entry === null) continue
const name = entry['name']
if (typeof name !== 'string' || name === '') continue
const description = entry['description']
entries.push({ name, description: typeof description === 'string' ? description : '' })
}
return entries
}
/**
* `catalog` form: the published entries as a list, read from the source rather
* than re-parsed out of the model-facing prose.
*
* A catalog whose source carries no usable entries falls through to the opaque
* body, so an older or hand-edited log still shows its text.
* @param props - Durable content, its source, and the locale seat.
* @returns The catalog context body.
*/
export function CatalogBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const entries = catalogEntries(source)
if (entries.length === 0) return <OpaqueBody content={content} source={source} t={t} />
return (
<ul className={css.entries} data-context-entries>
{entries.map(entry => (
<li key={entry.name} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
)
}

View File

@@ -45,7 +45,6 @@
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -1,55 +1,19 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import { CatalogBody, InstructionsBody, OpaqueBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
const MAX_CHARS = 20_000
function inlineJson(payload: unknown): string {
const raw = JSON.stringify(payload)
let formatted = ''
let quoted = false
let escaped = false
for (let index = 0; index < raw.length; index++) {
const char = raw.charAt(index)
if (quoted) {
formatted += char
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') quoted = false
continue
}
if (char === '"') {
quoted = true
formatted += char
continue
}
if (char === '{' || char === '[') {
formatted += char
const close = char === '{' ? '}' : ']'
if (raw[index + 1] !== close) formatted += ' '
continue
}
if (char === '}' || char === ']') {
const open = char === '}' ? '{' : '['
if (raw[index - 1] !== open) formatted += ' '
formatted += char
continue
}
formatted += char === ':' || char === ',' ? `${char} ` : char
}
return formatted
}
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** Role and producer name projected from the durable source. */
provenance: ContextMessageNode['provenance']
/** Producer-declared information form; null renders the opaque body. */
form: ContextMessageNode['form']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -60,18 +24,13 @@ export interface ContextInjectionRowProps {
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* @param props - Durable content, its projected provenance, and the locale seat.
* @returns A collapsed context row with a bounded JSON body.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected provenance and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, provenance, t }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source, t])
return (
<DisclosureRow
@@ -94,7 +53,13 @@ export function ContextInjectionRow({ content, source, provenance, t }: ContextI
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
<div className={css.body} data-context-injection-body data-context-form={form ?? undefined}>
{form === 'instructions'
? <InstructionsBody content={content} source={source} t={t} />
: form === 'catalog'
? <CatalogBody content={content} source={source} t={t} />
: <OpaqueBody content={content} source={source} t={t} />}
</div>
</DisclosureRow>
)
}

View File

@@ -257,6 +257,7 @@ export const MessageItem = memo(function MessageItem({
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
t={t}
/>
)

View File

@@ -55,6 +55,9 @@ export const zh = {
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.updated': '已更新',
'message.context.instructions.removed': '已移除',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -170,6 +173,9 @@ export const en = {
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.updated': 'updated',
'message.context.instructions.removed': 'removed',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',

View File

@@ -227,14 +227,15 @@ describe('MessageItem arms', () => {
expect(fork).toHaveBeenCalledWith(2)
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
const ctxView = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
content: [{ type: 'text', text: 'line one\n\nline two' }],
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
provenance: { role: 'inject', label: 'fixture' },
form: null,
} as never}
/>,
)
@@ -245,29 +246,84 @@ describe('MessageItem arms', () => {
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
)
// An unknown form renders the opaque body: the model-facing text keeps its
// real line breaks instead of being escaped into one JSON line, and the
// remaining provenance follows it as fields.
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
.toBe('line one\n\nline two')
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'empty', 'list'])
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
})
it('context preserves the bounded JSON truncation contract', () => {
it('the instructions form names the files it reconciled above their text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
source: null,
provenance: { role: 'inject', label: null },
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
],
},
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
.toMatch(/… 已截断,共 \d+ 字符$/)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
// The `<system-reminder>` framing is part of what the model read, so the
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
expect(view.container.querySelector('[data-context-text]')?.textContent)
.toContain('<system-reminder>')
})
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
expect(view.container.querySelector('[data-context-text]')).toBeNull()
})
it('a catalog whose source carries no entries falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries: [] },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
})
it('a recalled session titles its row by role and names the sessions it read', () => {
@@ -278,6 +334,7 @@ describe('MessageItem arms', () => {
content: [{ type: 'text', text: 'snapshot' }],
source: { kind: 'session-reference', version: 1, references: [{ label: '重构 loader' }] },
provenance: { role: 'recall', label: '重构 loader' },
form: null,
} as never}
/>,
)
@@ -293,6 +350,7 @@ describe('MessageItem arms', () => {
content: [{ type: 'text', text: 'instructions' }],
source: { kind: 'workspace-instructions', changes: [{ path: 'AGENTS.md' }] },
provenance: { role: 'inject', label: 'AGENTS.md' },
form: null,
} as never}
/>,
)
@@ -307,6 +365,7 @@ describe('MessageItem arms', () => {
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }], source: null,
provenance: { role: 'inject', label: null },
form: null,
} as never}
/>,
)

View File

@@ -452,6 +452,7 @@ describe('ChatView', () => {
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
provenance: { role: 'inject', label: null },
form: null,
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -14,6 +14,7 @@ const checkpoint = {
content: [],
source: { kind: 'plugin', plugin: 'compact' },
provenance: { role: 'inject', label: 'compact' },
form: null,
} as ConversationNode
const abandoned = {

View File

@@ -126,6 +126,7 @@ export function apply(ctx: Context, config: Config): void {
content: baselineMessage.content,
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [...baseline.changes.values()],
},

View File

@@ -39,6 +39,8 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
/** Every workspace context carries instructions read out of a file (the `instructions` context form). */
form: 'instructions'
/** Marks the complete startup/resume baseline rather than a later delta. */
baseline?: true
changes: WorkspaceInstructionChange[]
@@ -87,7 +89,7 @@ export interface ReconciledInstructionContext {
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage {
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', changes },
source: { kind: 'workspace-instructions', form: 'instructions', changes },
})
}

View File

@@ -219,6 +219,7 @@ function workspaceChangeContext(scope: string, digest: string): UserMessage {
content: [{ type: 'text', text: `instructions for ${scope}` }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }],
},
})
@@ -974,6 +975,7 @@ describe('workspace context request injection', () => {
role: 'user',
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
@@ -1951,6 +1953,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{
action: 'set',
scope: sk('pkg', 'AGENTS.md'),
@@ -2262,6 +2265,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(changed)?.source).toMatchObject({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
@@ -2487,6 +2491,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(removed)?.source).toEqual({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
@@ -2887,6 +2892,7 @@ describe('dynamic nested workspace context injection', () => {
],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [
null,
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
@@ -3073,6 +3079,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
@@ -3505,7 +3512,7 @@ describe('workspace context pending state', () => {
// must not mint per-session pending state.
expect(commitPendingInstructionContexts(agent, [createUserMessage({
content: [],
source: { kind: 'workspace-instructions', changes: [] },
source: { kind: 'workspace-instructions', form: 'instructions', changes: [] },
})], pending)).toEqual([])
expect(pending.has(agent.session)).toBe(false)

View File

@@ -1809,6 +1809,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ContentBlockType',
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
{
name: 'ContextForm',
declaration: 'export type ContextForm = \'instructions\' | \'catalog\';',
},
{
name: 'ContextFormed',
declaration: 'export interface ContextFormed {\n readonly form?: ContextForm;\n}',
},
{
name: 'ContinuableCreateRequest',
declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}',
@@ -2135,7 +2143,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}',
},
{
name: 'ModelMessageSource',

View File

@@ -467,9 +467,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(loadedRequest).toContain('Use the freshly loaded body.')
const transcript = handle.agent.session.events.flatMap<Record<string, unknown>>((event) => {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'dsh-tool-skill') {
if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') {
return [{
type: event.type,
source: event.data.source,
@@ -503,8 +501,14 @@ describe('dsh-agent-spine-demo bundle', () => {
},
{
"source": {
"kind": "plugin",
"plugin": "dsh-tool-skill",
"entries": [
{
"description": "Hot-added skill",
"name": "hot-skill",
},
],
"form": "catalog",
"kind": "skill-catalog",
},
"text": "<system-reminder>
A skill is a reusable set of task-specific instructions. The following skills are available in this session:

View File

@@ -29,13 +29,40 @@ export interface ToolMessageSource {
callId: CallId
}
/**
* What SHAPE of information a producer-supplied context carries, declared by
* the producer beside its provenance.
*
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
* of thing it is*, and the two axes are deliberately independent — several
* producers share one form (three snapshot producers today), and one producer
* may emit more than one form over a session.
*
* The vocabulary is SEMANTIC, never visual: a value states that the content is
* a file's instructions or a catalog of available items, and a consumer decides
* what that looks like. Colors, icons, ordering, and collapse defaults are the
* consumer's business and must not enter this union. It grows one value at a
* time as producers gain the structured fields their form needs; an absent or
* unknown value is the documented default, presented as opaque content.
*/
export type ContextForm =
/** Instructions read out of workspace files the model is expected to follow. */
| 'instructions'
/** A catalog of items available in this session, republished as it changes. */
| 'catalog'
/** Optional producer-declared {@link ContextForm}, mixed into the source shapes that carry one. */
export interface ContextFormed {
readonly form?: ContextForm
}
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.
*/
export interface MessageSourceMap {
user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string }
plugin: { kind: 'plugin'; plugin: string } & ContextFormed
model: ModelMessageSource
tool: ToolMessageSource
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce
README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3
README.md: deccda0ece1ffe2cbdb576a8af3801f28695d8d5
README.zh.md: 67ec0d32759c5768af32b5a505e7a080e19eb396

View File

@@ -10,7 +10,7 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools
At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `<system-reminder>` before the request. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog.
The digest covers the exact rendered text between the `<available_skills>` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `<system-reminder>` framing — written for the model — cannot decide whether a republish is needed, and a consumer presenting the list never re-parses the `<available_skills>` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary.
The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned.

View File

@@ -10,7 +10,7 @@
每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 `<system-reminder>`。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。
该 digest 覆盖 `<available_skills>` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
每条目录消息都携带 `skill-catalog` 来源:一份 `catalog` 形态的上下文,其 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖的是这些持久条目而非渲染出的散文,因此为模型书写的 `<system-reminder>` 包装无法左右是否需要重新发布,消费方展示该列表时也不必再解析 `<available_skills>` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见的 `skill-catalog` 消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。
如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。

View File

@@ -22,9 +22,37 @@ export const name = 'tool-skill'
export const inject = ['agents', 'tools', 'skills']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
const CATALOG_ENTRIES_START = '<available_skills>\n'
const CATALOG_ENTRIES_END = '</available_skills>'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
/**
* Durable provenance for one published session skill catalog. The catalog is a
* `catalog`-form context, so it records the entries it published beside the
* model-facing prose: a consumer presenting the list must not re-parse the
* `<available_skills>` block, whose framing exists for the model.
*/
export interface SkillCatalogSource {
readonly kind: 'skill-catalog'
readonly form: 'catalog'
/** Marks a replacement catalog rather than this session's first publication. */
readonly update?: true
/** Exactly the entries this message published, in catalog order. */
readonly entries: readonly { readonly name: string; readonly description: string }[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'skill-catalog': SkillCatalogSource
}
}
/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
function catalogSourceEntries(
skills: SkillSummary[],
descriptionMaxLength: number,
): SkillCatalogSource['entries'] {
return skills.map(skill => ({
name: skill.name,
description: catalogDescription(skill.description, descriptionMaxLength),
}))
}
/** Model-facing skill catalog configuration. */
export interface Config {
@@ -142,13 +170,14 @@ export function apply(ctx: Context, config: Config = {}): void {
signal.throwIfAborted()
if (!snapshot.complete) return
const skills = snapshot.skills.filter(isModelInvocable)
const digest = catalogDigest(skills, catalogDescriptionMaxLength)
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
const digest = digestCatalogEntries(entries)
const history = catalogHistory(agent)
if (history.visibleDigest === digest) return
if (!history.published && skills.length === 0) return
const catalog = history.published
? renderCatalogUpdate(skills, catalogDescriptionMaxLength)
: renderCatalogMessage(skills, catalogDescriptionMaxLength)
? renderCatalogUpdate(entries)
: renderCatalogMessage(entries)
agent.inject(catalog)
})
}
@@ -199,8 +228,7 @@ function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceB
}
}
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
const entries = renderCatalogEntries(skills, descriptionMaxLength)
function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage {
return createUserMessage({
content: [{
type: 'text',
@@ -209,20 +237,23 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
'',
'<available_skills>',
...entries,
...renderCatalogEntries(entries),
'</available_skills>',
'',
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
'</system-reminder>',
].join('\n'),
}],
source: PLUGIN_SOURCE,
source: {
kind: 'skill-catalog',
form: 'catalog',
entries,
},
})
}
function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage {
const entries = renderCatalogEntries(skills, descriptionMaxLength)
const availability = skills.length === 0
function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage {
const availability = entries.length === 0
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
]
@@ -237,28 +268,36 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
'',
'<available_skills>',
...entries,
...renderCatalogEntries(entries),
'</available_skills>',
'',
...availability,
'</system-reminder>',
].join('\n'),
}],
source: PLUGIN_SOURCE,
source: {
kind: 'skill-catalog',
form: 'catalog',
update: true,
entries,
},
})
}
function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] {
return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
/** Model-facing catalog lines, projected from the same entries the source records. */
function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`)
}
function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string {
return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n'))
}
function digestCatalogEntries(entries: string): string {
/**
* Catalog identity over the durable entry list rather than the rendered prose.
* The entries are what changes; the surrounding `<system-reminder>` framing is
* written for the model and must not decide whether a republish is needed.
*/
function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
const canonical = entries.map(entry => `${entry.name}\u0000${entry.description}`).join('\n')
return createHash('sha256')
.update(entries)
.update(canonical)
.digest('hex')
}
@@ -270,30 +309,14 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
// The loop bounds prove the read-only event view contains this index.
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (event.type !== 'user/message'
|| event.data.source.kind !== 'plugin'
|| event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue
const digest = catalogContentDigest(event.data.content)
if (digest === undefined) continue
if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
const digest = digestCatalogEntries(event.data.source.entries)
published = true
if (visible.has(event.seq)) return { visibleDigest: digest, published }
}
return { published }
}
function catalogContentDigest(content: UserMessage['content']): string | undefined {
if (content.length !== 1 || content[0]?.type !== 'text') return undefined
const text = content[0].text
const start = text.indexOf(CATALOG_ENTRIES_START)
if (start === -1) return undefined
const entriesStart = start + CATALOG_ENTRIES_START.length
const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
if (end === -1) return undefined
const renderedEntries = text.slice(entriesStart, end)
const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
return digestCatalogEntries(entries)
}
function catalogDescription(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= maxLength

View File

@@ -94,8 +94,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number):
function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
return session.events.filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'dsh-tool-skill')
&& event.data.source.kind === 'skill-catalog')
}
function catalogContent(entries: string[]): Message['content'] {
@@ -215,7 +214,15 @@ describe('dsh-tool-skill', () => {
{
id: expect.any(String) as unknown,
role: 'user',
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [
{ name: 'a-skill', description: 'Use {{placeholder}} &lt;safely&gt; &amp; carefully.' },
{ name: 'model-only-skill', description: 'Model-only skill.' },
{ name: 'z-skill', description: 'Long description Long description Long descript...' },
],
},
content: [{
type: 'text',
text: [
@@ -354,7 +361,12 @@ describe('dsh-tool-skill', () => {
expect(catalogMessages(session)).toHaveLength(3)
})
it('resumes from the latest valid visible catalog content', async () => {
it('resumes from the durable entries of the latest visible catalog', async () => {
// Catalog identity moved onto `source.entries` when the catalog became a
// `catalog`-form context: the model-facing prose no longer decides whether
// a republish is needed, so a seeded message is recognized by its source
// alone and malformed prose can no longer hide (or fake) a published
// catalog. A foreign-sourced message is not this plugin's catalog at all.
const home = await tempDir('tool-catalog-resume')
const ctx = await setup(home)
ctx.skills.register({
@@ -367,30 +379,36 @@ describe('dsh-tool-skill', () => {
const agent = sessionAgent(session)
openMessageTurn(session)
session.append('user/message', createUserMessage({
content: catalogContent(['- `old-skill`: Old skill']),
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
content: [{ type: 'text', text: 'prose a reader cannot rely on' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'old-skill', description: 'Old skill' }],
},
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'missing catalog markers' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<available_skills>\nmissing closing marker' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }],
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'reasoning', text: 'not a user-role catalog block' }],
content: catalogContent(['- `resumed-skill`: Resumed skill']),
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
}), { surfaceOp: 'append' })
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toHaveLength(6)
expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill')
// The seeded entries differ from the live snapshot, so one replacement
// lands; the foreign-sourced lookalike neither counts as published nor
// suppresses it.
expect(catalogMessages(session)).toHaveLength(2)
const latest = catalogMessages(session).at(-1)
expect(latest?.data.source).toMatchObject({
kind: 'skill-catalog',
form: 'catalog',
update: true,
entries: [{ name: 'resumed-skill', description: 'Resumed skill' }],
})
expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill')
// A second step over unchanged entries republishes nothing.
await fireStep(ctx, agent, 1, 2)
expect(catalogMessages(session)).toHaveLength(2)
})
it('re-establishes the current catalog after compaction hides its durable message', async () => {