Merge master into docs/post-v3-release-proofreading

This commit is contained in:
xjt
2026-08-12 20:48:08 +08:00
148 changed files with 2028 additions and 408 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: e2db456f92151c3602fce0bf40a86e4019cda1cb
README.zh.md: c5f3152006ea37848b21942d886cbfb74af15943
README.md: b6a265c9f0a67d31ebeaa88bd59082c4be465983
README.zh.md: 001e0a58badd6f31875c09a31085277928f1ae22

View File

@@ -16,7 +16,7 @@ Chat business rows are independent registry contributions rather than a closed b
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.

View File

@@ -14,7 +14,7 @@
Chat 业务行是彼此独立的注册表贡献不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key`ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer它无须修改会话 fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。
会话页头会在标题旁声明并渲染会话作用域的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
会话页头会在标题旁渲染会话作用域的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。会话上下文和谱系控件保留在 `actions` 中;可选的会话工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态或摘要[历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡沿用用户气泡的呈现不加任何装饰transcript 中唯一的 steering 信号是它出现在轮次中途的位置。

View File

@@ -259,6 +259,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (): ConversationSessionHeaderInjected => ({

View File

@@ -244,6 +244,12 @@
font: var(--dsw-font-markdown-code-block-small);
}
.maxTokensTitle {
margin-right: 6px;
color: var(--dsw-alias-state-warn-primary);
font-weight: 600;
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;

View File

@@ -130,6 +130,21 @@ function TurnErrorItem({ node, t }: {
)
}
/** Persistent, turn-positioned notice for a turn ended at the output-token cap. */
function TurnMaxTokensItem({ t }: {
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.turnErrorRow} role="status">
<StateDot state="warning" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.maxTokensTitle}>{t('message.maxTokens')}</span>
<span className={css.turnErrorMessage}>{t('message.maxTokens.hint')}</span>
</div>
</div>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -272,6 +287,11 @@ export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: Ch
return <TurnErrorItem node={node.data} t={t} />
})
/** Max-tokens turn-end notice keyed Chat renderer. */
export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) {
return <TurnMaxTokensItem t={t} />
})
/** Explicit unknown-surface keyed Chat renderer. */
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
const data = node.data

View File

@@ -4,7 +4,7 @@ import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from './MessageItem.tsx'
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
@@ -35,6 +35,8 @@ export function registerChatNodeRenderers(ctx: Context): void {
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'turn-tail',

View File

@@ -45,6 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* context that precedes interactive actions.
*/
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* Right-aligned Session utilities kept outside the title-adjacent action
* group, so an optional utility cannot reorder session context or lineage.
*/
'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
@@ -525,7 +530,7 @@ export type ConversationSessionSlotProps =
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderSlots<'conversation.session.header.actions'>
& PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'>
& PropsStore<ChatStore>
& ConversationSessionHeaderInjected
& PropsLocale<'conversation'>

View File

@@ -165,6 +165,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
case 'command':
case 'compaction':
case 'turn-error':
case 'turn-max-tokens':
case 'unknown':
return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null }
case 'assistant-step': {

View File

@@ -7,11 +7,14 @@ import type {
/**
* Relative positions in one durable event's seq neighborhood: interrupted
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final.
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final. The
* max-tokens notice sits between a closing Assistant and the turn-tail so the
* tail stays the turn's last node and keeps its branch action enabled.
*/
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
interruptedAssistant: -0.9,
interruptedFollowup: -0.8,
maxTokensNotice: 0.05,
finalizedFollowup: 0.1,
} as const

View File

@@ -9,6 +9,7 @@ import { registerMessageConversationNode } from './message.ts'
import { registerRetryConversationNode } from './retry.ts'
import { registerToolConversationNode } from './tool.ts'
import { registerTurnErrorConversationNode } from './turn-error.ts'
import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
import { registerTurnTailConversationNode } from './turn-tail.ts'
/**
@@ -24,6 +25,7 @@ export function registerConversationNodes(ctx: Context): void {
registerCompactionConversationNode(ctx)
registerRetryConversationNode(ctx)
registerTurnErrorConversationNode(ctx)
registerTurnMaxTokensConversationNode(ctx)
registerTurnTailConversationNode(ctx)
registerUnknownConversationFallback(ctx)
registerChatConversationView(ctx)

View File

@@ -0,0 +1,82 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Turn ended by the per-request output-token cap. */
'turn-max-tokens': TurnMaxTokensNode
}
}
interface TurnMaxTokensState {
readonly turn: number
readonly seq: number
readonly time: number
}
function lastStep(context: ConversationNodeContext<TurnMaxTokensState>): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return 0
return location.turn.steps.at(-1)?.step ?? 0
}
/**
* Anchor the notice between the closing Assistant and the turn-tail so the
* tail stays the turn's last Chat node and keeps its branch action enabled.
* Without a closing text Assistant there is no branch action to protect, and
* the turn/end seq keeps the notice at the truncation point.
*/
function noticeAnchor(context: ConversationNodeContext<TurnMaxTokensState>, seq: number): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return seq
const closing = location.turn.data.get('turn-tail')?.closing
return closing === null || closing === undefined
? seq
: closing.finalNode.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.maxTokensNotice
}
function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined {
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined
return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time }
}
/** Notice Definition for a turn the provider ended at its output-token cap. */
export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensState> = {
kind: 'turn-max-tokens',
target: 'chat',
match: (event) => {
if (event.type === 'turn/end' && event.data.reason.kind === 'max-tokens') {
return { id: String(event.data.turn), role: 'start' }
}
return null
},
start: (_context, match) => {
const state = stateFrom(match)
if (state === undefined) throw new Error('turn-max-tokens start requires a max-tokens turn/end')
return state
},
update: context => context.state,
buildViewNode: (context) => {
const state = context.state
if (state === undefined) return null
const node: TurnMaxTokensNode = {
kind: 'turn-max-tokens',
seq: state.seq,
time: state.time,
turn: state.turn,
step: lastStep(context),
}
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
},
}
/**
* Register the max-tokens turn-end notice contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnMaxTokensConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnMaxTokensDefinition)
}

View File

@@ -11,6 +11,7 @@ export type {} from './conversation-nodes/message.ts'
export type {} from './conversation-nodes/retry.ts'
export type {} from './conversation-nodes/tool.ts'
export type {} from './conversation-nodes/turn-error.ts'
export type {} from './conversation-nodes/turn-max-tokens.ts'
export type {} from './conversation-nodes/turn-tail.ts'
export { apply, inject } from './apply.ts'

View File

@@ -122,6 +122,8 @@ export const zh = {
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.maxTokens': '已达到输出 token 上限',
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
'message.ranFor': '用时 {duration}',
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
@@ -289,6 +291,8 @@ export const en = {
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.maxTokens': 'Output token limit reached',
'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
'message.ranFor': 'Ran for {duration}',
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',

View File

@@ -53,9 +53,18 @@
.titleRow {
display: flex;
align-items: center;
gap: 10px;
gap: 0;
min-height: 32px;
}
.titleCluster {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.crumbs {
display: flex;
align-items: center;
@@ -111,6 +120,18 @@
gap: 8px;
}
.headerUtilities {
display: flex;
flex: none;
align-items: center;
gap: 8px;
margin-left: 20px;
}
.headerUtilities:empty {
display: none;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
position: relative;

View File

@@ -69,27 +69,32 @@ export function ConversationSessionHeader({
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
<div className={css.titleCluster}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
<div className={css.headerUtilities}>
{renderSlot('conversation.session.header.utilities', {})}
</div>
</div>
{tabs.length > 1 && (

View File

@@ -9,7 +9,7 @@ import { useEffect } from 'react'
import type {
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
TurnMaxTokensNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import {
@@ -28,7 +28,7 @@ import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
@@ -108,6 +108,9 @@ const turnError = (seq: number, code?: string): TurnErrorNode => ({
message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
...(code === undefined ? {} : { code }),
})
const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({
kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0,
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -217,6 +220,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return <RetryNodeView {...nodeProps<'model-retry'>()} />
case 'turn-error':
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
case 'turn-max-tokens':
return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
case 'turn-tail':
return (
<TurnTailNodeView
@@ -585,6 +590,16 @@ describe('ChatView', () => {
])
})
it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => {
const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] })
const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([
'已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
])
expect(view.queryByText('本轮运行失败')).toBeNull()
})
it('hands the trajectory callback to the Tool seat', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],

View File

@@ -14,6 +14,7 @@ import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
import type {
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
@@ -29,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
compactionDefinition,
retryDefinition,
turnErrorDefinition,
turnMaxTokensDefinition,
turnTailDefinition,
]
@@ -812,6 +814,75 @@ describe('built-in conversation node Definitions', () => {
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
})
it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/message', {
turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
}, { surfaceOp: 'append' }),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
])
const notice = node(snapshot(value), 'turn-max-tokens')
expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
// The tail stays the turn's last node so its branch action survives; the
// notice slots between the truncated closing Assistant and the tail.
const tail = node(snapshot(value), 'turn-tail')
expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
expect(notice?.anchorSeq).toBeGreaterThan(3)
const completed = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
const failed = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'turn/end', {
turn: 1,
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
}),
])
expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
expect(node(snapshot(failed), 'turn-error')).toBeDefined()
})
it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
const value = assembler([
at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
], true)
const notice = node(snapshot(value), 'turn-max-tokens')
expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
})
it('pins the max-tokens Definition edges the engine cannot reach', () => {
// The engine only hands start the single matched turn/end and never emits
// update Matches for this kind; these direct calls pin the declared
// behavior of both required Definition members anyway.
const match = (seq: number, type: string, data: unknown) => ({
event: { seq, time: seq * 1_000, type, data },
view: undefined,
role: 'start',
location: undefined,
}) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
const context = (state: unknown, matches: unknown[] = []) => ({
key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
}) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
const reader = { previous: () => undefined }
expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
.toThrow('turn-max-tokens start requires a max-tokens turn/end')
const state = { turn: 1, seq: 5, time: 5_000 }
expect(turnMaxTokensDefinition.update(
context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
)).toBe(state)
expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
})
it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
const value = assembler([
at(12, 'tool/code-dispatch-start', {

View File

@@ -337,6 +337,8 @@ describe('ConversationRoot resident composer', () => {
expect(host?.contains(header)).toBe(false)
expect(host?.contains(seat)).toBe(true)
expect(seat?.contains(textarea)).toBe(true)
expect(b.slotCalls).toContain('conversation.session.header.actions')
expect(b.slotCalls).toContain('conversation.session.header.utilities')
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {