fix(web): display known prompts in headerless history windows

Give appended Chat system messages their own cards and suppress the following initial or same-step update header card. Preserve replacement/header ownership and conservative unknown surface ordering. Trajectory uses prompt-only presentation when no request header is loaded, never fabricated provider, model, or tools; prepend deduplicates against real request changes. Test-first regressions showed missing Chat cards and missing trajectory prompt data; 148 focused tests and affected client project typechecks pass.
This commit is contained in:
Tianyi Cui
2026-09-07 11:09:40 +08:00
parent 4574602c07
commit cea0aff474
18 changed files with 142 additions and 46 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: 5995c8cdc2a96919780f3be7ebe67ea12584e17e
README.zh.md: 23ba6084742e22684aa30b68adef75cbc5f4710d
README.md: 1b60d08014ccbe7aa3f4e746556a250a2df27ec7
README.zh.md: f80d5afe3ac4ff83ee257d0b52c98dcd7794e5fa
+1 -1
View File
@@ -25,7 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
<a id="system-prompt-row"></a>
## System prompt row
Chat shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
<a id="turn-token-usage"></a>
## Turn token usage
+1 -1
View File
@@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
<a id="system-prompt-row"></a>
## 系统提示词行
Chat 会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。即使系统文本未变,resume 也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。即使系统文本未变,resume 也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
-----
@@ -51,12 +51,10 @@ function stableRequestPromptAnchor(
/**
* System-prompt surface node Definition for the Chat target. It owns every
* `system/message` event on the Chat target so the unknown-surface fallback
* never renders the prompt as a transcript row. A node that introduces or
* replaces the prompt materializes no Node: the request-prompt Definition
* reads its State through `reader.previous` and presents the prompt as the
* request's `system-prompt` card. An in-history update — a prompt appended
* after an earlier loaded system node — is the model-visible change at that
* position, so it presents its own `system-prompt` card there. Positional
* never renders the prompt as a transcript row. Each nonempty append owns a
* prompt card, even without a loaded request header. Initial cards precede
* their step's input; in-history updates stay at their own positions. The
* request-prompt Definition owns replacement and later-series cards. Positional
* replacements advance the effective prompt without changing historical cards.
* @param inspect - Pure surface interpretation supplied by uiConversation.
* @returns The Chat system-prompt Definition.
@@ -75,8 +73,10 @@ export function systemMessageDefinition(inspect: SystemPromptInspector): Convers
update: context => context.state,
buildViewNode: (context) => {
const state = context.state?.introduced
if (state === undefined || !state.update || state.text === '') return null
return chatNode(context, 'system-prompt', state.seq, { text: state.text, update: true })
if (state === undefined || state.text === ''
|| context.start?.event.type !== 'system/message' || context.start.event.surfaceOp !== 'append') return null
const anchor = state.update ? state.seq : requestPromptAnchor(context.start, undefined, true)
return chatNode(context, 'system-prompt', anchor, { text: state.text, ...state.update ? { update: true } : {} })
},
}
}
@@ -100,15 +100,18 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
throw new Error('request-prompt start requires request/header')
}
const previous = reader.previous<RequestPromptState>('request-prompt')?.state
const system = reader.previous<SystemPromptState>('system-message')?.state.effective
const systemContext = reader.previous<SystemPromptState>('system-message')
const system = systemContext?.state.effective
const location = match.location.kind === 'step'
? { turn: match.location.turn.turn, step: match.location.step.step }
: {}
const inspection = inspect(previous?.prompt, match.event, system)
const change = inspection.change?.kind
// An in-history update committed in this same step already shows the
// prompt at its own position.
const shownByUpdate = system?.update === true
// Appended prompts own their cards; a same-step header must not repeat them.
const systemEvent = systemContext?.matches[0]?.event
const shownByUpdate = system !== undefined
&& systemEvent?.type === 'system/message' && systemEvent.surfaceOp === 'append'
&& (system.update || previous === undefined)
&& system.turn === location.turn
&& system.step === location.step
return {
@@ -1475,7 +1475,7 @@ describe('built-in conversation node Definitions', () => {
const prompts = current.nodes.values()
.filter(candidate => candidate.kind === 'system-prompt')
expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
{ anchorSeq: 2, data: { text: '# Initial' } },
{ anchorSeq: 1, data: { text: '# Initial' } },
{ anchorSeq: 5, data: { text: '# Initial' } },
{ anchorSeq: 6, data: { text: '# Initial' } },
{ anchorSeq: 8, data: { text: '# Updated' } },
@@ -1483,6 +1483,25 @@ describe('built-in conversation node Definitions', () => {
expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([])
})
it('shows a complete appended prompt at the start of a headerless window', () => {
const value = assembler([
systemUpdateAt(10, '# Known prompt', 2, 1),
at(11, 'user/message', textMessage('window-user', 'continue'), { surfaceOp: 'append' }),
], true)
const current = snapshot(value)
expect(current.nodes.values().filter(candidate => candidate.kind === 'system-prompt')
.map(candidate => candidate.data)).toEqual([{ text: '# Known prompt' }])
expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([])
value.prepend([
systemAt(1, '# Original'),
at(2, 'request/header', { reason: 'initial', header: { config: { provider: 'fake', model: 'fake' } } }),
], false)
value.flush()
const restored = snapshot(value)
expect(restored.order.map(key => restored.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt')
.map(candidate => candidate?.data)).toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }])
})
it('withholds windowed replacement prompts until prepend resolves their positions', () => {
const windowed = assembler([
systemAt(10, '# Resumed prompt', 5),
@@ -1584,7 +1603,8 @@ describe('built-in conversation node Definitions', () => {
if (mode === 'partial') {
value.replaceWindow(history.slice(7), true)
value.flush()
expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt')).toEqual([])
expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt').map(candidate => candidate.data))
.toEqual([{ text: 'B' }])
value.prepend(history.slice(0, 7), false)
value.flush()
}
@@ -1594,8 +1614,9 @@ describe('built-in conversation node Definitions', () => {
value.flush()
}
}
expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt')
.map(candidate => candidate.data)).toEqual([
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt')
.map(candidate => candidate?.data)).toEqual([
{ text: 'A' }, { text: 'B', update: true }, { text: 'A' },
])
})
@@ -1606,10 +1627,12 @@ describe('built-in conversation node Definitions', () => {
at(8, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }),
], true)
expect(node(snapshot(value), 'system-prompt')).toBeUndefined()
const uncertain = assembler([systemAt(6, 'C', 3), systemUpdateAt(7, 'Known but unordered', 1, 2)], true)
expect(node(snapshot(uncertain), 'system-prompt')).toBeUndefined()
value.prepend([systemAt(1, 'A'), systemAt(3, 'B'), systemAt(5, 'A2', 1)], false)
value.flush()
expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt')
.map(candidate => candidate.data)).toEqual([{ text: 'B', update: true }, { text: 'C' }])
.map(candidate => candidate.data)).toEqual([{ text: 'A' }, { text: 'B', update: true }, { text: 'C' }])
})
it('never renders a system/message as a transcript bubble', () => {
@@ -1621,12 +1644,12 @@ describe('built-in conversation node Definitions', () => {
])
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['user'])
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
value.append(systemAt(5, '# Replaced', 3))
value.flush()
const replaced = snapshot(value)
expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['user'])
expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
})
it('presents an in-history prompt update as its own card and lets no same-step header repeat it', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 2dd236ecec41137e39b017b79bfbec84d536bebc
README.zh.md: a7a521bfe0a0ce2b76633e3145e67d87b344c20f
README.md: ea7e90221317dd62beb259773a8d9872e7b01ddb
README.zh.md: 1e875c48d66bbd80057ccaecb86219f8de6540e8
+1 -1
View File
@@ -45,7 +45,7 @@ A fixed Overview above the ledger projects real record start/duration timing fro
The view is a pure projection: Trajectory-owned Definitions assemble business records from the shared Session window — including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records — so Trajectory neither reads nor changes the Chat conversation snapshot. Its steering classifier retains only next-step Inbox IDs through persistent splice state and shares each current claimed batch across later Contexts.
In-history system prompt changes compare against the most recent request state, including earlier prompt updates without a new request header. Each request retains the prompt and change that applied at its own position. Surface replacements, including compaction, restore the last nonempty surviving system prompt even without a new system event; an unloaded prompt remains unavailable until its page arrives.
A complete appended prompt without a loaded request header appears as a standalone system row; only its known text is available, with no inferred request options or tool catalog. Prepending its request history replaces that standalone presentation without duplicating the prompt. In-history system prompt changes compare against the most recent request state, including earlier prompt updates without a new request header. Each request retains the prompt and change that applied at its own position. Surface replacements, including compaction, restore the last nonempty surviving system prompt even without a new system event; an unloaded prompt remains unavailable until its page arrives.
### Virtual rows
+1 -1
View File
@@ -45,7 +45,7 @@ kind: "package-reference"
视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering 分类器通过持久 splice state 只保留 next-step Inbox ID,并让后续 Context 共享当前 claimed batch。
历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。
完整的追加提示词在请求头未加载时显示为独立系统行;仅提供已知文本,不推断请求选项或工具目录。补入其请求历史后,该独立展示被替代而不重复提示词。历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。
### 虚拟行
@@ -954,6 +954,9 @@ function markdownSource(record: TableRecord): string | undefined {
function detailTabs(record: TableRecord): readonly DetailTabItem[] {
if (record.cell.kind === 'system') {
if (record.cell.promptDetail === undefined && record.cell.systemPromptDetail !== undefined) {
return SYSTEM_PROMPT_TABS.filter(tab => tab.id === 'system-prompt')
}
return record.cell.previousPromptDetail === undefined
? SYSTEM_PROMPT_TABS
: SYSTEM_UPDATE_TABS
@@ -1961,7 +1964,8 @@ export function TrajectoryTable({
const selectedPreviousPrompt = selected?.cell.kind === 'system'
? selected.cell.previousPromptDetail
: undefined
const promptSelected = selectedPrompt !== undefined
const selectedSystemPrompt = selectedPrompt?.system ?? selected?.cell.systemPromptDetail
const promptSelected = selectedSystemPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedRequestInfo = selectedRequest === null
? undefined
@@ -2947,7 +2951,7 @@ export function TrajectoryTable({
t={t}
/>
)}
{promptSelected
{selectedPrompt !== undefined
&& selectedPreviousPrompt !== undefined
&& activeTab === 'diff' && (
<SystemPromptDiff
@@ -2957,15 +2961,15 @@ export function TrajectoryTable({
/>
)}
{promptSelected && activeTab === 'system-prompt' && (
selectedPrompt.system === ''
selectedSystemPrompt === ''
? <p className={css.noPayload}>{t('record.systemPromptMissing')}</p>
: (
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
<MarkdownText text={selectedPrompt.system} labels={markdownLabels(t)} />
<MarkdownText text={selectedSystemPrompt} labels={markdownLabels(t)} />
</div>
)
)}
{promptSelected && activeTab === 'tools' && (
{selectedPrompt !== undefined && activeTab === 'tools' && (
<ToolCatalog tools={selectedPrompt.tools} t={t} />
)}
{!promptSelected
@@ -319,12 +319,13 @@ export function TrajectoryView({
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests,
systemPrompts: inspection.systemPrompts,
callSchemas,
}, t)
return { turns, lastIndex: lastCellIndex(turns) }
}, [
nodes, eventLocations, partialTurn, partialStep,
runningCalls, requests, callSchemas, t,
runningCalls, requests, inspection.systemPrompts, callSchemas, t,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<TrajectorySnapshot['partial']>(() => partial === null
@@ -37,6 +37,7 @@ export interface TrajectoryTurnModel {
/** Snapshot slice the trajectory view folds. */
export interface TrajectoryLayoutInput {
systemPrompts?: TrajectorySnapshot['systemPrompts']
nodes: TrajectorySnapshot['eventNodes']
eventLocations?: ReadonlyMap<number, ConversationLocation>
partial: TrajectorySnapshot['partial']
@@ -94,7 +95,8 @@ type OrderedLayoutEntry =
| {
kind: 'system'
seq: number
request: AssistantRequestView
request?: AssistantRequestView
systemPrompt?: string
change: RequestPromptChange
}
| {
@@ -234,6 +236,10 @@ export function deriveTrajectoryLayout(
}
const entries: OrderedLayoutEntry[] = [
...(input.systemPrompts ?? []).map(prompt => ({
kind: 'system' as const, seq: prompt.seq, systemPrompt: prompt.text,
change: { seq: prompt.seq, time: prompt.time, kind: prompt.update ? 'system' as const : 'initial' as const },
})),
...nodes.map((node, nodeIndex) => ({
kind: 'node' as const,
seq: node.seq,
@@ -306,7 +312,8 @@ export function deriveTrajectoryLayout(
kind: 'system',
text: promptChangeLabel(change, t),
sourceSeq: change.seq,
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
...(request?.prompt === undefined ? {} : { promptDetail: request.prompt }),
...(entry.systemPrompt === undefined ? {} : { systemPromptDetail: entry.systemPrompt }),
...(change.previous === undefined
? {}
: { previousPromptDetail: change.previous }),
@@ -1,7 +1,7 @@
import type {
AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot,
ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange,
RequestView, RunningToolCall, ToolCallBlock,
RequestView, RunningToolCall, SystemPromptNode, ToolCallBlock,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
@@ -16,6 +16,7 @@ export interface TrajectoryRequestHeaderState {
/** One independently assembled contribution to the legacy Trajectory ledger. */
export type TrajectoryContribution =
| { readonly kind: 'system-prompt'; readonly prompt: SystemPromptNode }
| {
readonly kind: 'node'
readonly node: ConversationNode
@@ -61,6 +62,8 @@ export interface TrajectoryConversationViewNode extends ConversationViewNode {
/** Stage-oriented Trajectory data assembled from registered business Contexts. */
export interface TrajectorySnapshot {
/** Complete loaded prompt text whose request header is outside the window. */
readonly systemPrompts?: readonly SystemPromptNode[]
readonly eventNodes: readonly ConversationNode[]
readonly eventLocations: ReadonlyMap<number, ConversationLocation>
readonly requests: readonly RequestView[]
@@ -58,6 +58,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
inputDetail?: string
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
promptDetail?: ConversationPromptSnapshot
/** Known prompt text without a loaded request config or tool catalog. */
systemPromptDetail?: string
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
previousPromptDetail?: ConversationPromptSnapshot
/** Full assistant/tool result content for the details panel. */
@@ -21,7 +21,7 @@ export interface TrajectorySystemMessageState extends SystemPromptState {
* Definition retaining each `system/message` surface node for the Trajectory
* request-header Definition, which reads it through `reader.previous` and
* presents the prompt through the request's `system` cell. A node that
* introduces or replaces the prompt contributes nothing itself; an in-history
* lacks a loaded header contributes known text without request config or tools; an in-history
* update contributes a request-header fact at its own position, since no
* `request/header` follows a prompt change that keeps the cached history.
* Surface replacements also contribute prompt changes when they remove the
@@ -74,13 +74,17 @@ function trajectorySystemMessageDefinition(inspect: SystemPromptInspector): Conv
}
},
update: context => context.state,
buildViewNode: context => context.state?.header === undefined
|| context.state.header.seq !== context.start?.event.seq
? null
: trajectoryNode(context, context.state.header.seq, {
kind: 'request-header',
header: context.state.header,
}),
buildViewNode: (context) => {
const state = context.state
if (state?.header !== undefined && state.header.seq === context.start?.event.seq) {
return trajectoryNode(context, state.header.seq, { kind: 'request-header', header: state.header })
}
const prompt = state?.introduced
return prompt !== undefined && prompt.text !== ''
&& context.start?.event.type === 'system/message' && context.start.event.surfaceOp === 'append'
? trajectoryNode(context, prompt.seq, { kind: 'system-prompt', prompt })
: null
},
}
}
/* jscpd:ignore-end */
@@ -199,6 +199,9 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
: previous?.change === undefined ? {} : { change: previous.change }),
})
}
const representedPrompts = new Set(this.contributions.flatMap(node =>
node.data.kind === 'request-header' && node.data.header.change !== undefined ? [node.data.header.change.seq] : []))
const systemPrompts: NonNullable<TrajectorySnapshot['systemPrompts']>[number][] = []
const finalized: ConversationNode[] = []
const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
const requests: RequestView[] = []
@@ -218,6 +221,10 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
for (const contribution of this.contributions) {
const data = contribution.data
if (data.kind === 'system-prompt') {
if (!representedPrompts.has(data.prompt.seq)) systemPrompts.push(data.prompt)
continue
}
if (data.kind === 'request-header') {
previousHeader = data.header
previousTools = indexTools(data.header.prompt.tools)
@@ -274,6 +281,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
const eventNodes = finalized
return {
eventNodes,
...systemPrompts.length > 0 ? { systemPrompts } : {},
eventLocations,
requests,
callSchemas,
@@ -637,6 +637,31 @@ describe('Trajectory conversation Definitions', () => {
])
})
it('shows a complete appended prompt at the start of a headerless window', () => {
const value = assembler([
at(10, 'system/message', {
turn: 2, step: 1, message: systemMessage('known prompt'),
}, { surfaceOp: 'append' }),
at(11, 'step/end', { turn: 2, step: 1 }),
at(12, 'step/start', { turn: 2, step: 2 }),
at(13, 'assistant/message', {
turn: 2, step: 2, message: assistantMessage('window-assistant', 'answer'),
}),
], true)
const request = snapshot(value).requests.find(request => request.purpose === 'assistant')
expect(request?.purpose === 'assistant' && request.prompt).toBeUndefined()
expect(request?.requestConfig).toBeUndefined()
expect(snapshot(value).systemPrompts).toMatchObject([{ seq: 10, text: 'known prompt', update: false }])
value.prepend([
at(1, 'system/message', { turn: 1, step: 1, message: systemMessage('original') }, { surfaceOp: 'append' }),
at(2, 'request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' } } }),
], false)
value.flush()
expect(snapshot(value).systemPrompts).toBeUndefined()
expect(snapshot(value).requests.find(request => request.purpose === 'assistant'))
.toMatchObject({ prompt: { system: 'known prompt', config: { provider: 'test', model: 'test' } } })
})
it('carries an in-history prompt update into later requests as a system change at its own position', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -804,6 +829,8 @@ describe('Trajectory conversation Definitions', () => {
at(10, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('reply', 'reply') }),
])
expect(snapshot(value).requests.at(-1)).toMatchObject({ prompt: { system: '' } })
const uncertain = assembler([system(6, 'C', 3), system(7, 'Known but unordered')], true)
expect(snapshot(uncertain).systemPrompts).toBeUndefined()
value.prepend([system(1, 'A'), system(3, 'B'), system(5, 'A2', 1)], false)
value.flush()
expect(snapshot(value).requests.at(-1)).toMatchObject({ prompt: { system: 'C' } })
@@ -6,7 +6,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import type { ComponentProps } from 'react'
import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import { deriveTrajectoryLayout, type TrajectoryTurnModel } from '../src/client/layout.ts'
import { trajectoryRecordId } from '../src/client/trajectory-record.ts'
import { t, tZh } from './locale.client.ts'
@@ -124,6 +124,20 @@ const FOLD_PROPS = {
}
describe('TrajectoryTable', () => {
it('shows known standalone prompt text without a fabricated tool catalog or request options', () => {
const turns = deriveTrajectoryLayout({
nodes: [], partial: null, runningCalls: [],
systemPrompts: [{ seq: 10, time: 10, turn: 2, step: 1, text: '# Known instructions', update: false }],
}, t)
expect(turns.flatMap(turn => turn.groups.flatMap(group => group.cells))).toMatchObject([
{ kind: 'system', text: 'Initial System Prompt', systemPromptDetail: '# Known instructions' },
])
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /SYSTEM/ }))
expect(screen.getByRole('heading', { name: 'Known instructions' })).toBeTruthy()
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['System Prompt'])
})
it('shows a muted placeholder for an assistant response containing only tool calls', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
@@ -1271,7 +1271,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.trajectory.images\', () => ctx.slots.register(\n { name: \'conversation.trajectory.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:95',
source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:98',
},
{
key: 'conversation.view',