feat(client): present compact developer tool update notices
This commit is contained in:
1 parent
bc8c0dbf40
commit
7e5f906945
20 files changed
+313
-48
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user