mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): address review findings on producer-declared context forms
- `catalogHistory` validated its durable read. `agent.session.events` is a JSONL/SQLite seed on resume or fork, and seed validation guarantees only a source object with a non-empty `kind`; a `skill-catalog` record with missing or wrongly shaped `entries` threw inside the step listener, failing every later turn of that session. It is now skipped as an unrecognizable record, the posture the replaced content digest had, with a regression test over six malformed shapes. - The headless keyless smoke still filtered catalogs by the old plugin source, so the `built-bin-smoke` gate would not have found the catalog message. - Entries record the published description unescaped. The pseudo-XML escaping belongs to the `<available_skills>` frame and is applied when rendering it, so a description containing `<` no longer reaches the card as `<`. `escapeText` is injective, so republish semantics and the model-facing text are unchanged. - Adjacent text blocks join with no separator, matching how provider adapters flatten them; the body no longer shows a line break the model never saw. - Provenance fields are bounded like the text: an unknown producer may record an arbitrarily large value. - Both readers are all-or-nothing, and the row's form marker reports what rendered rather than what was declared, so a partly unreadable record cannot present a confident but incomplete account. - The catalog body consumes `update` as a replacement notice; the digest canonicalizes per entry as JSON, since every separator character is itself legal in a description. - `core.md` documents the form axis with a `ContextForm` type-equiv block, and both projections assert the wiring they duplicate.
This commit is contained in:
@@ -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 .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md
|
||||
2026-08-05-context-form-vocabulary.md: c2369a21a474e677eb324b6f8d2457091ee44e30
|
||||
2026-08-05-context-form-vocabulary.zh.md: 3c16279a4e36742eb7c5d60aa1c5ad9a2ae07cd6
|
||||
2026-08-05-context-form-vocabulary.md: 20577b0ad0eaa9eb8dc8528cedd18d15439ad299
|
||||
2026-08-05-context-form-vocabulary.zh.md: 154e8ae54fc6b27ce782533b4246d48190639128
|
||||
|
||||
@@ -23,10 +23,16 @@ The vocabulary is semantic, never visual. A value states that the content is a f
|
||||
|
||||
**`instructions`** — instructions read out of workspace files. `workspace-context` declares it on both the startup baseline and later deltas; its existing `changes[]` already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the `<system-reminder>` framing verbatim: the framing is part of what the model read, so hiding it would misreport the request.
|
||||
|
||||
**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement. The body lists those entries instead of re-parsing the `<available_skills>` block out of the prose.
|
||||
**`catalog`** — a catalog of items available this session, republished as it changes. `dsh-tool-skill` moves off the shared `plugin` kind to its own `skill-catalog` source carrying `entries` (the exact `name`/`description` pairs published) and `update` on a replacement, which the body renders as a replacement notice. The body lists those entries instead of re-parsing the `<available_skills>` block out of the prose.
|
||||
|
||||
Entries record the published fact **unescaped**. The pseudo-XML escaping belongs to the `<available_skills>` frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing `<`, and the same frame knowledge this decision removes would leak back in another shape. `escapeText` is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical.
|
||||
|
||||
That move also relocates catalog **identity**: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits.
|
||||
|
||||
Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared.
|
||||
|
||||
The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point.
|
||||
|
||||
Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining provenance as fields. Opaque is the documented default, not a leftover bin. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer.
|
||||
|
||||
## Why not a presenter registry
|
||||
@@ -47,7 +53,7 @@ The tool seam pairs its vocabulary with `presentCall(args)`, a host-side pure fu
|
||||
|
||||
- `packages/client/runtime` pins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque.
|
||||
- `packages/client/ui-conversation` pins each body: the opaque body's preserved line breaks and provenance fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque.
|
||||
- `packages/skill/tool-skill` pins the new source on first publication and replacement, and republish behavior driven by the durable entries.
|
||||
- `packages/skill/tool-skill` pins the new source on first publication and replacement, republish behavior driven by the durable entries, and a malformed durable catalog leaving step observation intact.
|
||||
- The keyless assembled-Web seeded-history scenario expands a real `instructions` context in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry. `catalog` has no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -23,10 +23,16 @@ Status: implemented
|
||||
|
||||
**`instructions`**——从工作区文件中读出的指令。`workspace-context` 在启动基线与后续增量上都声明它;其既有的 `changes[]` 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 `<system-reminder>` 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。
|
||||
|
||||
**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`。内容区直接列出这些条目,不再从散文里反解 `<available_skills>` 块。
|
||||
**`catalog`**——本会话可用项的目录,随变化重新发布。`dsh-tool-skill` 从共享的 `plugin` kind 迁到自有的 `skill-catalog` 来源,携带 `entries`(本次发布的 `name`/`description` 对)与替换目录上的 `update`,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 `<available_skills>` 块。
|
||||
|
||||
条目记录的是**未转义**的发布事实。伪 XML 转义属于 `<available_skills>` 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 `<` 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。`escapeText` 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。
|
||||
|
||||
这次迁移同时挪动了目录的**身份**:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——发布前阶段的姿态允许这样做。
|
||||
|
||||
两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。
|
||||
|
||||
生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。
|
||||
|
||||
其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源信息列成字段。opaque 是有文档的默认,不是兜底垃圾桶。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。
|
||||
|
||||
## 为什么不做 presenter 注册表
|
||||
@@ -47,7 +53,7 @@ Status: implemented
|
||||
|
||||
- `packages/client/runtime` 钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。
|
||||
- `packages/client/ui-conversation` 逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。
|
||||
- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源,以及由持久条目驱动的重新发布行为。
|
||||
- `packages/skill/tool-skill` 钉住首次发布与替换时的新来源、由持久条目驱动的重新发布行为,以及畸形持久目录不打断步骤观察。
|
||||
- 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的 `instructions` 上下文,断言其文件列表、原样包装与未改动的展开项几何。`catalog` 没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -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 docs/core-data-structures/core.md
|
||||
core.md: 6c12b8af07498590bb8eb5b92c3c8f6bbef313f3
|
||||
core.zh.md: f83e8873adfae5ab81c02678605f4014214b96b6
|
||||
core.md: d91e84a7066b004567ed9f668ded52ee486a1c99
|
||||
core.zh.md: f9cf2c14fe1021d7862d8bb14696044b7d8f7fbb
|
||||
|
||||
@@ -167,6 +167,32 @@ interface MessageSourceMap {
|
||||
}
|
||||
```
|
||||
|
||||
Provenance and shape are two independent axes. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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'
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
Adapters emit a raw **chunk** protocol; the loop logs the chunks (replay fidelity) while feeding the same chunks through a `BlockAssembler` to rebuild blocks and messages. `StreamChunk` is a closed discriminated union over `type` — `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`.
|
||||
|
||||
@@ -173,6 +173,32 @@ interface MessageSourceMap {
|
||||
}
|
||||
```
|
||||
|
||||
溯源与形态是相互独立的两根轴。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现:
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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'
|
||||
```
|
||||
|
||||
## 流式输出
|
||||
|
||||
适配器发出原始**分片**协议;循环记录分片(回放保真度),同时将同一批分片送入 `BlockAssembler` 以重建块和消息。`StreamChunk` 是基于 `type` 的封闭判别联合——`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`。
|
||||
|
||||
@@ -39,8 +39,7 @@ describe('headless-agent keyless smoke', () => {
|
||||
expect(stderr).toBe('')
|
||||
expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true)
|
||||
const catalogMessage = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'dsh-tool-skill')
|
||||
&& event.data.source.kind === 'skill-catalog')
|
||||
const catalog = catalogMessage?.type === 'user/message'
|
||||
? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n')
|
||||
: ''
|
||||
|
||||
@@ -19,12 +19,15 @@ describe('projectConversationHistory', () => {
|
||||
// A plugin source, because the client program does not see the host
|
||||
// packages that merge richer source kinds; those arms are pinned in
|
||||
// context-provenance.spec.ts.
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
||||
}),
|
||||
})
|
||||
const { contexts } = projectConversationHistory([{ event: injected }])
|
||||
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
||||
kind: 'context', seq: 0, provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
form: 'catalog',
|
||||
}])
|
||||
})
|
||||
|
||||
|
||||
@@ -205,11 +205,14 @@ describe('TranscriptAdapter', () => {
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{
|
||||
kind: 'context', seq: 0, provenance: { role: 'inject', label: 'compact' },
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: 'instructions',
|
||||
}])
|
||||
})
|
||||
|
||||
|
||||
@@ -65,7 +65,13 @@
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* catalog: one row per published entry. */
|
||||
/* catalog: a replacement notice above one row per published entry. */
|
||||
.catalogNotice {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
|
||||
.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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 type { ContextMessageNode, KnownContextForm } 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'
|
||||
@@ -27,6 +27,9 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
* 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.
|
||||
*
|
||||
* Blocks join with no separator, matching how provider adapters flatten them:
|
||||
* inserting a line break here would show the reader a line the model never saw.
|
||||
*/
|
||||
function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } {
|
||||
const texts: string[] = []
|
||||
@@ -35,7 +38,7 @@ function partitionContent(content: ContextMessageNode['content']): { text: strin
|
||||
if (block.type === 'text') texts.push(block.text)
|
||||
else rest.push(block)
|
||||
}
|
||||
return { text: texts.join('\n'), rest }
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
/** The model-facing text, truncated to the display bound. */
|
||||
@@ -45,11 +48,16 @@ function boundedText(text: string, t: Translate): string {
|
||||
: 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)
|
||||
/**
|
||||
* One source field rendered as a value row; nested shapes stay compact JSON.
|
||||
* Bounded on its own, because provenance is as unbounded as the text: an unknown
|
||||
* producer may record an arbitrarily large string or array.
|
||||
*/
|
||||
function fieldValue(value: unknown, t: Translate): string {
|
||||
const text = typeof value === 'string'
|
||||
? value
|
||||
: typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
|
||||
return boundedText(text, t)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +65,7 @@ function fieldValue(value: unknown): string {
|
||||
* header already names the producer, and `form` because the presentation the
|
||||
* reader is looking at IS that value.
|
||||
*/
|
||||
function SourceFields({ source }: { source: unknown }): ReactNode {
|
||||
function SourceFields({ source, t }: { source: unknown; t: Translate }): ReactNode {
|
||||
const record = asRecord(source)
|
||||
if (record === null) return null
|
||||
const rows = Object.entries(record).filter(([key]) => key !== 'kind' && key !== 'form')
|
||||
@@ -67,7 +75,7 @@ function SourceFields({ source }: { source: unknown }): ReactNode {
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className={css.field}>
|
||||
<dt className={css.fieldKey}>{key}</dt>
|
||||
<dd className={css.fieldValue}>{fieldValue(value)}</dd>
|
||||
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -98,7 +106,7 @@ export function OpaqueBody({ content, source, t }: {
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
))}
|
||||
<SourceFields source={source} />
|
||||
<SourceFields source={source} t={t} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -110,26 +118,37 @@ interface InstructionChange {
|
||||
digest?: string
|
||||
}
|
||||
|
||||
/** Instruction changes read off the source; empty when the shape is unusable. */
|
||||
function instructionChanges(source: unknown): InstructionChange[] {
|
||||
/**
|
||||
* Instruction changes read off the source, or null when the record is not a
|
||||
* usable instruction list.
|
||||
*
|
||||
* The read is all-or-nothing: silently dropping one unreadable entry would show
|
||||
* a confident, incomplete file list for a log this version cannot fully read.
|
||||
* Paths are deduplicated in first-seen order, matching how the header label is
|
||||
* derived from the same array.
|
||||
*/
|
||||
function instructionChanges(source: unknown): InstructionChange[] | null {
|
||||
const record = asRecord(source)
|
||||
const list = record === null ? undefined : record['changes']
|
||||
if (!Array.isArray(list)) return []
|
||||
if (!Array.isArray(list)) return null
|
||||
const changes: InstructionChange[] = []
|
||||
for (const entry of list) {
|
||||
const seen = new Set<string>()
|
||||
for (const entry of list as readonly unknown[]) {
|
||||
const change = asRecord(entry)
|
||||
if (change === null) continue
|
||||
if (change === null) return null
|
||||
const path = change['path']
|
||||
if (typeof path !== 'string' || path === '') continue
|
||||
if (typeof path !== 'string' || path === '') return null
|
||||
const action = change['action']
|
||||
const digest = change['digest']
|
||||
if (seen.has(path)) continue
|
||||
seen.add(path)
|
||||
changes.push({
|
||||
action: typeof action === 'string' ? action : '',
|
||||
path,
|
||||
...typeof digest === 'string' ? { digest } : {},
|
||||
})
|
||||
}
|
||||
return changes
|
||||
return changes.length === 0 ? null : changes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +157,8 @@ function instructionChanges(source: unknown): InstructionChange[] {
|
||||
* 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.
|
||||
* @returns The instructions context body, or the opaque body when the change
|
||||
* list is unreadable.
|
||||
*/
|
||||
export function InstructionsBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
@@ -146,22 +166,21 @@ export function InstructionsBody({ content, source, t }: {
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const changes = instructionChanges(source)
|
||||
if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
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>
|
||||
)}
|
||||
<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
|
||||
@@ -181,21 +200,26 @@ interface CatalogEntry {
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Catalog entries read off the source; empty when the shape is unusable. */
|
||||
function catalogEntries(source: unknown): CatalogEntry[] {
|
||||
/**
|
||||
* Catalog entries read off the source, or null when the record is not a usable
|
||||
* catalog. All-or-nothing for the same reason as the instruction list: this body
|
||||
* replaces the model-facing text, so a partial list would hide the only complete
|
||||
* account of what the model read.
|
||||
*/
|
||||
function catalogEntries(source: unknown): CatalogEntry[] | null {
|
||||
const record = asRecord(source)
|
||||
const list = record === null ? undefined : record['entries']
|
||||
if (!Array.isArray(list)) return []
|
||||
if (!Array.isArray(list)) return null
|
||||
const entries: CatalogEntry[] = []
|
||||
for (const item of list) {
|
||||
for (const item of list as readonly unknown[]) {
|
||||
const entry = asRecord(item)
|
||||
if (entry === null) continue
|
||||
if (entry === null) return null
|
||||
const name = entry['name']
|
||||
if (typeof name !== 'string' || name === '') continue
|
||||
const description = entry['description']
|
||||
entries.push({ name, description: typeof description === 'string' ? description : '' })
|
||||
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
|
||||
entries.push({ name, description })
|
||||
}
|
||||
return entries
|
||||
return entries.length === 0 ? null : entries
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,7 +229,8 @@ function catalogEntries(source: unknown): CatalogEntry[] {
|
||||
* 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.
|
||||
* @returns The catalog context body, or the opaque body when the entry list is
|
||||
* unreadable.
|
||||
*/
|
||||
export function CatalogBody({ content, source, t }: {
|
||||
content: ContextMessageNode['content']
|
||||
@@ -213,15 +238,55 @@ export function CatalogBody({ content, source, t }: {
|
||||
t: Translate
|
||||
}): ReactNode {
|
||||
const entries = catalogEntries(source)
|
||||
if (entries.length === 0) return <OpaqueBody content={content} source={source} t={t} />
|
||||
if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
|
||||
const update = asRecord(source)?.['update'] === true
|
||||
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>
|
||||
<>
|
||||
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
|
||||
<ul className={css.entries} data-context-entries>
|
||||
{entries.map((entry, index) => (
|
||||
// Index key: a hand-edited or foreign log may repeat a name, and a
|
||||
// duplicate React key would drop a row the model did see.
|
||||
<li key={index} className={css.entry}>
|
||||
<code className={css.entryName}>{entry.name}</code>
|
||||
<span className={css.entryDescription}>{entry.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the body for one context node.
|
||||
*
|
||||
* Returns the form the body actually rendered as, which is not always the
|
||||
* declared one: a declared form whose fields are unreadable falls back to
|
||||
* opaque, and the caller labels the row with what it really shows.
|
||||
* @param form - the producer-declared form projected onto the node.
|
||||
* @param props - durable content, its source, and the locale seat.
|
||||
* @returns the rendered form (null for opaque) and its body.
|
||||
*/
|
||||
export function contextBody(
|
||||
form: ContextMessageNode['form'],
|
||||
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
|
||||
): { rendered: KnownContextForm | null; body: ReactNode } {
|
||||
switch (form) {
|
||||
case 'instructions':
|
||||
return instructionChanges(props.source) === null
|
||||
? { rendered: null, body: <OpaqueBody {...props} /> }
|
||||
: { rendered: 'instructions', body: <InstructionsBody {...props} /> }
|
||||
case 'catalog':
|
||||
return catalogEntries(props.source) === null
|
||||
? { rendered: null, body: <OpaqueBody {...props} /> }
|
||||
: { rendered: 'catalog', body: <CatalogBody {...props} /> }
|
||||
case null:
|
||||
return { rendered: null, body: <OpaqueBody {...props} /> }
|
||||
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
|
||||
KnownContextForm here rather than letting it degrade to opaque silently. */
|
||||
default: {
|
||||
const unreachable: never = form
|
||||
throw new Error(`unreachable context form: ${String(unreachable)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { contextBody } from './ContextBody.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
/** Props for the logged non-user message presentation. */
|
||||
@@ -31,6 +31,9 @@ export interface ContextInjectionRowProps {
|
||||
*/
|
||||
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// Resolved rather than declared: a form whose fields are unreadable renders
|
||||
// the opaque body, and the marker must say what the row actually shows.
|
||||
const { rendered, body } = contextBody(form, { content, source, t })
|
||||
|
||||
return (
|
||||
<DisclosureRow
|
||||
@@ -53,12 +56,8 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co
|
||||
expandOnRowClick
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<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 className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
|
||||
{body}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
)
|
||||
|
||||
@@ -58,6 +58,7 @@ export const zh = {
|
||||
'message.context.instructions.loaded': '已载入',
|
||||
'message.context.instructions.updated': '已更新',
|
||||
'message.context.instructions.removed': '已移除',
|
||||
'message.context.catalog.replaced': '替换目录',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
@@ -176,6 +177,7 @@ export const en = {
|
||||
'message.context.instructions.loaded': 'loaded',
|
||||
'message.context.instructions.updated': 'updated',
|
||||
'message.context.instructions.removed': 'removed',
|
||||
'message.context.catalog.replaced': 'Replacement catalog',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
|
||||
@@ -271,6 +271,7 @@ describe('MessageItem arms', () => {
|
||||
changes: [
|
||||
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
|
||||
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
|
||||
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
|
||||
],
|
||||
},
|
||||
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
|
||||
@@ -307,6 +308,107 @@ describe('MessageItem arms', () => {
|
||||
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()
|
||||
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a replacement catalog says so above its entries', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
update: true,
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }],
|
||||
},
|
||||
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-catalog-update]')?.textContent).toBe('替换目录')
|
||||
})
|
||||
|
||||
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
|
||||
// All-or-nothing: a body that replaces the model-facing text must not show
|
||||
// a confident, incomplete account of what the model read.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'catalog prose' }],
|
||||
source: {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
|
||||
},
|
||||
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')
|
||||
// The marker reports what rendered, not what was declared.
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'instruction prose' }],
|
||||
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
|
||||
provenance: { role: 'inject', label: 'workspace-instructions' },
|
||||
form: 'instructions',
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
|
||||
expect(view.container.querySelector('[data-context-files]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
|
||||
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
|
||||
// No invented separator: showing a line break the model never saw would
|
||||
// misreport the request.
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
|
||||
source: null,
|
||||
provenance: { role: 'inject', label: null },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
|
||||
})
|
||||
|
||||
it('bounds an oversized provenance field, not only the model-facing text', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'short' }],
|
||||
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
|
||||
provenance: { role: 'inject', label: 'plugin' },
|
||||
form: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
|
||||
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('a catalog whose source carries no entries falls back to the opaque body', () => {
|
||||
|
||||
@@ -284,9 +284,14 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag
|
||||
})
|
||||
}
|
||||
|
||||
/** Model-facing catalog lines, projected from the same entries the source records. */
|
||||
/**
|
||||
* Model-facing catalog lines, projected from the same entries the source records.
|
||||
* The pseudo-XML escaping belongs to this frame, not to the published fact, so it
|
||||
* is applied here and never stored. Names are `isSkillName`-validated and carry
|
||||
* no escapable character.
|
||||
*/
|
||||
function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
|
||||
return entries.map(entry => `- \`${entry.name}\`: ${entry.description}`)
|
||||
return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,12 +300,38 @@ function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[]
|
||||
* 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')
|
||||
// JSON per entry rather than a separator character: every separator is itself
|
||||
// a legal description character, so only quoting makes the boundary exact.
|
||||
const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
|
||||
return createHash('sha256')
|
||||
.update(canonical)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries of one durable catalog message, or undefined when the record is not a
|
||||
* usable catalog.
|
||||
*
|
||||
* `agent.session.events` may be a resumed, forked, or externally written seed,
|
||||
* and seed validation only guarantees a source object with a non-empty `kind`;
|
||||
* no per-kind field is checked there. An unreadable record is therefore treated
|
||||
* as "not this plugin's catalog" — the posture the replaced content digest had —
|
||||
* rather than throwing inside the step listener, which would fail every
|
||||
* subsequent turn of that session.
|
||||
*/
|
||||
function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
|
||||
const entries = (source as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries)) return undefined
|
||||
const readable: { name: string; description: string }[] = []
|
||||
for (const entry of entries as readonly unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) return undefined
|
||||
const { name, description } = entry as { name?: unknown; description?: unknown }
|
||||
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
|
||||
readable.push({ name, description })
|
||||
}
|
||||
return readable
|
||||
}
|
||||
|
||||
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
|
||||
const visible = new Set(agent.session.surface.nodes)
|
||||
const events = agent.session.events
|
||||
@@ -310,19 +341,19 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
|
||||
const digest = digestCatalogEntries(event.data.source.entries)
|
||||
const entries = readCatalogEntries(event.data.source)
|
||||
if (entries === undefined) continue
|
||||
const digest = digestCatalogEntries(entries)
|
||||
published = true
|
||||
if (visible.has(event.seq)) return { visibleDigest: digest, published }
|
||||
}
|
||||
return { published }
|
||||
}
|
||||
|
||||
/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(truncated)
|
||||
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
|
||||
@@ -97,6 +97,14 @@ function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/
|
||||
&& event.data.source.kind === 'skill-catalog')
|
||||
}
|
||||
|
||||
function readableCatalog(event: Extract<SessionEvent, { type: 'user/message' }>): boolean {
|
||||
const entries = (event.data.source as { entries?: unknown }).entries
|
||||
return Array.isArray(entries)
|
||||
&& entries.every(entry => typeof entry === 'object' && entry !== null
|
||||
&& typeof (entry as { name?: unknown }).name === 'string'
|
||||
&& typeof (entry as { description?: unknown }).description === 'string')
|
||||
}
|
||||
|
||||
function catalogContent(entries: string[]): Message['content'] {
|
||||
return [{
|
||||
type: 'text',
|
||||
@@ -218,7 +226,7 @@ describe('dsh-tool-skill', () => {
|
||||
kind: 'skill-catalog',
|
||||
form: 'catalog',
|
||||
entries: [
|
||||
{ name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' },
|
||||
{ name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' },
|
||||
{ name: 'model-only-skill', description: 'Model-only skill.' },
|
||||
{ name: 'z-skill', description: 'Long description Long description Long descript...' },
|
||||
],
|
||||
@@ -411,6 +419,48 @@ describe('dsh-tool-skill', () => {
|
||||
expect(catalogMessages(session)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => {
|
||||
// Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork,
|
||||
// and seed validation only guarantees a source object with a non-empty
|
||||
// `kind`. A catalog whose entries are missing or wrongly shaped must be
|
||||
// skipped like any foreign record; throwing here would fail every later
|
||||
// step of that session at the latest possible point.
|
||||
const home = await tempDir('tool-catalog-malformed')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'live-skill',
|
||||
description: 'Live skill',
|
||||
source: 'runtime',
|
||||
content: 'Live body.',
|
||||
})
|
||||
const session = Session.create(SessionId('catalog-malformed'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
for (const source of [
|
||||
{ kind: 'skill-catalog', form: 'catalog' },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: null },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: [null] },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] },
|
||||
{ kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] },
|
||||
]) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unreadable catalog' }],
|
||||
source: source as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined()
|
||||
|
||||
// None of the six counted as published, so the live catalog lands as a
|
||||
// first publication rather than a replacement.
|
||||
const published = catalogMessages(session).filter(event => readableCatalog(event))
|
||||
expect(published).toHaveLength(1)
|
||||
expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' })
|
||||
expect(published[0]?.data.source).not.toHaveProperty('update')
|
||||
expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill')
|
||||
})
|
||||
|
||||
it('re-establishes the current catalog after compaction hides its durable message', async () => {
|
||||
const home = await tempDir('tool-catalog-compaction')
|
||||
const ctx = await setup(home)
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
"symbol": "MessageSourceMap",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContextForm",
|
||||
"source": "packages/llm/llm/src/message.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "FinishReasonMap",
|
||||
|
||||
Reference in New Issue
Block a user