Merge remote-tracking branch 'origin/master' into worktree/default-workspace-write-ui-20260731

# Conflicts:
#	apps/cli/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-08-01 18:06:19 +08:00
111 changed files with 1150 additions and 341 deletions

View File

@@ -46,7 +46,8 @@ export interface ISession {
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn.
* Cancel the running turn. Pending queued work remains and resumes in FIFO
* order after the Host reaches cancellation quiescence.
* @returns acceptance, or the business error.
*/
cancel(): Promise<RpcResult<{ accepted: true }>>

View File

@@ -388,6 +388,9 @@ export class SessionsService implements ISessions {
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* A fractional anchor floors to a real event seq: the frozen nodes of an
* interrupted turn carry flow-ordering seqs between two events, and the
* wire takes integers only.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -402,7 +405,10 @@ export class SessionsService implements ISessions {
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
// Flooring lands inside the anchor's own turn (every turn opens with a
// turn/start), so the host's first-turn/end-at-or-after cut still ends
// on that turn — never clipped back to the previous one.
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()

View File

@@ -248,7 +248,8 @@ export class Session implements SessionFace {
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {

View File

@@ -455,6 +455,17 @@ describe('fork', () => {
})
})
it('floors a fractional anchor to the real event seq the wire accepts', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])

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: 68115f8f9b9225d1f6b0e9cc93394d042c2befaa
README.zh.md: 65a3b334ff316a37dfb8506396eae8c17cccb40d
README.md: b61a70fb079eb6a1bc2a67b682a337ffdf708b79
README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365

View File

@@ -6,13 +6,13 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
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 sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. 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.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.

View File

@@ -6,11 +6,11 @@
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包package自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。

View File

@@ -165,7 +165,6 @@ export function apply(ctx: Context): void {
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
locale: NS,
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
@@ -175,7 +174,6 @@ export function apply(ctx: Context): void {
version: () => slots.getVersion('conversation.view'),
},
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)

View File

@@ -1,4 +1,4 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */
.root {
min-width: 0;
@@ -15,7 +15,7 @@
.body {
box-sizing: border-box;
width: calc(100% - 22px);
height: 141px;
max-height: 141px;
margin: 4px 0 0 22px;
overflow: auto;
padding: 10px 16px 12px 12px;

View File

@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
@@ -244,8 +244,6 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
/**
@@ -355,7 +353,6 @@ export type ConversationSessionSlotProps =
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>

View File

@@ -30,7 +30,6 @@ export const zh = {
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
@@ -130,7 +129,6 @@ export const en = {
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',

View File

@@ -38,7 +38,7 @@ export interface IConversation {
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* Cancel the scoped session's in-flight turn while preserving its pending Queue.
* @returns completion; failures reject as in send.
*/
cancel(): Promise<void>
@@ -86,7 +86,7 @@ export class ConversationService extends Service implements IConversation {
}
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
/** Cancel the scoped session's in-flight turn while preserving Queue (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')
const result = await session.cancel()

View File

@@ -1,4 +1,4 @@
/* Conversation column skeleton: header (breadcrumb row + tabs) over the view
/* Conversation column skeleton: header (session title + tabs) over the view
area, composer InputBar at the bottom. Column width/squeeze is layout's;
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
a 3px active bar. */
@@ -23,59 +23,24 @@
display: none;
}
.crumbRow {
.titleRow {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
.sessionTitle {
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.crumb {
max-width: 220px;
overflow: hidden;
margin: 0;
padding: 4px 8px;
border: none;
border-radius: 12px;
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumb:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
text-overflow: ellipsis;
white-space: nowrap;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */

View File

@@ -2,35 +2,21 @@
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
renderSlot, views, bindDraftMirror, wrapActiveBody,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const title = useSessions(s => s.byId[sessionId]?.displayTitle ?? sessionId)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
@@ -69,26 +55,8 @@ export function ConversationSession({
>
{!hideChrome && (
<>
<div className={css.crumbRow}>
<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.titleRow}>
<h1 className={css.sessionTitle}>{title}</h1>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">

View File

@@ -228,12 +228,9 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const resident = b.residentSurface(ROOT)
injected.open(ROOT)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
@@ -241,7 +238,7 @@ describe('conversation slot inject surface', () => {
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
})
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
expect(state.getSnapshot().draft).toBe('carry me')

View File

@@ -20,7 +20,7 @@
* suite only proves the assembled wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { cleanup, fireEvent, waitFor } from '@testing-library/react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -256,16 +256,14 @@ describe('prompt rejection through the assembled composer', () => {
})
describe('title projection across assembled surfaces', () => {
it('one summary update re-labels the breadcrumb and document.title consumers together', async () => {
it('one summary update re-labels the current-session heading', async () => {
const runtime = await bench([])
const view = runtime.renderRoot()
// The strict session header breadcrumb reads useSessions ancestry.
const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement)
expect(crumb.getByText('S')).toBeTruthy()
expect(view.getByRole('heading', { name: 'S', level: 1 })).toBeTruthy()
await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' })
await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() })
expect(crumb.queryByText('S')).toBeNull()
await waitFor(() => { expect(view.getByRole('heading', { name: '修订标题', level: 1 })).toBeTruthy() })
expect(view.queryByRole('heading', { name: 'S', level: 1 })).toBeNull()
await runtime.dispose()
})
})

View File

@@ -111,7 +111,6 @@ function mount(
const useInput = bindSnapshotSelector(wiring.state)
const inputActions = wiring.actions
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
@@ -140,8 +139,6 @@ function mount(
version: () => 1,
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
t={t}
{...owner}
/>
)
@@ -203,7 +200,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, open, retargetWorkspace, session, slotCalls,
view, chat, sink, retargetWorkspace, session, slotCalls,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -218,8 +215,8 @@ describe('ConversationRoot resident composer', () => {
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
expect(b.view.getByRole('heading', { name: 'Child', level: 1 })).toBeTruthy()
expect(b.view.queryByText('Root')).toBeNull()
})
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {

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-primitives/README.md
README.md: f06e8c014c19d17197980a232e8b42080aac2901
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a
README.md: 8bdc3f043488631424c85a4319020c3d8ba5437b
README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM`$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出

View File

@@ -23,13 +23,16 @@
"@shikijs/langs": "^4.3.1",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"katex": "^0.16.47",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^4.3.1"
},
"devDependencies": {

View File

@@ -1,5 +1,5 @@
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
Modules. Cite pills, header anchors, and thinking-small variants are
intentionally absent (no matching DOM). Token names match that sheet. */
.markdown {
@@ -160,6 +160,12 @@
font-family: var(--ds-font-family-code);
}
.markdown :global(.katex-display) {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-label-secondary);

View File

@@ -1,11 +1,16 @@
import { isValidElement, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
const streamingRemarkPlugins = [remarkGfm]
const settledRemarkPlugins = [remarkGfm, remarkMath]
const settledRehypePlugins = [rehypeKatex]
function sanitizeUrl(url: string): string {
try {
@@ -88,12 +93,12 @@ const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences plain (highlighting lands on the finalize swap);
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
* @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
@@ -109,7 +114,8 @@ export function MarkdownText({ text, streaming = false, codeLabels }: {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
rehypePlugins={streaming ? undefined : settledRehypePlugins}
components={components}
urlTransform={safeUrl}
>

View File

@@ -94,6 +94,11 @@ describe('MarkdownText', () => {
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
})
it('forwards localized labels to fenced code blocks', () => {
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
@@ -121,6 +126,39 @@ describe('MarkdownText', () => {
expect(container.querySelectorAll('li')).toHaveLength(2)
expect(screen.getByText('**unfinished')).toBeTruthy()
})
it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
const source = [
'Einstein wrote $E = mc^2$.',
'',
'$$',
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
'$$',
'',
'$\\href{javascript:alert(1)}{unsafe}$',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(3)
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
expect(container.querySelector('a')).toBeNull()
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
const live = render(<MarkdownText text={partial} streaming />)
expect(live.container.querySelector('.katex')).toBeNull()
expect(live.container.querySelector('.katex-error')).toBeNull()
expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
live.rerender(<MarkdownText text={complete} />)
expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
expect(live.container.querySelector('.katex-error')).toBeNull()
})
})
describe('JsonBlock', () => {

View File

@@ -23,7 +23,6 @@ import type {
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -36,11 +35,6 @@ import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
// Stub of the conversation package's standard locale seat (this spec mounts
// its ConversationSession chrome); answers from the zh dictionary and falls
// back to the key like the real chain.
const tConversation: ConversationSessionProps['t'] =
key => (conversationZh as Record<string, string>)[key] ?? key
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
@@ -219,7 +213,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
return render(
<ConversationSession
sessionId={SID}
t={tConversation}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
@@ -236,7 +229,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
/>,
)
}

View File

@@ -1,9 +1,9 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
* clears queued + steering work, aborts the active turn, and drops work not yet claimed by the
* driver without leaking cancellation into a replacement prompt. The suite covers every landing
* window plus signal reset and `whenIdle()` quiescence.
* Tests for the queue-aware `Agent.cancel()` primitive. The default clears
* queued and steering work, while `keepInbox` preserves pending input and
* resumes waking turns after the active turn reaches quiescence. The suite
* covers every landing window plus signal reset and `whenIdle()` quiescence.
* @module dsh-agent-loop/tests/cancel
*/
@@ -302,6 +302,41 @@ describe('Agent.cancel()', () => {
expect(adapter.requests).toHaveLength(1)
})
it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => {
const adapter = new MockAdapter([
'hang',
textResponse('second reply'),
textResponse('third reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
const discards: unknown[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items)
})
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
send(agent, 'queued second')
send(agent, 'queued third')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' }, { keepInbox: true })
await idle
expect(discards).toEqual([])
expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third'])
expect(reasons).toEqual([
{ kind: 'aborted' },
{ kind: 'completed' },
{ kind: 'completed' },
])
expect(adapter.requests).toHaveLength(3)
})
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'danger', {}),

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/host/apiproxy/README.md
README.md: 5af2739ee419d32c93578b37bebc6808431fd003
README.zh.md: c2729c379bd3c132c94d96134c04a0976a3aca9b
README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5
README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f

View File

@@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.

View File

@@ -20,7 +20,7 @@
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`队列操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。

View File

@@ -1587,7 +1587,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { sessionId },
}))
}
agent.cancel({ kind: 'user' })
agent.cancel({ kind: 'user' }, { keepInbox: true })
return Promise.resolve(ok(request, { accepted: true as const }))
},
},

View File

@@ -279,7 +279,7 @@ export interface SessionsApi {
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
/** Stops the active turn, preserving pending inbox work that resumes in FIFO order after cancellation settles. */
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
}

View File

@@ -46,7 +46,10 @@ async function composed(): Promise<Context> {
return ctx
}
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
type Tail = 'none' | 'open' | 'aborted'
function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -56,12 +59,13 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (openTail) {
if (tail !== 'none') {
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
@@ -92,7 +96,7 @@ describe('sessions.fork', () => {
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, true)
const source = liveAgent(ctx, 'session-tail', 2, 'open')
const proxy = api(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
@@ -114,9 +118,26 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('cuts through an aborted turn: stopped is closed, not open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
// What a stopped message's fork button anchors on: the frozen node sits
// one event before its turn/end, floored client-side to that event's seq.
const anchor = (source.events.at(-1)?.seq ?? 0) - 1
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
])
await ctx.fiber.dispose()
})
it('rejects an in-log anchor whose turn is still open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-open', 1, true)
const source = liveAgent(ctx, 'session-open', 1, 'open')
const anchor = source.events.at(-1)?.seq ?? 0
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result).toMatchObject({

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/ui/app-boot/README.md
README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3
README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c
README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e
README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5

View File

@@ -15,7 +15,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.

View File

@@ -15,7 +15,7 @@
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader在配置树条目挂载前执行可选的宿主准备操作`prepare` 可以使用 Loader也可以提供由启动器拥有的上下文插槽例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr读取解析形状失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber并把原始错误堆栈写入启动 rejection。抛出错误前审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。

View File

@@ -480,11 +480,11 @@ export async function boot(
export const HARNESS_SOURCE_SECTION = 'harness:source'
/**
* Add a global prompt section naming the on-disk path to the harness source
* checkout the running bin was launched from, so the agent knows where its own
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
* it). Call once on the settled boot context ({@link boot}); the section orders
* just after the harness identity opener (`-100`) and before the deployment
* Add a global prompt section naming the on-disk harness source checkout while
* explicitly distinguishing it from the task workspace and current working
* directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
* checkout. Call once on the settled boot context ({@link boot}); the section
* orders just after the harness identity opener (`-100`) and before the deployment
* persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
* augment, so this is then a no-op that returns `undefined`. The section is
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
@@ -499,6 +499,6 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() =
return systemPrompt.section({
name: HARNESS_SOURCE_SECTION,
order: -99,
text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`,
})
}

View File

@@ -391,9 +391,9 @@ describe('boot', () => {
describe('addHarnessSourceSection', () => {
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
it('adds the source path between the harness identity and the deployment persona', async () => {
it('distinguishes the source path from the current workdir between identity and persona', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })

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/ui/tui/README.md
README.md: 20ad471faa707cce0e6f0ce4df2289a900f09b93
README.zh.md: a7d918ba3239d3e6feaa2fb43181e4bcf33e1d89
README.md: c81cac891403e5294c4456ce4d4048ecd74666ce
README.zh.md: 01055619f4df460284564f0a1816de366d809e01

View File

@@ -40,7 +40,7 @@ Selection repeats those checks and requires the current agent to be idle before
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh experimental-upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
## Config

View File

@@ -40,7 +40,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`即恢复本会话的命令释放终端后退出会原样打印它未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`skill 名称来播种全新会话的首轮聊天就绪后TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill未知名称会以通知形式报告。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`skill 名称来播种全新会话的首轮聊天就绪后TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill未知名称会以通知形式报告。
## 配置

View File

@@ -122,7 +122,7 @@ export interface Config extends TuiConfig {
/**
* Skill name auto-invoked as this session's first user turn, exactly as if
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
* skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
* leaves the first turn to the user.
*/
initialSkill?: string

View File

@@ -227,7 +227,7 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
* turn with `/skill:<name>` — the `dsh migrate`/`dsh experimental-upgrade`
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade`
* guided-session entry. The launcher sets it only when minting a fresh session,
* so it never re-fires on a resumed one. Absent leaves the first turn to the user.
*/
@@ -1690,7 +1690,7 @@ export function createTuiChat(
})
startBannerReveal()
// A launcher-seeded first turn (`dsh migrate`/`dsh experimental-upgrade`):
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`):
// invoke the named skill exactly as a typed `/skill:<name>` would, once the
// chat is live and the agent is idle. The launcher sets this only for a fresh
// session, so there is no prior turn to collide with; invokeSkill reports an