Преглед на файлове

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.
Tianyi Cui преди 4 дни
родител
ревизия
cea0aff474

+ 2 - 2
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

+ 1 - 1
packages/client/ui-chat/README.md

@@ -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
packages/client/ui-chat/README.zh.md

@@ -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 顺序一致;展开后显示保留原始换行的精确模型可见文本。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
 
 -----
 

+ 15 - 12
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<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 {

+ 30 - 7
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', () => {

+ 2 - 2
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

+ 1 - 1
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
 

+ 1 - 1
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 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。
 
 ### 虚拟行
 

+ 9 - 5
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' && (
               <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

+ 2 - 1
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<TrajectorySnapshot['partial']>(() => partial === null

+ 9 - 2
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<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 }),

+ 4 - 1
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<number, ConversationLocation>
   readonly requests: readonly RequestView[]

+ 2 - 0
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -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. */

+ 12 - 8
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 */

+ 8 - 0
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<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,

+ 27 - 0
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' } })

+ 15 - 1
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(<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,

+ 1 - 1
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',