feat(client): present compact developer tool update notices

This commit is contained in:
Yixiang Tu authored and Tianyi Cui committed 2026-09-23 22:39:34 +08:00
1 parent bc8c0dbf40
commit 7e5f906945
20 files changed
+313 -48

No files matched your search

+4 -4
View File
@@ -18,8 +18,8 @@
en: 3fda3d75483ce7c1
zh: 4d96ba654f70c021
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
en: 4e4ab65647a6dbfb
zh: 2ec5cd425c13440a
en: 5706ae03ceacf251
zh: 2e59d0815351349f
/deepseek-ai-dsh-client-ui-chat/command-and-failure-rows:
en: e2e86d9eec9130b5
zh: 6ee831c996670630
@@ -45,8 +45,8 @@
en: 15a7151ff9268f15
zh: 6eae68c718be579b
/deepseek-ai-dsh-client-ui-chat/known-limitations-and-deferred-work:
en: 69dd0f47c88ef020
zh: 0ed3b048ed0c96d5
en: 1e7e55adf5c87246
zh: dd5befdf58ea084f
/deepseek-ai-dsh-client-ui-chat/known-limitations-and-deferred-work/dev-note:
en: b8b7eeb4ae675d2a
zh: fbe7022074ba81b9
+2 -2
View File
@@ -40,7 +40,7 @@ Settings → General → Open chat links in selects the destination for ordinary
<a id="system-prompt-row"></a>
## Hidden Chat rows
Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat.
Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. Context containing tool additions or removals remains visible. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat.
When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start.
@@ -154,7 +154,7 @@ None; Chat presentation does not assemble or mutate provider requests.
<a id="known-limitations-and-deferred-work"></a>
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
- **Tool-change presentation** — The `developer-message` Definition shares context presentation with `input-message`. Tool-only developer messages name a single added or removed tool inline without expansion. Multiple changes show added/removed counts and expand to comma-separated tool lists, one line per change kind. Mixed content uses the generic context presentation.
- **Opening echoes predict local order** — several submissions made before the running update can all remain in Chat. Their initial order follows local submission order, not Host queue order; admission can reposition them when the Host receives requests in a different order.
+2 -2
View File
@@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
<a id="system-prompt-row"></a>
## Chat 隐藏的行
Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。
Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
@@ -154,7 +154,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
<a id="known-limitations-and-deferred-work"></a>
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
- **工具变更展示** — `developer-message` Definition 与 `input-message` 共用上下文展示。仅包含工具变更的 developer 消息在单个工具新增或移除时直接显示工具名,不提供展开操作。多个变更显示新增、移除数量,展开后按变更类型分别以逗号分隔显示一行工具列表。混合内容使用通用上下文展示。
- **开场回显预测本地顺序**——运行状态更新前连续发出的多条消息可能都留在 Chat。初始排列遵循本地提交顺序,而非 Host 队列顺序;Host 按不同顺序接收请求时,入档可能调整它们的位置。
@@ -62,3 +62,7 @@
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
}
.toolChanges {
white-space: nowrap;
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { DisclosureRow, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconBrowseOutlineRegular, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ContextMessageNode } from '../contract/snapshot.ts'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
@@ -34,15 +34,33 @@ export function ContextInjectionRow({ content, source, producer, form, t }: Cont
// the opaque body, and the marker must say what the row actually shows.
const { rendered, summary, body } = contextBody(form, { content, source, t })
const toolBlocks = content.length > 0
&& content.every(block => block.type === 'tool-addition' || block.type === 'tool-removal')
? content : undefined
const added = toolBlocks?.flatMap(block => block.type === 'tool-addition' ? [block.toolName] : []) ?? []
const removed = toolBlocks?.flatMap(block => block.type === 'tool-removal' ? [block.toolName] : []) ?? []
const single = toolBlocks?.length === 1 ? toolBlocks[0] : undefined
const toolSummary = toolBlocks === undefined || single !== undefined ? null
: added.length > 0 && removed.length > 0
? t('message.toolsChanged', { added: added.length, removed: removed.length })
: added.length > 0
? t('message.toolsAddedCount', { count: added.length })
: t('message.toolsRemovedCount', { count: removed.length })
return (
<DisclosureRow
className={css.root}
icon={producer.role === 'recall'
icon={toolBlocks !== undefined ? <IconBrowseOutlineRegular size={14} /> : producer.role === 'recall'
? <span data-context-recall-icon><ReferenceIconRegular kind="session" /></span>
: <IconContextInjectionOutlineRegular size={14} />}
chevronClassName={css.chevron}
title={t(producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={producer.label === null ? undefined : (
title={single !== undefined ? t(single.type === 'tool-addition' ? 'message.toolAdded' : 'message.toolRemoved', { name: single.toolName }) : t(toolBlocks !== undefined ? 'message.toolsUpdated' : producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={toolSummary !== null ? (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{toolSummary}</span>
</>
) : toolBlocks !== undefined || producer.label === null ? undefined : (
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
stays the two readable parts and the two disclosure rows expose one
name shape. A source that names no producer drops the dot with it. */
@@ -58,13 +76,18 @@ export function ContextInjectionRow({ content, source, producer, form, t }: Cont
</>
)}
keepContentWhenOpen
open={open}
expandable
open={open && single === undefined}
expandable={single === undefined}
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
{body}
{toolBlocks === undefined ? body : (
<div className={css.toolChanges}>
{added.length > 0 && <div>{t('message.toolsAdded', { names: added.join(', ') })}</div>}
{removed.length > 0 && <div>{t('message.toolsRemoved', { names: removed.join(', ') })}</div>}
</div>
)}
</div>
</DisclosureRow>
)
@@ -3,12 +3,14 @@ import type { ChatNode } from './chat-nodes.ts'
/**
* Exclude system prompts, ordinary Context, and permission commands from visible Chat rows.
* Context containing tool changes retains its notice row.
* @param node - projected Chat node.
* @returns whether the node contributes a visible Chat row.
*/
export function isVisibleChatNode(node: ChatNode): boolean {
return node.visibility === 'visible'
&& node.kind !== 'system-prompt'
&& node.kind !== 'context'
&& (node.kind !== 'context'
|| node.data.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal'))
&& !(node.kind === 'command' && node.data.name === 'permission')
}
@@ -42,6 +42,22 @@ function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['ma
return source.kind === 'compact-checkpoint'
}
/** Context presentation shared by user-role injections and developer messages. */
function contextMessage(
event: Pick<ContextMessageNode, 'seq' | 'time'>,
message: Pick<ContextMessageNode, 'content' | 'source'>,
): ContextMessageNode {
return {
kind: 'context',
seq: event.seq,
time: event.time,
content: message.content,
source: message.source,
producer: contextProducer(message.source),
form: contextForm(message.source),
}
}
/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'input-message',
@@ -52,13 +68,11 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
? { id: String(event.data.id), role: 'start' }
: null
}
// Developer history is persisted for V4; presentation is intentionally deferred.
if (event.type === 'developer/message') throw new Error('Chat developer messages are not supported yet')
return null
},
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
const event = match.event
if (event.type !== 'user/message') throw new Error('input-message start requires user/message')
if (event.data.source.kind !== 'user') {
const nextTurn = reader.previous<InboxState>('inbox-next-turn')?.state
const nextStep = reader.previous<InboxState>('inbox-next-step')?.state
@@ -72,14 +86,8 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
&& nextStep.currentClaimed.has(String(event.data.id))
return {
kind: 'context',
...contextMessage(event, event.data),
waking: nextTurn?.currentClaimed.has(String(event.data.id)) === true || idleSteer,
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
producer: contextProducer(event.data.source),
form: contextForm(event.data.source),
}
}
const claimed = reader.previous<InboxState>('inbox-next-step')
@@ -111,10 +119,25 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
},
}
/** Developer history uses the input-message lifecycle and context presentation. */
export const developerMessageDefinition: ConversationNodeDefinition<MessageNode> = {
...messageDefinition,
kind: 'developer-message',
match: event => event.type === 'developer/message'
? { id: String(event.data.message.id), role: 'start' }
: null,
start: (_context, match) => {
const event = match.event
if (event.type !== 'developer/message') throw new Error('developer-message start requires developer/message')
return contextMessage(event, event.data.message)
},
}
/**
* Register the user, steering, and injected-context message contribution.
* Register user, steering, injected-context, and developer message contributions.
* @param ctx - owning UI Conversation context.
*/
export function registerMessageConversationNode(ctx: Context): void {
ctx.uiConversation.events.register(messageDefinition)
ctx.uiConversation.events.register(developerMessageDefinition)
}
@@ -111,6 +111,14 @@ export const zh = {
'message.extraBlock': '附加内容块',
'message.systemPrompt': '系统提示词',
'message.systemPromptUpdate': '系统提示词更新',
'message.toolAdded': '已添加工具:{name}',
'message.toolRemoved': '已移除工具:{name}',
'message.toolsAdded': '新增:{names}',
'message.toolsAddedCount': '新增 {count} 个',
'message.toolsChanged': '新增 {added} 个,移除 {removed} 个',
'message.toolsRemoved': '移除:{names}',
'message.toolsRemovedCount': '移除 {count} 个',
'message.toolsUpdated': '工具已更新',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.referenceSummary': '引用会话 · {labels}',
@@ -295,6 +303,14 @@ export const en = {
'message.extraBlock': 'Extra content block',
'message.systemPrompt': 'System prompt',
'message.systemPromptUpdate': 'System prompt update',
'message.toolAdded': 'Tool added: {name}',
'message.toolRemoved': 'Tool removed: {name}',
'message.toolsAdded': 'Added: {names}',
'message.toolsAddedCount': '{count} added',
'message.toolsChanged': '{added} added, {removed} removed',
'message.toolsRemoved': 'Removed: {names}',
'message.toolsRemovedCount': '{count} removed',
'message.toolsUpdated': 'Tools updated',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.referenceSummary': 'Referenced session · {labels}',
@@ -22,7 +22,7 @@ import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { developerMessageDefinition, messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { requestPromptDefinition, systemMessageDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
@@ -39,6 +39,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
nextStepInboxDefinition,
nextTurnInboxDefinition,
messageDefinition,
developerMessageDefinition,
systemMessageDefinition(inspectSystemPrompt),
requestPromptDefinition(inspectRequestPrompt),
assistantDefinition,
@@ -224,11 +225,51 @@ function toolResult(callId: string, text: string, isError = false) {
}
describe('built-in conversation node Definitions', () => {
it('rejects developer history until presentation is implemented', () => {
expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: {
it.each(['replay', 'live', 'prepend'] as const)('keeps developer tool changes visible while hiding ordinary Context (%s)', (mode) => {
const entries = [
at(0, 'request/header', { reason: 'initial', header: {
config: { provider: 'test', model: 'test' },
tools: ['search', 'read_file'].map(name => ({ name, description: '', parameters: {} })),
} }),
at(1, 'user/message', {
...textMessage('context', 'workspace context'),
source: { kind: 'context' },
}, { surfaceOp: 'append' }),
at(2, 'developer/message', { turn: 1, step: 1, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old_search' }],
} }, { surfaceOp: 'append' }),
at(3, 'request/header', { reason: 'change', header: { config: { provider: 'test', model: 'test' }, tools: [] } }),
]
const value = assembler(mode === 'replay' ? entries : [])
if (mode === 'live') {
for (const entry of entries) {
value.append(entry)
value.flush()
}
} else if (mode === 'prepend') {
value.replaceWindow(entries.slice(2), true)
value.prepend(entries.slice(0, 2), false)
value.flush()
}
const current = snapshot(value)
const visible = current.order.map(key => current.nodes.get(key))
expect(visible.map(candidate => candidate?.kind)).toEqual(['context'])
expect(visible[0]?.data).toMatchObject({
kind: 'context', content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old_search' }],
})
expect(current.nodes.values().filter(candidate => candidate.kind === 'context')).toHaveLength(2)
})
it('preserves tool removals without a loaded header', () => {
const value = assembler([at(1, 'developer/message', { turn: 1, step: 1, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search' }],
} }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet')
content: [{ type: 'tool-removal', toolName: 'old_search' }],
} }, { surfaceOp: 'append' })], true)
const current = snapshot(value)
const removal = node(current, 'context')
expect(removal?.data).toMatchObject({ content: [{ type: 'tool-removal', toolName: 'old_search' }] })
expect(current.order).toContain(removal?.key)
})
it('rejects an unrelated event passed directly to the request-prompt start', () => {
@@ -0,0 +1,51 @@
// @vitest-environment jsdom
/** Tool updates share the context disclosure while keeping names and counts readable. */
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { ContextInjectionRow } from '../src/client/chat/ContextInjectionRow.tsx'
import { en, zh } from '../src/client/locale.ts'
afterEach(cleanup)
describe.each([
{ locale: 'en', t: makeTranslate(en, commonEn), title: 'Tools updated', summary: '2 added, 1 removed', added: 'Added: search, read_file', removed: 'Removed: old_search' },
{ locale: 'zh', t: makeTranslate(zh, commonZh), title: '工具已更新', summary: '新增 2 个,移除 1 个', added: '新增:search, read_file', removed: '移除:old_search' },
])('tool update disclosure ($locale)', ({ t, title, summary, added, removed }) => {
it('shows counts collapsed and compact name lists when expanded', () => {
render(<ContextInjectionRow
content={[{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-addition', toolName: 'read_file' }, { type: 'tool-removal', toolName: 'old_search' }]}
source={{ kind: 'tool-registry' }}
producer={{ role: 'inject', label: 'tool-registry' }}
form={null}
t={t}
/>)
const row = screen.getByRole('button', { name: new RegExp(title) })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(screen.getByText(summary)).toBeTruthy()
expect(screen.queryByText('tool-registry')).toBeNull()
fireEvent.click(row)
expect(screen.getByText(added)).toBeTruthy()
expect(screen.getByText(removed)).toBeTruthy()
expect(screen.queryByText(/Active tools:/)).toBeNull()
})
})
it.each([
['tool-addition', 'Tool added: last_tool'],
['tool-removal', 'Tool removed: last_tool'],
] as const)('names a single %s without a disclosure', (type, label) => {
render(<ContextInjectionRow
content={[{ type, toolName: 'last_tool' }]}
source={{ kind: 'tool-registry' }}
producer={{ role: 'inject', label: 'tool-registry' }}
form={null}
t={makeTranslate(en, commonEn)}
/>)
expect(screen.getByText(label)).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
fireEvent.click(screen.getByText(label))
expect(screen.queryByText(/Active tools:/)).toBeNull()
})
@@ -42,8 +42,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-ui-trajectory/known-limitations-and-deferred-work:
en: 8c3d3b2d42384c8c
zh: 00739e172c39db3b
en: d7da620f56eb3c65
zh: fb030b68322b94cd
/deepseek-ai-dsh-client-ui-trajectory/known-limitations-and-deferred-work/dev-note:
en: f1893e3a6c69cc58
zh: b6e0dfe20567c35f
+1 -1
View File
@@ -96,7 +96,7 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
- **Tool-change presentation** — Tool-only developer messages name a single added or removed tool inline without expansion. Multiple changes show added/removed counts and expand to comma-separated tool lists, one line per change kind. Mixed content uses the generic context presentation.
These limits define what the view can show while work is in flight; they are current package constraints.
+1 -1
View File
@@ -96,7 +96,7 @@ Trajectory 要求会话壳把 composer 作为浮层置于全高记录表上方
<a id="known-limitations-and-deferred-work"></a>
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
- **工具变更展示** — 仅包含工具变更的 developer 消息在单个工具新增或移除时直接显示工具名,不提供展开操作。多个变更显示新增、移除数量,展开后按变更类型分别以逗号分隔显示一行工具列表。混合内容使用通用上下文展示。
这些限制定义工作仍在进行时视图能显示什么;它们是当前包约束。
@@ -1982,3 +1982,8 @@
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.14);
}
}
.toolUpdatePayload {
white-space: pre;
overflow-x: auto;
}
@@ -1525,6 +1525,10 @@ function MarkdownRecordContent({
renderImages: RenderMessageImages
t: TrajectoryTranslate
}) {
if (record.cell.sourceBlocks?.length && record.cell.sourceBlocks.every(block =>
block.type === 'tool-addition' || block.type === 'tool-removal')) {
return <pre className={`${css.payload} ${css.toolUpdatePayload}`}>{record.cell.inputDetail}</pre>
}
if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
return (
<SourceBlocks
@@ -2691,9 +2695,12 @@ export function TrajectoryTable({
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
const singleToolNotice = record.cell.kind === 'context'
&& record.cell.sourceBlocks?.length === 1
&& record.cell.sourceBlocks.every(block => block.type === 'tool-addition' || block.type === 'tool-removal')
return (
<tr
tabIndex={isRequestOnly ? -1 : 0}
tabIndex={isRequestOnly || singleToolNotice ? -1 : 0}
aria-rowindex={position + 1 + historyRowOffset}
aria-label={isCollapsedSummary
? t('request.collapsedSummary', {
@@ -2728,7 +2735,7 @@ export function TrajectoryTable({
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
? undefined
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
onClick={isRequestOnly
onClick={isRequestOnly || singleToolNotice
? undefined
: isCollapsedSummary
? () => {
@@ -2738,7 +2745,7 @@ export function TrajectoryTable({
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary || isRequestOnly) return
if (isCollapsedSummary || isRequestOnly || singleToolNotice) return
if (record.turn !== null && collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
@@ -2762,7 +2769,7 @@ export function TrajectoryTable({
onToggleTurn(record.turn)
}}
onKeyDown={(event) => {
if (isRequestOnly) return
if (isRequestOnly || singleToolNotice) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
@@ -122,6 +122,29 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
| 'timeSeconds'
| 'startedAt'
> {
if (node.kind === 'context' && node.content.length > 0
&& node.content.every(block => block.type === 'tool-addition' || block.type === 'tool-removal')) {
const added = node.content.flatMap(block => block.type === 'tool-addition' ? [block.toolName] : [])
const removed = node.content.flatMap(block => block.type === 'tool-removal' ? [block.toolName] : [])
const single = node.content.length === 1 ? node.content[0] : undefined
const summary = added.length > 0 && removed.length > 0
? t('layout.toolsChanged', { added: added.length, removed: removed.length })
: added.length > 0 ? t('layout.toolsAddedCount', { count: added.length })
: t('layout.toolsRemovedCount', { count: removed.length })
return {
text: single !== undefined
? t(single.type === 'tool-addition' ? 'layout.toolAdded' : 'layout.toolRemoved', { name: single.toolName })
: `${t('layout.toolUpdateNotice')} · ${summary}`,
sourceSeq: node.seq,
sourceBlocks: node.content.map(block => ({ type: block.type, content: block.toolName })),
...(single !== undefined ? {} : { inputDetail: [
...added.length > 0 ? [t('layout.toolsAdded', { names: added.join(', ') })] : [],
...removed.length > 0 ? [t('layout.toolsRemoved', { names: removed.join(', ') })] : [],
].join('\n') }),
timeSeconds: 0,
startedAt: finiteTime(node.time),
}
}
const preview = previewContent(node.content)
const previewMarkdown = preview === '' ? undefined : preview
const images = imageBlockCount(node.content)
@@ -187,6 +187,14 @@ export const zh = {
'layout.initialSystemPrompt': '初始系统提示词',
'layout.systemPromptUpdated': '系统提示词已更新',
'layout.toolsUpdated': '工具已更新',
'layout.toolAdded': '已添加工具:{name}',
'layout.toolRemoved': '已移除工具:{name}',
'layout.toolUpdateNotice': '工具已更新',
'layout.toolsAdded': '新增:{names}',
'layout.toolsAddedCount': '新增 {count} 个',
'layout.toolsChanged': '新增 {added} 个,移除 {removed} 个',
'layout.toolsRemoved': '移除:{names}',
'layout.toolsRemovedCount': '移除 {count} 个',
'layout.systemPromptAndToolsUpdated': '系统提示词和工具已更新',
'layout.compactionInterrupted': '上下文压缩在完成前被中断。',
} as const
@@ -389,6 +397,14 @@ export const en: Record<TrajectoryKey, string> = {
'layout.initialSystemPrompt': 'Initial System Prompt',
'layout.systemPromptUpdated': 'System Prompt Updated',
'layout.toolsUpdated': 'Tools Updated',
'layout.toolAdded': 'Tool added: {name}',
'layout.toolRemoved': 'Tool removed: {name}',
'layout.toolUpdateNotice': 'Tools updated',
'layout.toolsAdded': 'Added: {names}',
'layout.toolsAddedCount': '{count} added',
'layout.toolsChanged': '{added} added, {removed} removed',
'layout.toolsRemoved': 'Removed: {names}',
'layout.toolsRemovedCount': '{count} removed',
'layout.systemPromptAndToolsUpdated': 'System Prompt and Tools Updated',
'layout.compactionInterrupted': 'Compaction was interrupted before completion.',
}
@@ -137,13 +137,23 @@ const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'trajectory-input-message',
target: 'trajectory',
match: (event) => {
// Developer history is persisted for V4; presentation is intentionally deferred.
if (event.type === 'developer/message') throw new Error('Trajectory developer messages are not supported yet')
return event.type === 'user/message'
return event.type === 'user/message' || event.type === 'developer/message'
? { id: String(event.seq), role: 'start' }
: null
},
start: (_context, match, reader) => {
if (match.event.type === 'developer/message') {
const { seq, time, data: { message } } = match.event
return {
kind: 'context',
seq,
time,
content: message.content,
source: message.source,
producer: contextProducer(message.source),
form: contextForm(message.source),
}
}
if (match.event.type !== 'user/message') {
throw new Error('trajectory-input-message start requires user/message')
}
@@ -176,11 +176,29 @@ describe('Trajectory conversation Definitions', () => {
expect(trajectoryViewDefinition.toolCallFocus?.('call-1')).toBe('call-1')
})
it('rejects developer history until presentation is implemented', () => {
expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: {
it('preserves developer tool-change content', () => {
const value = assembler([
at(0, 'request/header', { reason: 'initial', header: {
config: { provider: 'test', model: 'test' },
tools: ['search', 'read_file'].map(name => ({ name, description: '', parameters: {} })),
} }),
at(1, 'developer/message', { turn: 1, step: 1, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old_search' }],
} }, { surfaceOp: 'append' }),
at(2, 'request/header', { reason: 'change', header: { config: { provider: 'test', model: 'test' }, tools: [] } }),
])
expect(snapshot(value).eventNodes.find(node => node.kind === 'context')).toMatchObject({
kind: 'context', content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old_search' }],
})
})
it('preserves tool removals without a loaded header', () => {
const value = assembler([at(1, 'developer/message', { turn: 1, step: 1, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search' }],
} }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet')
content: [{ type: 'tool-removal', toolName: 'old_search' }],
} }, { surfaceOp: 'append' })], true)
expect(snapshot(value).eventNodes.find(node => node.kind === 'context')).toMatchObject({ content: [{ type: 'tool-removal', toolName: 'old_search' }] })
})
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
@@ -15,7 +15,7 @@ import {
appendTrajectoryPartialLayout as appendTrajectoryPartialLayoutWithLocale,
deriveTrajectoryLayout as deriveTrajectoryLayoutWithLocale,
} from '../src/client/layout.ts'
import { t } from './locale.client.ts'
import { t, tZh } from './locale.client.ts'
const deriveTrajectoryLayout = (
input: Parameters<typeof deriveTrajectoryLayoutWithLocale>[0],
@@ -75,6 +75,32 @@ describe('TrajectoryTurn', () => {
})
describe('deriveTrajectoryLayout', () => {
it.each(['tool-addition', 'tool-removal'] as const)('names a single %s without detail content', (type) => {
const nodes: ConversationNode[] = [{
kind: 'context', seq: 1, time: 1_000,
content: [{ type, toolName: 'search' }], source: 'tool-registry',
producer: { role: 'inject', label: 'tool-registry' }, form: null,
}]
const cells = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
.flatMap(turn => turn.groups.flatMap(group => group.cells))
expect(cells[0]?.text).toBe(type === 'tool-addition' ? 'Tool added: search' : 'Tool removed: search')
expect(cells[0]?.inputDetail).toBeUndefined()
})
it.each([
[t, 'Tools updated · 2 added, 1 removed', 'Added: search, read_file\nRemoved: old_search'],
[tZh, '工具已更新 · 新增 2 个,移除 1 个', '新增:search, read_file\n移除:old_search'],
] as const)('keeps tool update details on two literal lines', (translate, text, inputDetail) => {
const nodes: ConversationNode[] = [{
kind: 'context', seq: 1, time: 1_000, content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-addition', toolName: 'read_file' }, { type: 'tool-removal', toolName: 'old_search' }],
source: 'tool-registry', producer: { role: 'inject', label: 'tool-registry' }, form: null,
}]
const turns = deriveTrajectoryLayoutWithLocale({ nodes, partial: null, runningCalls: [] }, translate)
const cells = turns.flatMap(turn => turn.groups.flatMap(group => group.cells))
expect(cells).toMatchObject([{ text, inputDetail }])
expect(cells[0]?.sourceBlocks).toHaveLength(3)
})
it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },