fix(client): derive effective system prompts from surviving surface nodes
Chronological lookup keeps B after compaction shadows it even though the model sees surviving head A and no system event is emitted. Share pure extraction and compact immutable surface interpretation through uiConversation; target-owned Definitions preserve historical cards. Track inherited surface positions in a map containing only surviving replacement endpoints. No full-log scans or prompt work on ordinary messages/chunks. A partial window cannot infer the order of older unindexed endpoints: pre-window [head seq5, tail seq3], followed by replacements 3->6 and 5->7, must not report seq7 as effective. Withhold subsequent prompt text until prepend reconstructs the prefix; exact pure and both target regressions cover unavailable -> C. Drop shadowed replacement-index entries; repeated rewriting one node keeps one entry, while historical prefix maps remain immutable. Current replacement work is O(S+P), where S is surviving system nodes and P surviving replacement endpoints; history retains per-prefix copies, not an unbounded linked chain. Coverage includes A->B->C, A->B->compaction->A without a system event in replay/live/partial windows, chained nonmonotonic replacements, empty tails, head rewrites, unknown ordering, inherited prompt withholding, and index pruning. Focused suites total 89 passing cases; rebuilt trajectory smoke 3 passed. Affected client types/lint/bundles, diff checks, and 33/33 doc-sync gates passed. README EN/ZH and owning note updated. Parent owns final GUI/recorded-session evidence; no server started.
This commit is contained in:
1 parent
9c8884284f
commit
b3116b6048
20 files changed
+446
-122
No files matched your search
+2
-2
@@ -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 .agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md
|
||||
2026-09-02-in-history-system-prompt-replacement.md: c2cc69fd2aaffc2e89e88f74aee171ee61bf35b8
|
||||
2026-09-02-in-history-system-prompt-replacement.zh.md: 6ff2ccaea4e340ebdac8c0d686e9920fa1ee4b0e
|
||||
2026-09-02-in-history-system-prompt-replacement.md: f5b1aa13f830ea4e312f79074e913c664d24b9f9
|
||||
2026-09-02-in-history-system-prompt-replacement.zh.md: 7207d7241992c01f42bcc1b6c70152cb6e7105da
|
||||
@@ -47,6 +47,8 @@ Web presents an appended in-history node at its own position. `SystemPromptNode`
|
||||
|
||||
Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence.
|
||||
|
||||
Chat and Trajectory interpret the effective prompt through the pure `uiConversation.inspectSystemPrompt` operation. Each target keeps immutable prefix states for system events and positional replacements, with only surviving system nodes and a map of surviving replacement sequences to inherited surface positions. Each replacement copies that map and removes shadowed entries; historical prefix maps remain immutable. Ordinary appends and streaming updates require no prompt fold. Surface order, rather than event order or provenance citations, determines which nodes survive: a compaction can restore an older prompt without another system event, and a head rewrite can have a greater sequence than a later active prompt. Empty nodes remain addressable but do not override a nonempty prompt. An endpoint older than the earliest relevant loaded event has unknown order unless its replacement position is indexed. The interpreter withholds all subsequent prompt text after such an endpoint until prepend replay resolves the missing prefix; numeric event order cannot establish surface order. Historical cards keep their own prefix state rather than reading the final surface.
|
||||
|
||||
### Compaction
|
||||
|
||||
`compaction-basic` is unchanged. `selectCompactableRange` still anchors at the first non-system node, so node 0 is never shadowed and later in-history nodes can be; `buildSummarizationInput` replays node 0's text as the summarizer `system` and every shadowed node's derived message in surface order, so a mid-region system node is replayed in place and the summarization call remains a genuine prefix of the conversation.
|
||||
|
||||
+2
@@ -47,6 +47,8 @@ Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携
|
||||
|
||||
Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。
|
||||
|
||||
Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释有效提示词。每个 target 为系统事件与位置替换保留不可变的前缀状态,其中只包含存活系统节点,以及将存活替换序号映射到继承 surface 位置的映射表。每次替换复制该表并删除被遮蔽的条目;历史前缀映射表保持不可变。普通追加与流式更新无需折叠提示词。节点是否存活由 surface 顺序决定,而不是事件顺序或来源引用:压缩可以在没有另一个系统事件的情况下恢复更早的提示词,头部重写的序号也可能大于更后位置的有效提示词。空节点仍可被定位,但不会覆盖非空提示词。早于最早已加载相关事件的端点,其顺序未知,除非已有替换位置索引。遇到这样的端点后,解释器会暂停公开之后的所有提示词文本,直到向前补页回放解析缺失的前缀;事件序号顺序不能确定 surface 顺序。历史卡片保留自己的前缀状态,而不是读取最终 surface。
|
||||
|
||||
### 压缩
|
||||
|
||||
`compaction-basic` 不变。`selectCompactableRange` 仍锚定在第一个非系统节点,因此第 0 号节点永不被遮蔽,更后的历史内节点则可能被遮蔽;`buildSummarizationInput` 把第 0 号节点的文本作为摘要器的 `system` 回放,并按 surface 顺序回放每个被遮蔽节点的派生消息,因此区域中途的系统节点在原位被回放,摘要调用仍是对话的真实前缀。
|
||||
|
||||
@@ -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: c5ca444762245786048867f074210d35dad2ac25
|
||||
README.md: 5995c8cdc2a96919780f3be7ebe67ea12584e17e
|
||||
README.zh.md: 6252237a3b220511fe9489ef84495c5950053c4f
|
||||
@@ -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 text from the latest `system/message` node before 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.
|
||||
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.
|
||||
|
||||
<a id="turn-token-usage"></a>
|
||||
## Turn token usage
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector,
|
||||
SystemPromptNode,
|
||||
SystemPromptState, SystemPromptInspector,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
@@ -56,36 +56,29 @@ function stableRequestPromptAnchor(
|
||||
* 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.
|
||||
* position, so it presents its own `system-prompt` card there. Positional
|
||||
* replacements advance the effective prompt without changing historical cards.
|
||||
* @param inspect - Pure surface interpretation supplied by uiConversation.
|
||||
* @returns The Chat system-prompt Definition.
|
||||
*/
|
||||
export const systemMessageDefinition: ConversationNodeDefinition<SystemPromptNode> = {
|
||||
kind: 'system-message',
|
||||
target: 'chat',
|
||||
match: event => event.type === 'system/message'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'system/message') {
|
||||
throw new Error('system-message start requires system/message')
|
||||
}
|
||||
return {
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
turn: match.event.data.turn,
|
||||
step: match.event.data.step,
|
||||
text: match.event.data.message.content
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
.join(''),
|
||||
update: match.event.surfaceOp === 'append'
|
||||
&& reader.previous<SystemPromptNode>('system-message') !== undefined,
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state
|
||||
if (state === undefined || !state.update || state.text === '') return null
|
||||
return chatNode(context, 'system-prompt', state.seq, { text: state.text, update: true })
|
||||
},
|
||||
export function systemMessageDefinition(inspect: SystemPromptInspector): ConversationNodeDefinition<SystemPromptState> {
|
||||
return {
|
||||
kind: 'system-message',
|
||||
target: 'chat',
|
||||
match: event => event.type === 'system/message'
|
||||
|| ('surfaceOp' in event && event.surfaceOp !== undefined && event.surfaceOp !== 'append')
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
return inspect(reader.previous<SystemPromptState>('system-message')?.state, match.event)
|
||||
},
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +100,7 @@ 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<SystemPromptNode>(systemMessageDefinition.kind)?.state
|
||||
const system = reader.previous<SystemPromptState>('system-message')?.state.effective
|
||||
const location = match.location.kind === 'step'
|
||||
? { turn: match.location.turn.turn, step: match.location.step.step }
|
||||
: {}
|
||||
@@ -157,7 +150,9 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
|
||||
* @param ctx - Owning UI Conversation context.
|
||||
*/
|
||||
export function registerRequestPromptConversationNode(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(systemMessageDefinition)
|
||||
ctx.uiConversation.events.register(systemMessageDefinition(
|
||||
(previous, event) => ctx.uiConversation.inspectSystemPrompt(previous, event),
|
||||
))
|
||||
ctx.uiConversation.events.register(requestPromptDefinition(
|
||||
(previous, event, system) => ctx.uiConversation.inspectRequestPrompt(previous, event, system),
|
||||
))
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts'
|
||||
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
|
||||
@@ -36,7 +37,7 @@ import type {
|
||||
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
|
||||
nextStepInboxDefinition,
|
||||
messageDefinition,
|
||||
systemMessageDefinition,
|
||||
systemMessageDefinition(inspectSystemPrompt),
|
||||
requestPromptDefinition(inspectRequestPrompt),
|
||||
assistantDefinition,
|
||||
turnProcessDefinition,
|
||||
@@ -239,9 +240,8 @@ describe('built-in conversation node Definitions', () => {
|
||||
}
|
||||
const state = { seq: 1, time: 1, turn: 1, step: 1, text: '# System', update: false }
|
||||
|
||||
expect(() => systemMessageDefinition.start({} as never, invalidStart, {} as never))
|
||||
.toThrow('system-message start requires system/message')
|
||||
expect(systemMessageDefinition.update({ state } as never, invalidStart)).toBe(state)
|
||||
expect(systemMessageDefinition(inspectSystemPrompt).match(invalidStart.event)).toBeNull()
|
||||
expect(systemMessageDefinition(inspectSystemPrompt).update({ state } as never, invalidStart)).toBe(state)
|
||||
})
|
||||
|
||||
it('keeps ordinary command-only history inactive for the Conversation shell', () => {
|
||||
@@ -1483,7 +1483,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([])
|
||||
})
|
||||
|
||||
it('renders a windowed prompt from its in-window system node and fills a missing node after prepend', () => {
|
||||
it('withholds windowed replacement prompts until prepend resolves their positions', () => {
|
||||
const windowed = assembler([
|
||||
systemAt(10, '# Resumed prompt', 5),
|
||||
at(11, 'request/header', {
|
||||
@@ -1503,7 +1503,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
header: { config: { provider: 'fake', model: 'fake' } },
|
||||
}),
|
||||
])
|
||||
expect(node(snapshot(windowed), 'system-prompt')?.data).toEqual({ text: '# Resumed prompt' })
|
||||
expect(node(snapshot(windowed), 'system-prompt')).toBeUndefined()
|
||||
expect(node(snapshot(nodeless), 'system-prompt')).toBeUndefined()
|
||||
expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined()
|
||||
|
||||
@@ -1555,6 +1555,63 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(node(current, 'system-prompt')?.data).toEqual({ text: '# System\n\nFollow instructions.' })
|
||||
})
|
||||
|
||||
it.each(['replay', 'live', 'partial'] as const)('restores A when compaction shadows B without a new system event (%s)', (mode) => {
|
||||
const history = [
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'system/message', { turn: 1, step: 1, message: systemMessage('A') }, { surfaceOp: 'append' }),
|
||||
at(4, 'request/header', {
|
||||
reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [] },
|
||||
}),
|
||||
at(5, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('a', 'a') }),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'step/start', { turn: 1, step: 2 }),
|
||||
at(8, 'system/message', { turn: 1, step: 2, message: systemMessage('B') }, { surfaceOp: 'append' }),
|
||||
at(9, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('b', 'b') }),
|
||||
at(10, 'step/end', { turn: 1, step: 2 }),
|
||||
at(11, 'step/start', { turn: 1, step: 3 }),
|
||||
at(12, 'user/message', {
|
||||
turn: 1, step: 3, id: 'summary', role: 'user',
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compaction' },
|
||||
}, { surfaceOp: { op: 'replace', start: 5, end: 9 }, sourceEventSeqs: [5, 8, 9] }),
|
||||
at(13, 'request/header', {
|
||||
reason: 'series', header: { config: { provider: 'test', model: 'test' }, tools: [] },
|
||||
}),
|
||||
at(14, 'assistant/message', { turn: 1, step: 3, message: assistantMessage('restored', 'restored') }),
|
||||
at(15, 'step/end', { turn: 1, step: 3 }),
|
||||
]
|
||||
const value = assembler(mode === 'replay' ? history : [])
|
||||
if (mode === 'partial') {
|
||||
value.replaceWindow(history.slice(7), true)
|
||||
value.flush()
|
||||
expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt')).toEqual([])
|
||||
value.prepend(history.slice(0, 7), false)
|
||||
value.flush()
|
||||
}
|
||||
if (mode === 'live') {
|
||||
for (const entry of history) {
|
||||
value.append(entry)
|
||||
value.flush()
|
||||
}
|
||||
}
|
||||
expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt')
|
||||
.map(candidate => candidate.data)).toEqual([
|
||||
{ text: 'A' }, { text: 'B', update: true }, { text: 'A' },
|
||||
])
|
||||
})
|
||||
|
||||
it('withholds reversed unknown replacement endpoints and resolves them after prepend', () => {
|
||||
const value = assembler([
|
||||
systemAt(6, 'C', 3), systemAt(7, 'D', 5),
|
||||
at(8, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }),
|
||||
], true)
|
||||
expect(node(snapshot(value), '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' }])
|
||||
})
|
||||
|
||||
it('never renders a system/message as a transcript bubble', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
@@ -1718,7 +1775,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
|
||||
})
|
||||
|
||||
it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => {
|
||||
it('places a withheld replacement prompt after prepend supplies its original node', () => {
|
||||
const reasons = ['change', 'resume', 'series'] as const
|
||||
for (const reason of reasons) {
|
||||
const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
|
||||
@@ -1736,11 +1793,8 @@ describe('built-in conversation node Definitions', () => {
|
||||
const before = snapshot(windowed)
|
||||
const prompt = node(before, 'system-prompt')
|
||||
const user = node(before, 'user')
|
||||
if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete')
|
||||
const stableOrder = [user.key, prompt.key]
|
||||
expect(prompt.anchorSeq).toBe(10)
|
||||
expect(prompt.data).toEqual({ text: windowedSystem })
|
||||
expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
|
||||
expect(prompt).toBeUndefined()
|
||||
if (user === undefined) throw new Error('windowed user fixture is incomplete')
|
||||
|
||||
windowed.prepend([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
@@ -1760,8 +1814,8 @@ describe('built-in conversation node Definitions', () => {
|
||||
return candidate?.kind === 'system-prompt' ? [candidate] : []
|
||||
})
|
||||
expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 10])
|
||||
expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(10)
|
||||
expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
|
||||
expect(prompts.at(-1)?.data).toEqual({ text: windowedSystem })
|
||||
expect(restored.nodes.get(user.key)).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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-conversation/README.md
|
||||
README.md: 4f9a060894445c76d0ed835cad21741b46f14dd6
|
||||
README.zh.md: 613b74867d06c5579e9c6a83f105a5e0d5ab5800
|
||||
README.md: d698603d76fab1627a47268357450a17441f7b95
|
||||
README.zh.md: ad7684b7036af80e6779ed8c161babda56f52a3b
|
||||
@@ -31,7 +31,7 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o
|
||||
|
||||
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
|
||||
|
||||
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request-inspection contract serves every target: a `SystemPromptNode` retains one `system/message` node with its `turn`, `step`, `text`, and `update` flag — true for a prompt appended after an earlier loaded system node, an in-history update presented where it was committed rather than by the next request header — and `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies a header's initial, system, or tool change, reporting no system change for a header that follows an in-history update.
|
||||
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work.
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
## Shell and standard props
|
||||
|
||||
@@ -31,7 +31,7 @@ adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type`
|
||||
|
||||
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
|
||||
|
||||
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查约定服务于每个 target:`SystemPromptNode` 保留一个 `system/message` 节点及其 `turn`、`step`、`text` 与 `update` 标志——追加在较早的已加载系统节点之后的提示词为 true,即在其提交位置而非由下一个请求头呈现的历史内更新——`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 将请求头归类为初始、系统或工具变更,并对跟随历史内更新的请求头报告无系统变更。
|
||||
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
## Shell 与标准 props
|
||||
|
||||
@@ -26,11 +26,11 @@ export interface ConversationPromptSnapshot {
|
||||
tools: readonly ToolSchema[]
|
||||
}
|
||||
|
||||
/** Facts a Definition retains from one `system/message` surface event. */
|
||||
/** Effective prompt or introduced system node, anchored at the event that establishes it. */
|
||||
export interface SystemPromptNode {
|
||||
/** Sequence of the `system/message` event. */
|
||||
/** Sequence of the system event or replacement that establishes this prompt. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the `system/message` event. */
|
||||
/** Unix epoch ms of that event. */
|
||||
time: number
|
||||
/** Turn the loop committed the node in. */
|
||||
turn: number
|
||||
@@ -86,7 +86,7 @@ export type RequestPromptInspector = (
|
||||
* classify the model-visible prompt change.
|
||||
* @param previous - Prompt from the preceding loaded request header, when available.
|
||||
* @param event - Durable full request header to inspect.
|
||||
* @param system - Latest `system/message` node before the header within the loaded window, when available.
|
||||
* @param system - Effective nonempty system prompt after loaded surface replacements; empty when removed.
|
||||
* An in-history update already presented its text at its own position, so the header reports no system change for it.
|
||||
* @returns The canonical prompt and an initial/system/tool change when it can be established.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Immutable system-only interpretation of the loaded Session surface. */
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SystemPromptNode } from './request-inspection.ts'
|
||||
|
||||
interface PositionedSystem {
|
||||
readonly position: number
|
||||
readonly node: SystemPromptNode
|
||||
}
|
||||
|
||||
/** Prompt facts at one log prefix; earlier instances remain valid for historical cards. */
|
||||
export interface SystemPromptState {
|
||||
/** Earliest relevant loaded event; older unindexed endpoints have unknown surface positions. */
|
||||
readonly firstSeq: number
|
||||
/** Missing endpoint order makes the prompt unavailable until prepend replay supplies it. */
|
||||
readonly uncertain: boolean
|
||||
/** Loaded system nodes in surface order, including empty dormant nodes. */
|
||||
readonly nodes: readonly PositionedSystem[]
|
||||
/** Surviving replacement endpoints only, mapped to inherited surface positions. */
|
||||
readonly replacements: ReadonlyMap<number, number>
|
||||
/** Effective prompt and its change origin; empty text records removal, undefined means unavailable. */
|
||||
readonly effective: SystemPromptNode | undefined
|
||||
/** System event at this position, if any; only this node may own an update card. */
|
||||
readonly introduced: SystemPromptNode | undefined
|
||||
}
|
||||
|
||||
/** Pure interpretation supplied to target-owned Definitions through uiConversation. */
|
||||
export type SystemPromptInspector = (previous: SystemPromptState | undefined, event: SessionEvent) => SystemPromptState
|
||||
|
||||
/**
|
||||
* Apply a system event or positional replacement without retaining ordinary messages.
|
||||
* Replacement positions inherit their start endpoint, not their chronological seq.
|
||||
* Unknown older endpoint order withholds the prompt until prepend replay resolves it.
|
||||
* @param previous - Interpretation at the preceding relevant event in the loaded window.
|
||||
* @param event - System message or surface replacement already admitted by Session.
|
||||
* @returns Immutable surviving system facts and the effective nonempty prompt.
|
||||
*/
|
||||
export function inspectSystemPrompt(previous: SystemPromptState | undefined, event: SessionEvent): SystemPromptState {
|
||||
const op = 'surfaceOp' in event ? event.surfaceOp : undefined
|
||||
const firstSeq = previous?.firstSeq ?? event.seq
|
||||
let nodes = previous?.nodes ?? []
|
||||
let replacements = previous?.replacements ?? new Map<number, number>()
|
||||
const unknownEndpoint = (seq: number): boolean => seq < firstSeq && !replacements.has(seq)
|
||||
const uncertain = previous?.uncertain === true || (op !== undefined && op !== 'append'
|
||||
&& (unknownEndpoint(op.start) || unknownEndpoint(op.end)))
|
||||
if (uncertain) {
|
||||
return { firstSeq, uncertain, nodes: [], replacements: new Map(), effective: undefined, introduced: undefined }
|
||||
}
|
||||
let position: number = event.seq
|
||||
if (op !== undefined && op !== 'append') {
|
||||
position = replacements.get(op.start) ?? op.start
|
||||
const end = replacements.get(op.end) ?? op.end
|
||||
nodes = nodes.filter(item => item.position < position || item.position > end)
|
||||
const retained = new Map([...replacements].filter(([, value]) => value < position || value > end))
|
||||
retained.set(event.seq, position)
|
||||
replacements = retained
|
||||
}
|
||||
const introduced: SystemPromptNode | undefined = event.type === 'system/message'
|
||||
? {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
text: event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join(''),
|
||||
update: op === 'append' && previous?.nodes.some(item => item.node.text !== '') === true,
|
||||
}
|
||||
: undefined
|
||||
if (introduced !== undefined) {
|
||||
nodes = [...nodes, { position, node: introduced }].sort((a, b) => a.position - b.position)
|
||||
}
|
||||
const surviving = nodes.findLast(item => item.node.text !== '')?.node
|
||||
const effective = surviving === previous?.nodes.findLast(item => item.node.text !== '')?.node
|
||||
? previous?.effective
|
||||
: introduced !== undefined && introduced === surviving
|
||||
? introduced
|
||||
: {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: surviving?.turn ?? 0,
|
||||
step: surviving?.step ?? 0,
|
||||
text: surviving?.text ?? '',
|
||||
update: false,
|
||||
}
|
||||
return { firstSeq, uncertain, nodes, replacements, effective, introduced }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ConversationPromptSnapshot, RequestPromptInspection, SystemPromptNode,
|
||||
} from '../contract/request-inspection.ts'
|
||||
import { inspectRequestPrompt } from '../contract/request-inspection.ts'
|
||||
import { inspectSystemPrompt, type SystemPromptState } from '../contract/system-prompt.ts'
|
||||
import { ConversationNodeAssembler } from './assembler.ts'
|
||||
import { ConversationEventRegistry } from './event-registry.ts'
|
||||
import { HistoricalImageCache } from './historical-images.ts'
|
||||
@@ -271,6 +272,16 @@ export class UiConversation extends Service {
|
||||
return this.images.seed(sessionId, attachment, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a system message or surface replacement for target-owned prompt Definitions.
|
||||
* @param previous - System facts at the preceding relevant loaded event.
|
||||
* @param event - Durable system message or positional replacement.
|
||||
* @returns Immutable prompt interpretation at this event.
|
||||
*/
|
||||
inspectSystemPrompt(previous: SystemPromptState | undefined, event: SessionEvent): SystemPromptState {
|
||||
return inspectSystemPrompt(previous, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize one `request/header` event against the previous prompt state
|
||||
* and the `system/message` node in force.
|
||||
@@ -280,7 +291,7 @@ export class UiConversation extends Service {
|
||||
* client bundles.
|
||||
* @param previous - prompt recorded by the preceding loaded header, if any.
|
||||
* @param event - the `request/header` session event to interpret.
|
||||
* @param system - latest loaded `system/message` node before the header, if any.
|
||||
* @param system - effective prompt after loaded surface replacements, if any.
|
||||
* @returns the canonical prompt snapshot and any model-visible change.
|
||||
*/
|
||||
inspectRequestPrompt(
|
||||
|
||||
@@ -35,6 +35,7 @@ export type {
|
||||
SystemPromptNode,
|
||||
} from './contract/request-inspection.ts'
|
||||
export { inspectRequestPrompt } from './contract/request-inspection.ts'
|
||||
export type { SystemPromptState, SystemPromptInspector } from './contract/system-prompt.ts'
|
||||
export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
|
||||
|
||||
export { ConversationNodeAssembler } from './conversation/assembler.ts'
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectSystemPrompt, type SystemPromptState } from '../src/client/contract/system-prompt.ts'
|
||||
|
||||
function system(seq: number, text: string, replaces?: number): SessionEvent {
|
||||
return {
|
||||
seq, time: seq, type: 'system/message',
|
||||
data: { turn: 1, step: seq, message: { role: 'system', content: [{ type: 'text', text }] } },
|
||||
surfaceOp: replaces === undefined ? 'append' : { op: 'replace', start: replaces, end: replaces },
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
function replace(seq: number, start: number, end: number): SessionEvent {
|
||||
return { seq, time: seq, type: 'user/message', surfaceOp: { op: 'replace', start, end } } as SessionEvent
|
||||
}
|
||||
|
||||
function fold(events: readonly SessionEvent[]): SystemPromptState | undefined {
|
||||
return events.reduce<SystemPromptState | undefined>((previous, event) => inspectSystemPrompt(previous, event), undefined)
|
||||
}
|
||||
|
||||
describe('loaded system surface interpretation', () => {
|
||||
it('withholds unknown replacement order until the missing prefix is loaded', () => {
|
||||
const prefix = [system(1, 'A'), system(3, 'B'), system(5, 'A2', 1)]
|
||||
const tail = [system(6, 'C', 3), system(7, 'D', 5)]
|
||||
expect(fold(tail)?.effective).toBeUndefined()
|
||||
expect(fold([...prefix, ...tail])?.effective?.text).toBe('C')
|
||||
})
|
||||
|
||||
it('restores A after compaction shadows B while preserving historical state', () => {
|
||||
const a = fold([system(1, 'A')])
|
||||
const b = inspectSystemPrompt(a, system(4, 'B'))
|
||||
const restored = inspectSystemPrompt(b, replace(7, 3, 5))
|
||||
expect(restored.effective).toMatchObject({ text: 'A', seq: 7, update: false })
|
||||
expect(a?.effective?.text).toBe('A')
|
||||
expect(b.effective?.text).toBe('B')
|
||||
expect(inspectSystemPrompt(restored, replace(8, 7, 7)).effective).toBe(restored.effective)
|
||||
})
|
||||
|
||||
it('uses surface order across chained replacements with reversed chronological endpoints', () => {
|
||||
const state = fold([
|
||||
system(1, 'A'), system(4, 'B'), system(6, 'C'),
|
||||
system(8, 'new A', 1), replace(9, 3, 5), replace(10, 9, 6),
|
||||
])
|
||||
expect(state?.effective).toMatchObject({ text: 'new A', seq: 10 })
|
||||
expect(state?.nodes.map(item => item.node.text)).toEqual(['new A'])
|
||||
})
|
||||
|
||||
it('keeps later nonempty nodes authoritative over head rewrites and ignores empty tails', () => {
|
||||
const b = fold([system(1, 'A'), system(3, 'B'), system(5, 'head', 1)])
|
||||
expect(b?.effective?.text).toBe('B')
|
||||
const head = inspectSystemPrompt(b, system(6, '', 3))
|
||||
expect(head.effective).toMatchObject({ text: 'head', seq: 6, update: false })
|
||||
expect(inspectSystemPrompt(head, system(7, '', 5)).effective?.text).toBe('')
|
||||
})
|
||||
|
||||
it('does not invent an unloaded head after a cross-window replacement', () => {
|
||||
const state = fold([system(8, 'B'), replace(12, 5, 9)])
|
||||
expect(state?.nodes).toEqual([])
|
||||
expect(state?.effective).toBeUndefined()
|
||||
expect(fold([replace(12, 5, 9)])?.effective).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops shadowed replacement endpoints without mutating historical prefixes', () => {
|
||||
const first = fold([system(1, 'A'), system(2, 'B', 1)])
|
||||
let state = first
|
||||
for (let seq = 3; seq < 100; seq++) state = inspectSystemPrompt(state, system(seq, 'C', seq - 1))
|
||||
expect(state?.replacements.size).toBe(1)
|
||||
expect([...state!.replacements]).toEqual([[99, 1]])
|
||||
expect([...first!.replacements]).toEqual([[2, 1]])
|
||||
expect(first?.effective?.text).toBe('B')
|
||||
})
|
||||
|
||||
it('extracts text blocks once and treats an empty initial node as no prompt', () => {
|
||||
const event = system(1, 'A')
|
||||
if (event.type !== 'system/message') throw new Error('expected system event')
|
||||
event.data.message.content = [{ type: 'text', text: 'A' }, { type: 'text', text: 'B' }]
|
||||
expect(inspectSystemPrompt(undefined, event).effective?.text).toBe('AB')
|
||||
expect(inspectSystemPrompt(undefined, system(2, '')).effective).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -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: 0a80916e6ce0f845ff43b7a4ed007e1e294c7c0b
|
||||
README.zh.md: bd556e0bdce5db4ddc81dd7dd5105a8403bed91e
|
||||
README.md: 2dd236ecec41137e39b017b79bfbec84d536bebc
|
||||
README.zh.md: a7a521bfe0a0ce2b76633e3145e67d87b344c20f
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ kind: "package-reference"
|
||||
|
||||
视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering 分类器通过持久 splice state 只保留 next-step Inbox ID,并让后续 Context 共享当前 claimed batch。
|
||||
|
||||
历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。
|
||||
历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。
|
||||
|
||||
### 虚拟行
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, RequestPromptInspector, SystemPromptNode,
|
||||
ConversationNodeDefinition, RequestPromptInspector, SystemPromptState, SystemPromptInspector,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { trajectoryNode } from './trajectory-definition-common.ts'
|
||||
import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts'
|
||||
|
||||
/** A retained system node plus, for an in-history update, the request facts it changes. */
|
||||
export interface TrajectorySystemMessageState extends SystemPromptNode {
|
||||
/** Loaded system surface plus the latest request facts changed by an append or compaction. */
|
||||
export interface TrajectorySystemMessageState extends SystemPromptState {
|
||||
/**
|
||||
* Present for an update that follows a loaded request header: that header's
|
||||
* prompt with this node's text, so later requests in the ledger show the
|
||||
* prompt the model read and the first of them carries the change.
|
||||
* Latest synthetic header, retained across unrelated replacements. Only the
|
||||
* Context whose start seq equals this header's seq contributes a view Node.
|
||||
*/
|
||||
readonly header?: TrajectoryRequestHeaderState
|
||||
}
|
||||
@@ -25,53 +24,64 @@ export interface TrajectorySystemMessageState extends SystemPromptNode {
|
||||
* introduces or replaces the prompt contributes nothing itself; 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
|
||||
* effective node; earlier request facts remain historical.
|
||||
* @param inspect - Pure surface interpretation supplied by uiConversation.
|
||||
* @returns The Trajectory system-prompt Definition.
|
||||
*/
|
||||
export const trajectorySystemMessageDefinition: ConversationNodeDefinition<TrajectorySystemMessageState> = {
|
||||
kind: 'trajectory-system-message',
|
||||
target: 'trajectory',
|
||||
match: event => event.type === 'system/message'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'system/message') {
|
||||
throw new Error('trajectory-system-message start requires system/message')
|
||||
}
|
||||
const node: SystemPromptNode = {
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
turn: match.event.data.turn,
|
||||
step: match.event.data.step,
|
||||
text: match.event.data.message.content
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
.join(''),
|
||||
update: match.event.surfaceOp === 'append'
|
||||
&& reader.previous<TrajectorySystemMessageState>('trajectory-system-message') !== undefined,
|
||||
}
|
||||
if (!node.update) return node
|
||||
const header = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')?.state
|
||||
const systemHeader = reader.previous<TrajectorySystemMessageState>('trajectory-system-message')?.state.header
|
||||
const previous = systemHeader !== undefined && (header === undefined || systemHeader.seq > header.seq)
|
||||
? systemHeader
|
||||
: header
|
||||
if (previous === undefined) return node
|
||||
return {
|
||||
...node,
|
||||
header: {
|
||||
seq: node.seq,
|
||||
time: node.time,
|
||||
prompt: { ...previous.prompt, system: node.text },
|
||||
change: { seq: node.seq, time: node.time, kind: 'system', previous: previous.prompt },
|
||||
location: match.location,
|
||||
},
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: context => context.state?.header === undefined
|
||||
? null
|
||||
: trajectoryNode(context, context.state.seq, {
|
||||
kind: 'request-header',
|
||||
header: context.state.header,
|
||||
}),
|
||||
function trajectorySystemMessageDefinition(inspect: SystemPromptInspector): ConversationNodeDefinition<TrajectorySystemMessageState> {
|
||||
return {
|
||||
kind: 'trajectory-system-message',
|
||||
target: 'trajectory',
|
||||
match: event => event.type === 'system/message'
|
||||
|| ('surfaceOp' in event && event.surfaceOp !== undefined && event.surfaceOp !== 'append')
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
const prior = reader.previous<TrajectorySystemMessageState>('trajectory-system-message')?.state
|
||||
const state = inspect(prior, match.event)
|
||||
const node = state.effective
|
||||
if (state.uncertain) {
|
||||
const header = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')?.state
|
||||
if (header === undefined) return state
|
||||
return {
|
||||
...state,
|
||||
header: {
|
||||
seq: match.event.seq, time: match.event.time, location: match.location,
|
||||
prompt: { ...header.prompt, system: '' },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (node === undefined || node.text === prior?.effective?.text
|
||||
|| (state.introduced !== undefined && !state.introduced.update)) {
|
||||
return { ...state, ...(prior?.header === undefined ? {} : { header: prior.header }) }
|
||||
}
|
||||
const header = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')?.state
|
||||
const systemHeader = prior?.header
|
||||
const previous = systemHeader !== undefined && (header === undefined || systemHeader.seq > header.seq)
|
||||
? systemHeader
|
||||
: header
|
||||
if (previous === undefined) return state
|
||||
return {
|
||||
...state,
|
||||
header: {
|
||||
seq: node.seq,
|
||||
time: node.time,
|
||||
prompt: { ...previous.prompt, system: node.text },
|
||||
change: { seq: node.seq, time: node.time, kind: 'system', previous: previous.prompt },
|
||||
location: match.location,
|
||||
},
|
||||
}
|
||||
},
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -92,10 +102,17 @@ function trajectoryRequestHeaderDefinition(inspect: RequestPromptInspector): Con
|
||||
if (match.event.type !== 'request/header') {
|
||||
throw new Error('trajectory-request-header start requires request/header')
|
||||
}
|
||||
const previous = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')
|
||||
?.state.prompt
|
||||
const system = reader.previous<SystemPromptNode>(trajectorySystemMessageDefinition.kind)?.state
|
||||
const { prompt, change } = inspect(previous, match.event, system)
|
||||
const header = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')?.state
|
||||
const state = reader.previous<TrajectorySystemMessageState>('trajectory-system-message')?.state
|
||||
const systemHeader = state?.header
|
||||
const previous = systemHeader !== undefined && (header === undefined || systemHeader.seq > header.seq)
|
||||
? systemHeader.prompt
|
||||
: header?.prompt
|
||||
const system = state?.effective
|
||||
const inspection = inspect(previous, match.event, system)
|
||||
const { prompt } = inspection
|
||||
const change = inspection.change ?? (systemHeader !== undefined
|
||||
&& (header === undefined || systemHeader.seq > header.seq) ? systemHeader.change : undefined)
|
||||
return {
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
@@ -120,7 +137,9 @@ function trajectoryRequestHeaderDefinition(inspect: RequestPromptInspector): Con
|
||||
* @param ctx - Plugin context receiving the Definitions.
|
||||
*/
|
||||
export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(trajectorySystemMessageDefinition)
|
||||
ctx.uiConversation.events.register(trajectorySystemMessageDefinition(
|
||||
(previous, event) => ctx.uiConversation.inspectSystemPrompt(previous, event),
|
||||
))
|
||||
ctx.uiConversation.events.register(trajectoryRequestHeaderDefinition(
|
||||
(previous, event, system) => ctx.uiConversation.inspectRequestPrompt(previous, event, system),
|
||||
))
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts'
|
||||
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
|
||||
@@ -28,6 +29,7 @@ const registrationContext = {
|
||||
},
|
||||
},
|
||||
inspectRequestPrompt,
|
||||
inspectSystemPrompt,
|
||||
},
|
||||
} as unknown as Context
|
||||
|
||||
@@ -745,6 +747,81 @@ describe('Trajectory conversation Definitions', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['replay', 'live', 'partial'] as const)('restores A when compaction shadows B without a new system event (%s)', (mode) => {
|
||||
const history = [
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'system/message', { turn: 1, step: 1, message: systemMessage('A') }, { surfaceOp: 'append' }),
|
||||
at(4, 'request/header', {
|
||||
reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [] },
|
||||
}),
|
||||
at(5, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('a', 'a') }),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'step/start', { turn: 1, step: 2 }),
|
||||
at(8, 'system/message', { turn: 1, step: 2, message: systemMessage('B') }, { surfaceOp: 'append' }),
|
||||
at(9, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('b', 'b') }),
|
||||
at(10, 'step/end', { turn: 1, step: 2 }),
|
||||
at(11, 'step/start', { turn: 1, step: 3 }),
|
||||
at(12, 'user/message', {
|
||||
turn: 1, step: 3, id: 'summary', role: 'user',
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compaction' },
|
||||
}, { surfaceOp: { op: 'replace', start: 5, end: 9 }, sourceEventSeqs: [5, 8, 9] }),
|
||||
at(13, 'request/header', {
|
||||
reason: 'series', header: { config: { provider: 'test', model: 'test' }, tools: [] },
|
||||
}),
|
||||
at(14, 'assistant/message', { turn: 1, step: 3, message: assistantMessage('restored', 'restored') }),
|
||||
at(15, 'step/end', { turn: 1, step: 3 }),
|
||||
]
|
||||
const value = assembler(mode === 'replay' ? history : [])
|
||||
if (mode === 'partial') {
|
||||
value.replaceWindow(history.slice(7), true)
|
||||
value.flush()
|
||||
expect(snapshot(value).requests.at(-1)).toMatchObject({ prompt: { system: '' } })
|
||||
value.prepend(history.slice(0, 7), false)
|
||||
value.flush()
|
||||
}
|
||||
if (mode === 'live') {
|
||||
for (const entry of history) {
|
||||
value.append(entry)
|
||||
value.flush()
|
||||
}
|
||||
}
|
||||
expect(snapshot(value).requests.filter(request => request.purpose === 'assistant')
|
||||
.map(request => [request.prompt?.system, request.promptChange?.previous?.system])).toEqual([
|
||||
['A', undefined], ['B', 'A'], ['A', 'B'],
|
||||
])
|
||||
expect(snapshot(value).requests.at(-1)).toMatchObject({ promptChange: { seq: 12, kind: 'system' } })
|
||||
})
|
||||
|
||||
it('withholds unknown replacement order in request headers until prepend', () => {
|
||||
const system = (seq: number, text: string, replaces?: number) => at(seq, 'system/message', {
|
||||
turn: 1, step: 1, message: systemMessage(text),
|
||||
}, { surfaceOp: replaces === undefined ? 'append' : { op: 'replace', start: replaces, end: replaces } })
|
||||
const value = assembler([
|
||||
system(6, 'C', 3), system(7, 'D', 5),
|
||||
at(8, 'step/start', { turn: 1, step: 1 }),
|
||||
at(9, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }),
|
||||
at(10, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('reply', 'reply') }),
|
||||
])
|
||||
expect(snapshot(value).requests.at(-1)).toMatchObject({ prompt: { system: '' } })
|
||||
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' } })
|
||||
})
|
||||
|
||||
it('withholds inherited prompts when a replacement reaches an unloaded endpoint', () => {
|
||||
const value = assembler([
|
||||
at(5, 'step/start', { turn: 1, step: 1 }),
|
||||
at(6, 'system/message', { turn: 1, step: 1, message: systemMessage('B') }, { surfaceOp: 'append' }),
|
||||
at(7, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }),
|
||||
at(8, 'user/message', {
|
||||
...systemMessage('summary'), role: 'user', source: { kind: 'plugin', plugin: 'compaction' },
|
||||
}, { surfaceOp: { op: 'replace', start: 2, end: 6 } }),
|
||||
at(9, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('reply', 'reply') }),
|
||||
])
|
||||
expect(snapshot(value).requests.at(-1)).toMatchObject({ prompt: { system: '' } })
|
||||
})
|
||||
|
||||
it('retains an in-history update without a loaded header as a plain system node', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
@@ -772,8 +849,7 @@ describe('Trajectory conversation Definitions', () => {
|
||||
const invalidStart = { ...input, role: 'start' as const, location: { kind: 'session' as const } }
|
||||
const state = { seq: 1, time: 1, turn: 1, step: 1, text: 'system prompt', update: false }
|
||||
|
||||
expect(() => definition.start({} as never, invalidStart, {} as never))
|
||||
.toThrow('trajectory-system-message start requires system/message')
|
||||
expect(definition.match(invalidStart.event)).toBeNull()
|
||||
expect(definition.update({ state } as never, invalidStart)).toBe(state)
|
||||
|
||||
const header = DEFINITIONS.find(candidate => candidate.kind === 'trajectory-request-header')
|
||||
|
||||
Reference in new issue
Block a user