From cea0aff474d607136e81c4b8e5fbaf3167a792fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:00:13 +0800 Subject: [PATCH] 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. --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../conversation-nodes/request-prompt.ts | 27 ++++++++------ ...nversation-node-definitions.client.spec.ts | 37 +++++++++++++++---- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryTable.tsx | 14 ++++--- .../src/client/TrajectoryView.tsx | 3 +- .../client/ui-trajectory/src/client/layout.ts | 11 +++++- .../src/client/trajectory-contract.ts | 5 ++- .../src/client/trajectory-record.ts | 2 + .../trajectory-request-header-definition.ts | 20 ++++++---- .../src/client/trajectory-snapshot-builder.ts | 8 ++++ .../conversation-definitions.client.spec.ts | 27 ++++++++++++++ .../ui-trajectory/tests/table.client.spec.tsx | 16 +++++++- .../src/client/slot-catalog.ts | 2 +- 18 files changed, 142 insertions(+), 46 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index a49e996625..14930f86cb 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5995c8cdc2..1b60d08014 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -25,7 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin ## 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. ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 23ba608474..f80d5afe3a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition ## 系统提示词行 -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 顺序一致;展开后显示保留原始换行的精确模型可见文本。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 ----- diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index 60b251b478..fd14a8115c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -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('request-prompt')?.state - const system = reader.previous('system-message')?.state.effective + const systemContext = reader.previous('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 { diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index c9303c0612..a6846aca36 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -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', () => { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 7c9e199013..922513b970 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 2dd236ecec..ea7e902213 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -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 diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index a7a521bfe0..1e875c48d6 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" 视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering 分类器通过持久 splice state 只保留 next-step Inbox ID,并让后续 Context 共享当前 claimed batch。 -历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。 +完整的追加提示词在请求头未加载时显示为独立系统行;仅提供已知文本,不推断请求选项或工具目录。补入其请求历史后,该独立展示被替代而不重复提示词。历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。 ### 虚拟行 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index c7c592e235..d2a952704f 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -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' && ( )} {promptSelected && activeTab === 'system-prompt' && ( - selectedPrompt.system === '' + selectedSystemPrompt === '' ?

{t('record.systemPromptMissing')}

: (
- +
) )} - {promptSelected && activeTab === 'tools' && ( + {selectedPrompt !== undefined && activeTab === 'tools' && ( )} {!promptSelected diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index a9f1df1b36..4f8bc63d9c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -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(() => partial === null diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index d8076b5797..fd361899d0 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -37,6 +37,7 @@ export interface TrajectoryTurnModel { /** Snapshot slice the trajectory view folds. */ export interface TrajectoryLayoutInput { + systemPrompts?: TrajectorySnapshot['systemPrompts'] nodes: TrajectorySnapshot['eventNodes'] eventLocations?: ReadonlyMap 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 }), diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index 37571f2b83..5507fd18ea 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -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 readonly requests: readonly RequestView[] diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index da330f2ae4..2bcb268f22 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -58,6 +58,8 @@ export interface TrajectoryCellProps extends HTMLAttributes { 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. */ diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index d8e8a754dc..488dca8775 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -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 */ diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 60aed23058..07f6e5d2f3 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -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[number][] = [] const finalized: ConversationNode[] = [] const eventLocations = new Map() 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, diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index 54aaefd3f0..864e599d1a 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -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' } }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 89a8165a29..b16475162a 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -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() + 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, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 78b9f15ce0..18e60de284 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -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',