Merge remote-tracking branch 'origin/master' into worktree/pr1018-todo-retarget-20260731

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
This commit is contained in:
Tianyi Cui
2026-07-31 19:17:50 +08:00
293 changed files with 8581 additions and 2060 deletions

View File

@@ -164,10 +164,10 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
{
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
matches: [
{ lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' },
{ lineNumber: 73, line: ' const search = searchCardModel(block)' },
{ lineNumber: 90, line: ' <SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />' },
{ lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" },
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
{ lineNumber: 52, line: ' search={search}' },
{ lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
],
},
]
@@ -197,7 +197,7 @@ const SEARCH_PATHS_FIXTURE = [
'packages/client/ui-primitives/src/SearchBlock.module.css',
'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
'packages/client/ui-conversation/src/client/toolviews/search-row.module.css',
'packages/client/ui-conversation/tests/search-card.spec.tsx',
]
/**

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/runtime/README.md
README.md: e8ba80790307e7123406934c1ab11b86dfc0faf3
README.zh.md: 6eb69d9cf10959b007f3759378612dbc013a8904
README.md: 64982c2b5af891b60055a41bc3c30c1ba4041300
README.zh.md: 2cc2dc30bd4913c52645c76de4bc55d109a40001

View File

@@ -30,6 +30,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
## Request inspection
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode sub-dispatch index
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.

View File

@@ -30,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。

View File

@@ -35,40 +35,55 @@ export interface RequestPromptChange {
previous?: ConversationPromptSnapshot
}
/** One provider request reconstructed from durable request lifecycle events. */
export interface RequestView {
/** Request category; compaction is a purpose, not a separate projection. */
purpose: 'assistant' | 'compaction'
/** Lifecycle fields shared by ordinary generation and compaction requests. */
interface RequestViewBase {
/** Sequence that opened the operation represented by this request. */
startSeq: number
turn: number
/** Agent-loop step, or zero for a direct compaction request. */
step: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Assistant message or compaction summary sequence produced by this request. */
resultSeq?: number
}
/** One ordinary assistant generation reconstructed from durable request events. */
interface AssistantRequestView extends RequestViewBase {
purpose: 'assistant'
turn: number
/** Agent-loop step that issued this request. */
step: number
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** One compaction provider request, either turn-owned or standalone between turns. */
interface CompactionRequestView extends RequestViewBase {
purpose: 'compaction'
/** Owning turn, or `null` when manual compaction ran between turns. */
turn: number | null
/** Direct compaction requests do not consume an agent-loop step. */
step: 0
/** Compaction replacement message sequence, when one was committed. */
replacementSeq?: number
/** Safe compaction summary projection. */
summary?: readonly ContentBlock[]
/** Complete compaction provider output before the safe projection. */
rawOutput?: readonly ContentBlock[]
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** One provider request reconstructed from durable request lifecycle events. */
export type RequestView = AssistantRequestView | CompactionRequestView
/** Immutable request-centric projection derived from one history window. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
@@ -110,7 +125,7 @@ interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
data: { turn: number | null }
}
interface CompactionSummaryEvent {
@@ -131,7 +146,7 @@ interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
data: { turn: number | null; error?: string }
}
function requestKey(turn: number, step: number): string {
@@ -228,10 +243,21 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const update = (index: number | undefined, change: Partial<RequestView>): void => {
const updateAssistant = (
index: number | undefined,
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
}
const updateCompaction = (
index: number | undefined,
change: Partial<Omit<CompactionRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
@@ -263,7 +289,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
@@ -278,8 +304,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
updateAssistant(index, {
usage: addTokenUsage(
request?.purpose === 'assistant' ? request.usage : undefined,
sourceEvent.data.chunk.usage,
),
})
continue
}
@@ -288,7 +317,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
@@ -296,7 +325,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
provider: sourceEvent.data.message.source.provider,
model: sourceEvent.data.message.source.model,
},
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
...(request?.purpose === 'assistant'
&& request.usage !== undefined
|| sourceEvent.data.usage === undefined
? {}
: { usage: sourceEvent.data.usage }),
})
@@ -306,8 +337,8 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.status === 'running') {
update(index, {
if (request?.purpose === 'assistant' && request.status === 'running') {
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'error',
})
@@ -317,7 +348,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
status: 'error',
error: event.data.failure.message,
retry: event.data.retry,
@@ -328,7 +359,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
const reason = sourceEvent.data.reason
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
@@ -336,6 +367,15 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
const type = sourceEvent.type as string
if (type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
})
activeCompaction = undefined
continue
}
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length
@@ -352,7 +392,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
update(activeCompaction, {
updateCompaction(activeCompaction, {
resultSeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
@@ -375,12 +415,12 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
update(activeCompaction, { replacementSeq: sourceEvent.seq })
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
update(activeCompaction, {
updateCompaction(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),

View File

@@ -85,6 +85,68 @@ describe('inspectRequests', () => {
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('preserves a standalone compaction owner without widening assistant turns', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'compact/summary', {
summary: [{ type: 'text', text: 'standalone summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(2, 'compact/end', { turn: null }),
at(3, 'step/start', { turn: 2, step: 1 }),
]))
const [compaction, assistant] = snapshot.requests
expect(compaction).toMatchObject({
purpose: 'compaction',
turn: null,
step: 0,
status: 'complete',
})
expect(assistant).toMatchObject({
purpose: 'assistant',
turn: 2,
step: 1,
status: 'running',
})
if (assistant?.purpose === 'assistant') {
const turn: number = assistant.turn
expect(turn).toBe(2)
}
})
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
at(1, 'session/end-seed', {}),
at(2, 'compact/start', { turn: null }),
at(3, 'compact/summary', {
summary: [{ type: 'text', text: 'replacement summary' }],
provider: 'fake',
model: 'compact-model',
}),
at(4, 'compact/end', { turn: null }),
]))
expect(snapshot.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 0,
status: 'error',
completedAt: 1_700_000_000_001,
error: 'Compaction was interrupted before completion.',
},
{
purpose: 'compaction',
startSeq: 2,
status: 'complete',
completedAt: 1_700_000_000_004,
summary: [{ type: 'text', text: 'replacement summary' }],
},
])
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
@@ -179,6 +241,7 @@ describe('inspectRequests', () => {
]))
expect(snapshot.callSchemas).toEqual(new Map())
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
const [request] = snapshot.requests
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
})
})

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: 66120f222de4b4d4707430a1ec310c6fe801c0c5
README.zh.md: f5e953f363733341400a292a1946b4e298858b77
README.md: f7279d2c640d447609c4e9804af633d026b35ed9
README.zh.md: 2e8f3483c160689cdf5ff283f2955a5c0be4fd8e

View File

@@ -18,13 +18,15 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)).
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

View File

@@ -16,13 +16,15 @@
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null,落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。

View File

@@ -20,9 +20,9 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { searchToolview } from './toolviews/search-row.tsx'
import { readToolview } from './toolviews/read-row.tsx'
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { searchToolview } from './toolviews/search-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
@@ -322,10 +322,6 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The grep/glob search row rides the same seam: one component registered
// under both tool names, since both declare the same search render intent.
ctx.plugin(searchToolview)
// The read row rides the same seam (a product registration, not a sample):
// Read · {path} chrome with the file's read card resident below it.
ctx.plugin(readToolview)
@@ -334,6 +330,11 @@ export function apply(ctx: Context): void {
// diff render intent, so these rows stack the applied diff card under their
// path-link summary (the terminal card's posture, applied to diffs).
ctx.plugin(fileMutationToolview)
// The grep/glob search row rides the same seam: one component registered
// under both tool names, since both declare the same search render intent.
ctx.plugin(searchToolview)
// The web rows ride the same seam: one WebRow registered under both
// web_search and web_fetch, rendering the completed retrieval's web card
// resident under the summary (a product registration, not a sample).

View File

@@ -1,17 +0,0 @@
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
without its own keyed row (e.g. web_fetch) grows a resident card under its
summary row. A column around the ToolRow keeps the row's own 24px height, so
the read card renders identically to the keyed ReadRow and the web card to
the web_search/web_fetch WebRow. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read,
.web {
margin: 4px 0 4px 22px;
}

View File

@@ -7,17 +7,16 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14, ReadBlock, WebBlock,
IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import css from './GenericToolCard.module.css'
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
@@ -39,9 +38,9 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const search = searchCardModel(block)
const read = readCardModel(block, cwd)
const diff = diffCardModel(block)
const search = searchCardModel(block)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
@@ -49,7 +48,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
? 'error'
: model.state
const singleFile = model.filePath !== undefined
const row = (
return (
<ToolRow
t={t}
variant={model.variant}
@@ -61,39 +60,20 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
// a search result view's replacement title outranks it the same way.
summary={terminal?.description ?? search?.title ?? model.summary}
// Single-file tools never expose an args body — the path link is the only
// args interaction. A diff card is not an args body: a write/edit row is
// single-file AND carries a diff, so the card expands under the path link.
// args interaction. A card is not an args body: a read/write/edit row is
// single-file AND carries a card, so the card expands under the path link.
body={singleFile ? null : model.body}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
search={search}
diff={diff}
read={read}
search={search}
web={web}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
inspect={inspect}
/>
)
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
// so the file's read card is resident below the summary row exactly as the
// keyed ReadRow draws it. Only wrap when a card is present, so every other
// tool keeps the bare ToolRow.
if (read !== null) {
return (
<div className={css.card}>
{row}
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
</div>
)
}
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row
return (
<div className={css.card}>
{row}
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
</div>
)
}

View File

@@ -248,13 +248,18 @@
/* The block-shaped expanded bodies: the code variant's run_code program
through CodeBlock (shiki-highlighted TypeScript), a terminal card's command
output through TerminalBlock, and a search card's grouped matches or path
list through SearchBlock. All are drawn by the shared primitive, so only the
row's indentation is this file's concern — the margin also replaces each
output through TerminalBlock, a diff card through DiffBlock, a read card's
line-numbered window through ReadBlock, a search card's grouped matches or
path list through SearchBlock, and a web card's citation/source list through
WebBlock. All are drawn by the shared primitive, so only the row's
indentation is this file's concern — the margin also replaces each
primitive's own standalone vertical spacing with the flow's row rhythm. */
.codeBody,
.terminalBody,
.searchBody {
.diffBody,
.readBody,
.searchBody,
.webBody {
margin: 4px 0 4px 4px;
}
@@ -269,12 +274,6 @@
color: var(--dsw-alias-label-tertiary);
}
/* A write/edit diff renders through DiffBlock; like the terminal card it draws
its own surface, so only the row indentation is this file's concern. */
.diffBody {
margin: 4px 0 4px 4px;
}
/* In-row code renders at the smaller code size (12/18) via each primitive's
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
.codeBody {
@@ -290,3 +289,15 @@
--dsl-terminal-output-max-height: 224px;
border: 1px solid var(--dsw-alias-border-l1);
}
/* Visually hidden run-state label for assistive technology: the StateDot and
the running sweep are aria-hidden / colour-only, so the text carries the
running/failed/interrupted state to a screen reader. */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -3,26 +3,33 @@
// separator dot + FILL-truncated summary, drawn through the shared
// DisclosureRow chrome with the whole row as the expand toggle (click /
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, terminal, or search material is
// expandable; the summary stays inline while open, except Think, whose body
// opens with the same first line and would repeat it.
// one line; every row with body, output, or a card material (terminal, diff,
// read, search, web) is expandable; the summary stays inline while open,
// except Think, whose body opens with the same first line and would repeat it.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, a terminal
// card's command output through TerminalBlock, or a search card's grouped
// matches / path list through SearchBlock (capped at CHAT_SEARCH_MAX_LINES) —
// lives in a max-height scroll container so a long payload scrolls internally
// instead of taking over the message flow; Think's prose is the exception and
// flows uncapped like message text. Expand state is component-local view state.
// File-tool summaries are path links that open through the host (stopPropagation
// keeps the two gestures independent); an error row's collapsed summary is the
// failure's first line in the error color.
// text input/output, the run_code program through CodeBlock, or a card
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
// call that declared that render intent — lives in a max-height scroll
// container so a long payload scrolls internally instead of taking over the
// message flow; Think's prose is the exception and flows uncapped like message
// text. Every card kind starts collapsed, so a run of tool calls stays
// scannable; the details panel is the single-call full-height reading surface.
// Expand state is component-local view state. File-tool summaries are path
// links that open through the host (stopPropagation keeps the two gestures
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useState, type MouseEvent, type ReactNode } from 'react'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, DiffBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
@@ -47,24 +54,34 @@ export interface ToolRowProps {
/**
* Terminal-card material for a call whose render intent is a terminal card
* (derived by `terminalCardModel`); it replaces the text sections when
* present. A row with no body, no output, and no terminal material is not
* expandable.
* present. A call carries at most one card kind, so the card props below are
* mutually exclusive.
*/
terminal?: TerminalCardModel | null | undefined
/**
* Diff-card material for a call whose render intent is a diff card (derived by
* `diffCardModel`); it replaces the text body when present, the same way
* `terminal` does.
*/
diff?: DiffCardModel | null | undefined
/**
* Read-card material for a call whose render intent is a read card (derived by
* `readCardModel`); it replaces the text body with the file's line-numbered,
* syntax-highlighted window when present.
*/
read?: ReadCardModel | null | undefined
/**
* Search-card material for a call whose render intent is a search card
* (derived by `searchCardModel`); it replaces the text body when present.
* Null or absent leaves the text body. A call carries at most one card kind,
* so `terminal`, `search`, and `diff` are never both present on the same row.
* (derived by `searchCardModel`); it replaces the text body with grouped
* matches or a path list when present.
*/
search?: SearchCardModel | null | undefined
/**
* Diff-card material for a call whose render intent is a diff card (derived by
* `diffCardModel`); it replaces the text body when present, the same way
* `terminal` does. A call carries at most one card intent, so the cards are
* never both set.
* Web-card material for a call whose render intent is a web card (derived by
* `webCardModel`); it replaces the text body with the retrieval's citation
* list or fetched-source card when present.
*/
diff?: DiffCardModel | null | undefined
web?: WebBlockProps | null | undefined
state: ToolRowState
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
@@ -101,6 +118,19 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
/** Visually hidden run-state label: the StateDot and the CSS sweep are both
* aria-hidden / colour-only, so assistive technology needs this text to know a
* row is running, failed, or interrupted. null in the ok state (the icon and
* summary already describe a settled row). */
function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null {
switch (state) {
case 'running': return t('row.running')
case 'error': return t('row.failed')
case 'stopped': return t('row.stopped')
default: return null
}
}
export function ToolRow({
t,
variant,
@@ -112,8 +142,10 @@ export function ToolRow({
output,
errorSummary,
terminal,
search,
diff,
read,
search,
web,
state,
filePath,
onOpenFile,
@@ -121,13 +153,20 @@ export function ToolRow({
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
const searchBody = search ?? null
const diffBody = diff ?? null
const readBody = read ?? null
const searchBody = search ?? null
const webBody = web ?? null
const outputText = output ?? null
// A search or diff card replaces the text body; a call carries at most one
// card kind, so terminal, search, and diff are never both present on a row.
const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null || diffBody !== null
// A card replaces the text body; a call carries at most one card kind, so the
// card props are mutually exclusive. Any of them, or a text body/output,
// makes the row expandable.
const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
const expandable = body !== null || outputText !== null || card !== null
const open = expanded && expandable
// The run-state label AT needs: the StateDot and the running sweep are both
// aria-hidden / colour-only, so a stopped or running row is otherwise silent.
const status = stateStatus(state, t)
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
const failureLine = state === 'error' ? errorSummary ?? null : null
@@ -141,6 +180,13 @@ export function ToolRow({
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Keep Enter/Space on the focused path link from bubbling to the row's
// keydown handler, which would preventDefault() the key and toggle expand
// instead of activating the link — the keyboard analogue of openFile's
// stopPropagation. The native button still fires its own onClick from the key.
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
@@ -153,6 +199,7 @@ export function ToolRow({
// of losing it with the icon.
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
@@ -175,6 +222,7 @@ export function ToolRow({
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
{summaryText}
</button>
@@ -198,51 +246,55 @@ export function ToolRow({
className={css.terminalBody}
/>
)
: searchBody !== null
? (
<>
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
{/* A capped search's recovery locator lives only in the result
text; show it below the card so the dropped rows survive. */}
{searchBody.recovery !== undefined && (
<div className={css.searchRecovery}>{searchBody.recovery}</div>
)}
</>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: readBody !== null
? <ReadBlock {...readBody} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
: searchBody !== null
? (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
{/* A capped search's recovery locator lives only in the result
text; show it below the card so the dropped rows survive. */}
{searchBody.recovery !== undefined && (
<div className={css.searchRecovery}>{searchBody.recovery}</div>
)}
</>
)}
)
: webBody !== null
? <WebBlock {...webBody} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"

View File

@@ -1,45 +0,0 @@
// Shared toolview-row helpers for the keyed rows whose card is resident below a
// summary (SearchRow, FileMutationRow): the visually hidden run-state label and
// the flattened settled-result text for the fallback arm a card cannot render.
// Both are pure functions of a frozen call slice — no chat-domain imports — so a
// row stays a thin ToolRowProps consumer.
import type { ToolRowProps } from './slots.ts'
import type { ToolRowState } from './tool-call-model.ts'
/**
* Visually hidden run-state label for a row's leading `StateDot` (which is
* `aria-hidden`), so assistive technology still announces the state. Returns
* null for the settled-ok state, which needs no spoken label.
* @param state - the row's run state.
* @returns the label, or null when none is needed.
*/
export function rowStateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* A settled result's text, flattened from its content blocks, for the fallback
* arm a keyed row shows when its card cannot render the result — an errored call
* (the tool emits no result view on error) or a settled call with no card view
* (a nested `run_code` sub-dispatch, a legacy generic result). The keyed row owns
* the render slot, so without this the model-facing text would have nowhere to
* go. Falls back to the error name/code when the result carries no text block.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
export function rowResultText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
if (item.type === 'text') parts.push(item.text)
}
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
const text = parts.join('\n')
return text === '' ? null : text
}

View File

@@ -80,6 +80,9 @@ export const zh = {
'bash.running': '运行中',
'bash.failed': '失败',
'bash.stopped': '已停止',
'row.running': '运行中',
'row.failed': '失败',
'row.stopped': '已停止',
'queue.count': '{n} 条排队消息',
'queue.edit': '编辑排队消息',
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
@@ -177,6 +180,9 @@ export const en = {
'bash.running': 'Running',
'bash.failed': 'Failed',
'bash.stopped': 'Stopped',
'row.running': 'Running',
'row.failed': 'Failed',
'row.stopped': 'Stopped',
'queue.count': '{n} queued messages',
'queue.edit': 'Edit queued message',
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',

View File

@@ -22,6 +22,7 @@ export function ConversationRoot({
const session = useSession(s => s)
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
const workspaces = useWorkspaces(s => s)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -65,8 +66,16 @@ export function ConversationRoot({
// While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
// Exemption: a session the list summary already proves blank can only
// land on the hero, so hiding would blank the column for the whole
// history round-trip (the startup auto-selection flash) for nothing.
// The exemption is deliberately open-state-wide, not loading-only: a
// summary-blank session is the hero before its open starts (`cold`) and
// after one fails (`error`) for the same reason — there is no history.
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
&& summaryBlank !== true
const hero = sessionId === undefined
|| (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }

View File

@@ -101,9 +101,9 @@
font: var(--dsw-font-xs-13);
}
/* A card body (terminal, search, or diff) sits directly under its section
/* A card body (terminal, diff, or search) sits directly under its section
label, so it drops the primitive's standalone vertical margin; the section
owns the spacing. Card-neutral: no card-specific value. */
owns the spacing. Card-neutral: no card-kind-specific value. */
.cardBody {
margin: 0;
}

View File

@@ -11,9 +11,9 @@ import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock }
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { searchCardModel } from '../contract/search-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
@@ -131,15 +131,16 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* The Output section's body for the selected call. A terminal-card call — a
* shell command's call/result views — renders through the shared TerminalBlock
* at the primitive's own full height allowance, so column-aligned output keeps
* its alignment and scrolls sideways instead of folding. A search-card call —
* a `grep`/`glob` result view — renders through the shared SearchBlock at the
* same full height allowance, with a capped search's recovery footer below it.
* A read-card call renders through the shared ReadBlock at that same full height,
* so the whole returned window is line-numbered and highlighted. A diff-card
* call — a write/edit's applied change — renders through the shared DiffBlock at
* the same full height. A web-card call — a `web_search`/`web_fetch` result —
* renders through WebBlock at its own full source-list allowance. Every other
* call, and a running call with no card yet, keeps the flattened text form.
* its alignment and scrolls sideways instead of folding. A read-card call
* renders through the shared ReadBlock at that same full height, so the whole
* returned window is line-numbered and highlighted. A diff-card call — a
* write/edit's applied change — renders through the shared DiffBlock at the same
* full height. A search-card call — a `grep`/`glob` result view — renders
* through the shared SearchBlock at the same full height allowance, with a
* capped search's recovery footer below it. A web-card call — a
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
* source-list allowance. Every other call, and a running call with no card yet,
* keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @param props.t - the panel's locale seat, passed down as a plain prop.
@@ -159,6 +160,12 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const read = readCardModel(material.block, cwd)
// The panel takes the primitive's own default cap, not the row's tighter one:
// it is the single-call reading surface, so the whole window is available.
if (read !== null) return <ReadBlock {...read} className={css.read} />
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
const search = searchCardModel(material.block)
if (search !== null) {
return (
@@ -172,12 +179,6 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const read = readCardModel(material.block, cwd)
// The panel takes the primitive's own default cap, not the row's tighter one:
// it is the single-call reading surface, so the whole window is available.
if (read !== null) return <ReadBlock {...read} className={css.read} />
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
const web = webCardModel(material.block)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the

View File

@@ -1,130 +0,0 @@
/* File-mutation toolview: same geometry/tokens as ToolRow (figma
{Edit,Write} · path), plus the diff card the row stacks under its summary
line. Mirrors bash-sample.module.css, whose terminal card this replaces with
a diff card. */
/* Summary line over the diff card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.diff {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-file-mutation-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* The result text for an errored mutation, indented to the card's own column
(the diff card's inset) and in the error tone, since it stands in for the diff
card the failure path does not produce. */
.failure {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -1,74 +1,54 @@
// File-mutation toolview registrant: third-party posture over the keyed
// toolview hole (ctx.slots.register + ToolRowProps only — never imports the
// chat domain), registered under both `edit` and `write`. Product chrome
// matches ToolRow (figma: {Edit,Write} · {path}).
//
// A write/edit call declares the diff render intent, so this row renders the
// applied change through DiffBlock resident below its summary line — the same
// posture BashRow gives a terminal card. The row has no expand control and is
// not a details-panel target (tool rows stopped being one), so the diff body
// is resident rather than expand-gated, and the card's own copy and expand
// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body
// against the message flow; the details panel keeps the block's full default.
// The summary stays a path link (the file-tool interaction) that opens through
// the host.
// File-mutation toolview registrant: the keyed toolview hole for the `edit`
// and `write` tools. The row composes the shared ToolRow (chrome, running
// sweep, whole-row expand) and feeds it the applied diff as ToolRow's `diff`
// card material, so the change renders through DiffBlock in the collapsed-by-
// default expanded body — the same unified interaction every other card row
// has. The summary stays a path link (the file-tool interaction) that opens
// through the host; an errored mutation (write/edit return no diff on
// `result.isError`) keeps the model-facing error text on ToolRow's Output
// section, its first line in the collapsed summary.
import type { Context } from 'cordis'
import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
import css from './file-mutation-row.module.css'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconEditOutline16 size={14} />
}
}
/** Full row props: the toolview runtime share plus the standard locale seat. */
type FileMutationRowProps = ToolRowProps & PropsLocale<'conversation'>
/**
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
* with the applied diff resident below it. The summary is a path link (a file
* tool's interaction); the host's `openFile` resolves it against the session
* cwd, so this passes the tool's own path verbatim. The card's copy and expand
* controls are the row's only other actions.
* with the applied diff as the row's collapsed-by-default card body. The
* summary is a path link (a file tool's interaction); the host's `openFile`
* resolves it against the session cwd, so this passes the tool's own path
* verbatim. An errored mutation has no diff card, so ToolRow surfaces the
* model-facing error text through its Output section and its first line in the
* collapsed summary instead.
*/
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) {
export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) {
const model = toolRowModel(toolName, block, cwd)
const diff = diffCardModel(block)
const status = rowStateStatus(model.state)
const filePath = model.filePath
// An errored mutation has no diff card (presentResult returns undefined on
// isError); surface its result text so the failure is more than a red dot.
const failure = diff === null && model.state === 'error' ? rowResultText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant={model.variant} data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{diff !== null && (
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
)}
{failure !== null && <div className={css.failure}>{failure}</div>}
</div>
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconEditOutline16 size={14} />}
title={model.title}
summary={model.summary}
body={null}
output={model.output}
errorSummary={model.errorSummary}
diff={diff}
state={model.state}
filePath={model.filePath}
onOpenFile={openFile}
inspect={inspect}
/>
)
}
@@ -87,7 +67,7 @@ export const fileMutationToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
},
}

View File

@@ -1,119 +0,0 @@
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
the read card the row stacks under its summary line. */
/* Summary line over the read card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
gives the same executing feedback a running command row does. The leading
read icon stays static (a read has no per-step state to animate); the sweep
is the row-level running signal. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-read-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-read-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,88 +1,49 @@
// Read toolview registrant: the keyed toolview hole for the read tool
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the
// file path as an openable link, exactly as the generic read row draws it.
//
// A read RESULT declares the read render intent, so this row renders the file's
// own line-numbered, syntax-highlighted content through ReadBlock resident
// below its summary line — the same posture BashRow gives a terminal card. The
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
// block's own default of 16) with the block's internal expander keeping a long
// read from taking over the message flow. A running read (no result yet) and a
// non-read result both render the summary row alone. The read intent is
// Read toolview registrant: the keyed toolview hole for the read tool. The row
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
// feeds it the file's line-numbered, syntax-highlighted content as ToolRow's
// `read` card material, so it renders through ReadBlock in the collapsed-by-
// default expanded body — the same unified interaction every other card row
// has. The summary path is an openable host link. A running read (no result
// yet) and a non-read result render the summary row alone: the read intent is
// result-side only, so there is no running-state read card to draw.
import type { Context } from 'cordis'
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './read-row.module.css'
import { readCardModel } from '../contract/read-card-model.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Leading-slot state substitution: the tool icon yields to the state dot
* (error = red, interrupted = amber). Running keeps the icon. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconBrowseOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/** Full row props: the toolview runtime share plus the standard locale seat. */
type ReadRowProps = ToolRowProps & PropsLocale<'conversation'>
/**
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
* read card resident below it. The summary path is an openable host link when
* the row names a single file; the card's copy and expand controls plus that
* link are the row's only interactions (tool rows are not details-panel
* targets).
* read card as the row's collapsed-by-default card body. The summary path is an
* openable host link when the row names a single file.
*/
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) {
// Session workspace root: the read view's path relativizes against it (a
// workspace-rooted absolute path shows its short form), which the pure
// presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) {
const model = toolRowModel(toolName, block, cwd)
const read = readCardModel(block, cwd)
const status = stateStatus(model.state)
const filePath = model.filePath
return (
<div className={css.card}>
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title,
sep, path-link/summary) is the shared ToolRow row shape every keyed
toolview draws; extracting it into one component is a separate change
tracked for all rows at once, not this read-card PR. */}
<div className={css.root} data-variant="read" data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{/* jscpd:ignore-end */}
{read !== null && (
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
)}
</div>
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconBrowseOutline16 size={14} />}
title={model.title}
summary={model.summary}
body={null}
output={model.output}
errorSummary={model.errorSummary}
read={read}
state={model.state}
filePath={model.filePath}
onOpenFile={openFile}
inspect={inspect}
/>
)
}
@@ -100,6 +61,6 @@ export const readToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow)
},
}

View File

@@ -1,117 +0,0 @@
/* Search toolview: same geometry/tokens as ToolRow and BashRow (figma
Search · summary), plus the search card the row stacks resident under its
summary line. */
/* Summary line over the search card; the summary row keeps its own 24px
height, so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.search {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow / BashRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-search-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-search-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* The result text for an errored search, indented to the card's own column and
in the error tone, standing in for the search card the failure path does not
produce. */
.failure {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-state-error-primary);
}
/* The recovery footer for a capped search: the model-facing result text (its
`Full … stored at …` locator) shown below the card in the muted tone, since
the card holds only the retained rows. Same column indent as the card body. */
.recovery {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -1,76 +1,62 @@
// Search toolview registrant: the keyed toolview hole (ctx.slots.register +
// ToolRowProps only — never imports the chat domain). One SearchRow component
// registered under both `grep` and `glob`, since both tools declare the same
// `card: 'search'` render intent and render as one visual object; the row reads
// the `kind` discriminant off the derived model to draw grouped matches or a
// path list. Product chrome matches ToolRow / BashRow (Search · {summary}).
//
// A search call declares its render intent result-time only, so this row's
// search card is resident below the summary rather than expand-gated: the row
// itself has no expand control, and the card's own copy, per-file collapse, and
// head/tail expand are the row's only interactions. CHAT_SEARCH_MAX_LINES is
// passed as `maxLines` — the chat flow's tighter cap over the block's own
// default of 16 — so a large result stays bounded in the message flow.
// Search toolview registrant: the keyed toolview hole for the `grep` and `glob`
// tools. One SearchRow component registered under both, since both declare the
// same `card: 'search'` render intent and render as one visual object; the
// derived model's `kind` decides the card shape (grouped matches or a path
// list). The row composes the shared ToolRow (chrome, running sweep, whole-row
// expand) and feeds it the completed search as ToolRow's `search` card
// material, so it renders through SearchBlock in the collapsed-by-default
// expanded body — with a capped search's recovery footer below the card. A
// search declares its render intent result-time only, so a running row is the
// summary line alone; a settled call with no search card (an errored search, a
// nested run_code sub-dispatch, a legacy generic result) surfaces its
// model-facing text through ToolRow's Output section instead.
import type { Context } from 'cordis'
import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
import css from './search-row.module.css'
import { searchCardModel } from '../contract/search-card-model.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Leading-slot glyph substitution: the search icon yields to the terminal
* state semantic (error = red, interrupted = amber). Running keeps the icon —
* the row sweep carries the in-flight signal. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconSearchOutline16 size={14} />
}
}
/** Full row props: the toolview runtime share plus the standard locale seat. */
type SearchRowProps = ToolRowProps & PropsLocale<'conversation'>
/**
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
* completed search's card resident below it, and — when the result was capped —
* the recovery footer below the card. The summary row is not a details-panel
* control, so the card's copy, per-file collapse, and expand controls are the
* row's only interactions. Registered under both `grep` and `glob`; the derived
* model's `kind` decides the card shape.
* completed search's card as the row's collapsed-by-default card body (a capped
* search's recovery footer rides below it, inside ToolRow). Registered under
* both `grep` and `glob`; the derived model's `kind` decides the card shape. A
* settled call with no search card surfaces its model-facing text through
* ToolRow's Output section, since the keyed SearchRow owns this render slot.
*/
export function SearchRow({ toolName, block }: ToolRowProps) {
export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
const model = toolRowModel(toolName, block)
const search = searchCardModel(block)
const status = rowStateStatus(model.state)
// A settled call with no search card — an errored search (grep/glob emit no
// result view on error), a successful nested run_code sub-dispatch, or a
// legacy generic result — has its model-facing text nowhere else to go, since
// the keyed SearchRow owns this render slot. Surface it as the fallback body.
// A running call ('kind' absent) has no result to flatten; rowResultText
// returns null for it, so the arm stays closed until settle.
const settled = 'kind' in block
const fallback = search === null && settled ? rowResultText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{/* The result view's replacement title outranks the args-derived
summary, matching the terminal card's description precedence. */}
<span className={css.summary}>{search?.title ?? model.summary}</span>
</div>
{search !== null && (
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
)}
{/* A capped search drops rows from the card; its recovery locator (the
`Full … stored at …` footer) lives only in the result text, so show it
below the card so the one path to the dropped rows survives. */}
{search?.recovery !== undefined && <div className={css.recovery}>{search.recovery}</div>}
{fallback !== null && <div className={css.failure}>{fallback}</div>}
</div>
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconSearchOutline16 size={14} />}
title={model.title}
// The result view's replacement title outranks the args-derived summary,
// matching the terminal card's description precedence.
summary={search?.title ?? model.summary}
body={null}
// A settled call with no search card (errored search, nested run_code
// sub-dispatch, legacy generic result) has its text nowhere else to go;
// ToolRow's Output section carries it, and errorSummary its first line.
// When a card is present ToolRow renders it instead of the output, so
// passing model.output unconditionally is safe and keeps the four card
// rows symmetric.
output={model.output}
errorSummary={model.errorSummary}
search={search}
state={model.state}
inspect={inspect}
/>
)
}
@@ -90,7 +76,7 @@ export const searchToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob' }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
},
}

View File

@@ -1,95 +0,0 @@
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
the web card the row stacks under its summary line, mirroring the bash row's
resident terminal card. */
/* Summary line over the web card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.web {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-web-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-web-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,24 +1,25 @@
// Web toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Registered under BOTH web_search and web_fetch, since both declare the one
// `web` render intent and render through the one WebBlock family; the row
// discriminates on the toolName only to pick its icon and title.
//
// A web tool declares the `web` render intent at result time, so this row
// renders the completed retrieval through WebBlock resident below its summary,
// the same posture BashRow uses for the terminal card: no expand control on the
// row itself, not a details-panel target, and the block's own expander keeps a
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
// passed as maxSources — the chat flow's tighter cap over the block's default
// of 16). Until the call settles there is no web card (the tools keep a generic
// pending view), so a running row is the summary line alone.
// Web toolview registrant: the keyed toolview hole for the `web_search` and
// `web_fetch` tools. Registered under BOTH, since both declare the one `web`
// render intent and render through the one WebBlock family; the row
// discriminates on the toolName only to pick its icon and title. The row
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
// feeds it the completed retrieval as ToolRow's `web` card material, so it
// renders through WebBlock in the collapsed-by-default expanded body — the same
// unified interaction every other card row has. Until the call settles there is
// no web card (the tools keep a generic pending view), so a running row is the
// summary line alone.
import type { Context } from 'cordis'
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './web-row.module.css'
import { webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
import { NS } from '../locales.ts'
/** Full row props: the toolview runtime share plus the standard locale seat. */
type WebRowProps = ToolRowProps & PropsLocale<'conversation'>
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
const WEB_TITLES: Record<string, string> = {
@@ -26,49 +27,30 @@ const WEB_TITLES: Record<string, string> = {
web_fetch: 'Fetch',
}
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
function leadingFor(toolName: string, state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
* the completed retrieval's web card resident below it. The summary row is not
* a details-panel control (tool rows stopped being one), so the card's own
* links and expander are the row's only interactions.
* the completed retrieval's web card as the row's collapsed-by-default card
* body. The row discriminates on `toolName` only to pick its icon and title.
*/
export function WebRow({ toolName, block }: ToolRowProps) {
export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
const model = toolRowModel(toolName, block)
const web = webCardModel(block)
const status = stateStatus(model.state)
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
return (
<div className={css.card}>
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
{web !== null && (
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
)}
</div>
<ToolRow
t={t}
variant={model.variant}
toolName={toolName}
icon={icon}
title={WEB_TITLES[toolName] ?? model.title}
summary={model.summary}
body={null}
output={model.output}
errorSummary={model.errorSummary}
web={web}
state={model.state}
inspect={inspect}
/>
)
}
@@ -86,7 +68,7 @@ export const webToolview = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
},
}

View File

@@ -88,15 +88,15 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the search rows, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// one search row registers under both grep and glob; the file-mutation
// registrant claims both write and edit for the diff card; the web rows
// register one component under both web tool names.
// file-mutation registrant claims both write and edit for the diff card; the
// one search row registers under both grep and glob; the web rows register
// one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -12,7 +12,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
@@ -24,6 +24,9 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
/** FileMutationRow's full prop shape (ToolRow runtime share + conversation locale seat). */
type FileMutationRowProps = Parameters<typeof FileMutationRow>[0]
const SID = 's1' as SessionId
const t = makeTranslate(zh, commonZh)
@@ -155,14 +158,23 @@ describe('FileMutationRow diff card', () => {
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
t,
} as unknown as FileMutationRowProps)
it('renders the applied diff under the summary row, without an expand gesture', () => {
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('collapses to the summary row; expanding reveals the applied diff card', () => {
const view = render(<FileMutationRow {...rowProps(settled())} />)
// The diff card is resident (no expand toggle needed).
// The diff card is collapsed by default — not in the DOM until expanded.
expect(view.container.querySelector('[data-diff]')).toBeNull()
expect(view.queryByText('hello fixture')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
@@ -171,6 +183,7 @@ describe('FileMutationRow diff card', () => {
it('the summary is a path link that opens the tool path through the host', () => {
const openFile = vi.fn()
const view = render(<FileMutationRow {...{ ...rowProps(settled()), openFile }} />)
// The path link rides the collapsed summary, so it opens without expanding.
fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' }))
// The row passes the tool's own path; the injected openFile resolves it
// against the session cwd (apply.ts), so the row must not resolve twice.
@@ -184,6 +197,8 @@ describe('FileMutationRow diff card', () => {
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
}), 'write')} />)
// The footer counts live inside the collapsed diff card.
toggleRow(view)
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
@@ -197,13 +212,16 @@ describe('FileMutationRow diff card', () => {
it('a mutation call with no diff view renders the summary row alone', () => {
const view = render(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
// No diff material: expanding shows the args-JSON body, never a diff card.
expect(view.container.querySelector('[data-diff]')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).toBeNull()
})
it('surfaces the result text when an errored mutation has no diff card', () => {
// write/edit return undefined from presentResult on isError, so the failure
// has no diff — the row shows the model-facing error text instead of a bare
// red dot.
// has no diff — ToolRow shows the model-facing error text as the collapsed
// summary's first line (errorSummary) instead of a bare red dot.
const view = render(<FileMutationRow {...rowProps(settled({
isError: true, callView: null, resultView: null,
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
@@ -220,12 +238,13 @@ describe('FileMutationRow diff card', () => {
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
})
it('shows no failure text for a successful diff or a running call', () => {
it('shows no error summary for a successful diff or a running call', () => {
// ToolRow's error-color summary line is set only on the error state.
const ok = render(<FileMutationRow {...rowProps(settled())} />)
expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull()
expect(ok.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
cleanup()
const run = render(<FileMutationRow {...rowProps(running())} />)
expect(run.container.querySelector('[class*="_failure_"]')).toBeNull()
expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
})
it('shows the stopped state when the call was interrupted', () => {
@@ -234,7 +253,8 @@ describe('FileMutationRow diff card', () => {
error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
// The visually-hidden status label carries the stopped semantic for AT.
// The amber StateDot is aria-hidden, so ToolRow carries the state to AT as
// visually-hidden text; without it a stopped row is a colour-only signal.
expect(view.getByText('已停止')).toBeTruthy()
})
@@ -250,12 +270,12 @@ describe('FileMutationRow diff card', () => {
describe('fileMutationToolview registration', () => {
it('registers one component under both edit and write, and each disposes', () => {
const registered: { key: string; disposed: boolean }[] = []
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
const disposers: (() => void)[] = []
const ctx = {
slots: {
register: ({ key }: { name: string; key: string }) => {
const entry = { key, disposed: false }
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
const entry = { key, locale, disposed: false }
registered.push(entry)
const dispose = () => { entry.disposed = true }
disposers.push(dispose)
@@ -265,6 +285,8 @@ describe('fileMutationToolview registration', () => {
}
fileMutationToolview.apply(ctx as never)
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// The registrant's inject seam is the load-order contract the row relies on.
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
// Disposal removes each contribution (packages/AGENTS.md registry contract).

View File

@@ -2,8 +2,9 @@
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
// each with the read card resident under the summary) and the details panel's
// Output section. Also pins the keyed 'read' toolview registration.
// each composing ToolRow with the read card as its collapsed-by-default expanded
// body) and the details panel's Output section (resident, full height). Also
// pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
@@ -16,7 +17,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
@@ -128,11 +129,19 @@ describe('GenericToolCard read body', () => {
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
it('renders the read card resident under the summary, capped tighter than the panel', () => {
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('expands to the read card, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
// fallback card owns the resident read block.
// fallback card owns the read block once expanded.
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
// Collapsed: no read card in the DOM yet.
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
@@ -145,6 +154,7 @@ describe('GenericToolCard read body', () => {
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
@@ -162,19 +172,34 @@ describe('ReadRow keyed toolview', () => {
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
t,
} as unknown as Parameters<typeof ReadRow>[0])
it('renders the file path summary and the resident read card', () => {
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('collapses to the path summary; the whole row toggles the read card', () => {
const view = render(<ReadRow {...rowProps(settled())} />)
expect(view.getByText('Read')).toBeTruthy()
// The path appears twice: the row summary link and the card's banner label.
// Collapsed: the path is the summary link alone, and the card is absent.
expect(view.getAllByText('src/a.ts').length).toBe(1)
expect(view.container.querySelector('[data-read]')).toBeNull()
toggleRow(view)
// Expanded: the summary link stays inline and the card's banner label adds a
// second occurrence of the path.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
// Collapse back in place: the card unmounts, the summary link returns.
toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getAllByText('src/a.ts').length).toBe(1)
})
it('the path summary opens the file through the host', () => {
@@ -212,7 +237,8 @@ describe('ReadRow keyed toolview', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
readToolview.apply(ctx)
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
// The row composes ToolRow, so it declares its locale namespace at the seat.
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
expect(readToolview.inject).toContain('conversation')
})
})

View File

@@ -1,9 +1,10 @@
// @vitest-environment jsdom
// The search render intent on the web side: the pure searchCardModel derivation
// over resultView, and the conversation render sites that consume it — the chat
// tool row (GenericToolCard's expand-gated body and SearchRow's resident card)
// and the details panel's Output section. The keyed registration under both grep
// and glob is pinned here too.
// tool row (GenericToolCard's fallback body and SearchRow, both composing the
// shared ToolRow with the search card collapsed by default) and the details
// panel's Output section (resident, full height). The keyed registration under
// both grep and glob is pinned here too.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
@@ -13,7 +14,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
@@ -23,6 +24,9 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/G
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
type SearchRowProps = Parameters<typeof SearchRow>[0]
afterEach(cleanup)
/** Conversation-locale translate stub for the render sites' `t` seat. */
@@ -228,21 +232,32 @@ describe('chat row search body (GenericToolCard fallback)', () => {
})
describe('SearchRow keyed card', () => {
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID,
} as unknown as ToolRowProps)
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t,
} as unknown as SearchRowProps)
it('renders the grep card resident under the summary row, without an expand gesture', () => {
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('collapses to the summary row; expanding reveals the grep card', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('Search')).toBeTruthy()
// Collapsed: the card is not in the DOM until the row is expanded.
expect(searchKindOf(view.container)).toBeNull()
expect(view.queryByText(/const foo = 1/)).toBeNull()
toggleRow(view)
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(searchKindOf(view.container)).toBe('matches')
// The card's controls are the row's only interactions.
// The card's copy control lives inside the expanded body.
expect(view.getByText('复制')).toBeTruthy()
})
it('renders the glob path card resident', () => {
it('expands to the glob path card', () => {
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
expect(searchKindOf(view.container)).toBeNull()
toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
@@ -250,7 +265,7 @@ describe('SearchRow keyed card', () => {
it('agrees with the summary row about the run state', () => {
const runningView = render(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
// No result view yet, so no resident card.
// No result view yet, so no card even once material could expand.
expect(searchKindOf(runningView.container)).toBeNull()
cleanup()
const errorView = render(<SearchRow {...rowProps(settledGrep({
@@ -259,28 +274,35 @@ describe('SearchRow keyed card', () => {
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
})
it('surfaces the result text when an errored search has no card', () => {
it('surfaces the result text through the Output section when an errored search has no card', () => {
// grep/glob return no presentResult on error → no card; the row shows the
// model-facing error text instead of a bare red dot.
// first error line as the collapsed summary and the full text once expanded.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null,
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
}), 'grep')} />)
expect(searchKindOf(view.container)).toBeNull()
// Error state: the first line is the collapsed summary.
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
toggleRow(view)
// Now in ToolRow's Output section too (the kept summary makes it appear twice).
expect(view.container.querySelector('[data-error]')?.textContent).toBe('grep: invalid regular expression')
})
it('surfaces the result text for a settled non-error call with no card', () => {
it('surfaces the result text for a settled non-error call with no card once expanded', () => {
// A successful nested run_code sub-dispatch (backend computes no
// presentationMeta, so resultView is null) or a legacy generic result settles
// with search === null and state ok. The keyed SearchRow owns the slot, so
// without the widened arm the content would be lost behind a bare summary.
// ToolRow's Output section carries the text; it is only visible expanded.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: false, resultView: null,
content: [{ type: 'text', text: 'nested run_code output line' }],
}), 'grep')} />)
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
expect(searchKindOf(view.container)).toBeNull()
// Collapsed: the ok row shows its args summary, not the output text.
expect(view.queryByText('nested run_code output line')).toBeNull()
toggleRow(view)
expect(view.getByText('nested run_code output line')).toBeTruthy()
})
@@ -290,12 +312,15 @@ describe('SearchRow keyed card', () => {
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
it('shows no recovery footer for an uncapped search', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.container.textContent).not.toMatch(/stored at/)
})
@@ -304,6 +329,7 @@ describe('SearchRow keyed card', () => {
isError: true, resultView: null, content: [],
error: { name: 'ToolError', code: 'timeout' },
}), 'grep')} />)
// Error state: the derived name/code line is the collapsed summary.
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
@@ -320,16 +346,18 @@ describe('SearchRow keyed card', () => {
})
it('registers the one row component under both grep and glob keys', () => {
const registered: { key: unknown; component: unknown }[] = []
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string }, component: unknown) => {
registered.push({ key: options.key, component })
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
},
},
} as never
searchToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['grep', 'glob'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component, two keys.
expect(registered[0]!.component).toBe(SearchRow)
expect(registered[1]!.component).toBe(SearchRow)

View File

@@ -80,16 +80,25 @@ function mount(
snapshot: ConversationSnapshot,
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover = false,
options: {
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover?: boolean
/** The session list summary's `blank` flag — independent of the snapshot's. */
summaryBlank?: boolean
/** Drop the session's summary row entirely (a session the list has not caught up with). */
omitSummaryRow?: boolean
} = {},
) {
const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
}
const listed = options.omitSummaryRow !== true
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
},
ids: listed ? [root, SID] : [root],
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
current: SID,
phase: 'ready',
})
@@ -167,7 +176,7 @@ function mount(
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => (
overlayTakeover
options.overlayTakeover === true
? (
<>
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
@@ -229,7 +238,7 @@ describe('ConversationRoot resident composer', () => {
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
const b = mount(conversationSnapshot(), undefined, undefined, true)
const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true })
const seat = b.view.container.querySelector('[data-composer-seat]')
const takeover = b.view.getByTestId('composer-takeover')
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
@@ -270,6 +279,39 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ omitSummaryRow: true },
)
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
})
it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ summaryBlank: true },
)
// The summary already proves the outcome, so the settling hide would only
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')

View File

@@ -4,17 +4,19 @@
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
// render-site fallback, and the details panel's Output section. Mirrors
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
// row's resident card, the panel arm, and the keyed registration.
// row's collapsed-by-default ToolRow card, the panel arm, and the keyed
// registration. WebRow now composes the shared ToolRow, so its web card is
// collapsed by default and appears only once the whole row is expanded.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -122,36 +124,49 @@ describe('chat row web body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
callId: block.callId, toolName, block, openFile: vi.fn(),
})
// WebRow reads only toolName/block off the full runtime share; the standard
// kit is unused, so the cast supplies the owner slice alone (as BashRow's
// tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
ownerProps(block, toolName) as unknown as ToolRowProps
// WebRow reads only toolName/block off the full runtime share plus the locale
// seat; the standard kit is unused, so the cast supplies the owner slice and
// `t` alone (as BashRow's tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters<typeof WebRow>[0] =>
({ ...ownerProps(block, toolName), t } as unknown as Parameters<typeof WebRow>[0])
it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
// The summary row plus the resident card, without any expand gesture on the row itself.
// Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
// Expanded: the resident search card with every source field.
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// hostname fallback for the source with no title
expect(view.getByText('plain.example.org')).toBeTruthy()
})
it('the WebRow renders the fetch card resident, titled Fetch', () => {
it('the WebRow expands to the fetch card, titled Fetch', () => {
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
expect(view.getByText('Fetch')).toBeTruthy()
// The url shows in the summary row and as the card's link; scope to the card.
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
// The url shows as the card's link; scope to the card.
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('a running web call is the summary row alone (no card until it settles)', () => {
it('a running web call is the summary row alone, with nothing to expand', () => {
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
// No card material and no expandable body: clicking the row reveals nothing.
expect(view.container.querySelector('[data-expandable]')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
})
@@ -165,12 +180,14 @@ describe('chat row web body', () => {
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
// A web-declaring tool without its own keyed row lands on the fallback; its
// card is resident there too.
// card routes through the same collapsed-by-default ToolRow.
const view = render(<GenericToolCard {...ownerProps(settledSearch({
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
}), 'fx-web')} t={t} />)
expect(view.container.querySelector('[data-web]')).toBeNull()
toggleRow(view)
expect(view.getByText('Titled')).toBeTruthy()
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
})
@@ -250,17 +267,19 @@ describe('DetailsPanel web Output section', () => {
describe('web toolview registration', () => {
it('registers one WebRow under both web_search and web_fetch', () => {
const registered: { key: string; component: unknown }[] = []
const registered: { key: string; locale: unknown; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string }, component: unknown) => {
registered.push({ key: options.key, component })
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
registered.push({ key: options.key, locale: options.locale, component })
return () => {}
},
},
} as unknown as import('cordis').Context
webToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
// Both keys claim the conversation locale seat ToolRow's body copy needs.
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
// One component under both keys, not two thin rows.
expect(registered[0]?.component).toBe(WebRow)
expect(registered[1]?.component).toBe(WebRow)

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: 6430a789c15634538a38d6581df50a489522db55
README.zh.md: 78249612cce3148fcececded40c529682450460a
README.md: f06e8c014c19d17197980a232e8b42080aac2901
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a

View File

@@ -2,7 +2,11 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, SearchBlock, DiffBlock, and WebBlock. Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
## Hover cards
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
## Markdown rendering
@@ -12,14 +16,18 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Search results
## Read rendering
`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md).
`ReadBlock` renders a returned file window as a line-numbered, syntax-highlighted code surface: a bold path (or presenter-supplied title) banner with a copy control, then the content lines with their file line numbers in a gutter (a windowed read keeps the file's own numbering, so a read past an offset starts above 1). A `totalLines` exceeding the window count draws a `showing N of M` note, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, the TerminalBlock split arithmetic) behind an expand button. Highlighting runs through the same shiki path as `CodeBlock`. Rationale: [the web read card note](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md).
## Diff rendering
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md).
## Search results
`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md).
## Web retrieval
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md).
@@ -37,5 +45,5 @@ None; this package neither assembles nor sends a provider request.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment.
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment.
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.

View File

@@ -2,23 +2,32 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、SearchBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。契约:api-contracts v3 §8。
## 悬浮卡片
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
## 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)。
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## 搜索结果
## Read 渲染
`SearchBlock` 渲染一次已完成的搜索,一个组件绘制两种 kind(由 `kind` 判别)。`matches`(grep)把每个文件渲染为粗体路径头加其 `lineNumber: line` 行,每个文件组可折叠;`paths`(glob)渲染扁平路径列表。两者都摊平成一个行列表,由高度上限做头/尾切片(默认 16,与 TerminalBlock 相同的切分算法),且都不软换行——长匹配行或路径横向滚动而非折行。当工具截断结果时,banner 摘要把截断前总数折入(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断结果呈现为完整;复制控件写入完整结构化结果,无论是否触及上限或哪些组被折叠。几何镜像 CodeBlock/TerminalBlock。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
`ReadBlock` 将返回的文件窗口渲染为带行号、语法高亮的代码表层:一个粗体路径(或 presenter 提供的标题)横幅加复制控件,其下是内容行,行号槽里是文件自身的行号(窗口化的 read 保留文件本身的编号,因此偏移之后的 read 从大于 1 处起始)。`totalLines` 超过窗口行数时画出 `showing N of M` 提示;超过 `maxLines`(默认 16,与 TerminalBlock 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。高亮走与 `CodeBlock` 相同的 shiki 路径。原理:[Web read 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)。
## Diff 渲染
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。
## 搜索结果
`SearchBlock` 渲染一次已完成的搜索,并通过 `kind` 判别,由一个组件处理两种结果。`matches`(grep)将每个文件显示为粗体路径头及其 `lineNumber: line` 行,各文件组均可折叠;`paths`(glob)显示扁平的路径列表。两者都摊平成一个行列表,由高度上限对其做头尾切片(默认 16,与 `TerminalBlock` 相同的切分算法),且都不软换行:较长的匹配行或路径会横向滚动而非折行。当工具截断结果时,banner 摘要会包含截断前的总数(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断后的结果呈现为完整结果;无论是否触及上限或哪些组处于折叠状态,复制控件都会写入完整的结构化结果。几何结构与 `CodeBlock`/`TerminalBlock` 一致。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
## Web 检索
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。
@@ -36,5 +45,5 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。

View File

@@ -7,7 +7,9 @@
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
* light/dark identical), so a component-level variable, not a theme token. */
* light/dark identical), so a component-level variable, not a theme token.
* Hit-testable on purpose: resting the pointer on the card holds it open
* (HoverCard's grace close), which a `pointer-events: none` card cannot do. */
.card {
--dsw-hovercard-bg: #2C2C2E;
position: fixed;
@@ -18,5 +20,35 @@
border-radius: 12px;
background: var(--dsw-hovercard-bg);
box-shadow: var(--dsw-shadow-lv3);
pointer-events: none;
}
.copyable {
cursor: pointer;
}
.copyable:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.feedback {
display: flex;
align-items: center;
justify-content: center;
}
.copied {
color: #FFFFFF;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,33 +1,73 @@
// HoverCard: delayed hover-preview card portaled to document.body.
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
// the card is fixed-positioned at its right edge and repositions on
// scroll/resize while open. Display-only — the card ignores pointer events
// and closes the instant the pointer leaves the anchor (no close delay).
// scroll/resize while open. The card is reachable: it takes pointer events,
// and leaving the anchor only arms a grace-delayed close, so the pointer can
// cross the 8px gap and settle on the card to read a clipped path or title.
// The portaled card is a React child of the wrapper, so React's enter/leave
// traversal already treats it as inside — one pair of wrapper handlers covers
// anchor and card alike.
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { writeClipboard } from './clipboard.ts'
import { usePointerGrace } from './pointer-grace.ts'
import css from './HoverCard.module.css'
/**
* Render an anchor with a hover-triggered preview card.
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
* @param props.content - card content (display-only, no pointer interaction).
* @param props.content - card content; the pointer may rest on it, so it is
* readable and selectable, but it carries no dismissal affordance of its own.
* @param props.openDelayMs - hover dwell before the card shows (default 500).
* @param props.disabled - suppress opening; turning true closes an open card.
* @param props.copyText - optional primary value copied by activation and
* included in the card's accessible name.
* @param props.copyLabel - accessible activation-label prefix (default "复制").
* @param props.copiedLabel - visible success label (default "复制成功").
* @returns anchor wrapper with the conditional portaled card.
*/
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
export function HoverCard({
anchor, content, openDelayMs = 500, disabled = false,
copyText, copyLabel = '复制', copiedLabel = '复制成功',
}: {
anchor: ReactNode
content: ReactNode
openDelayMs?: number
disabled?: boolean
copyText?: string | undefined
copyLabel?: string | undefined
copiedLabel?: string | undefined
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyHeightRef = useRef<number | null>(null)
const copyEpochRef = useRef(0)
const copyingRef = useRef(false)
const mountedRef = useRef(true)
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
const [copied, setCopied] = useState(false)
const clearCopied = useCallback(() => {
if (copyTimerRef.current !== null) {
clearTimeout(copyTimerRef.current)
copyTimerRef.current = null
}
copyHeightRef.current = null
setCopied(false)
}, [])
const close = useCallback(() => {
copyEpochRef.current += 1
clearCopied()
setOpen(false)
}, [clearCopied])
const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
const clearTimer = () => {
if (timerRef.current !== null) {
@@ -40,10 +80,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
useEffect(() => {
if (!disabled) return
clearTimer()
setOpen(false)
}, [disabled])
cancelClose()
close()
}, [disabled, cancelClose, close])
useEffect(() => clearTimer, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
copyEpochRef.current += 1
clearTimer()
if (copyTimerRef.current !== null) {
clearTimeout(copyTimerRef.current)
copyTimerRef.current = null
}
}
}, [])
// Fixed-position from the anchor rect before paint; track the anchor while
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
@@ -79,9 +131,49 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
}
}, [open, pos])
const copy = async (text: string): Promise<void> => {
if (copied || copyingRef.current) return
copyingRef.current = true
const copyEpoch = copyEpochRef.current
const accepted = await writeClipboard(text)
copyingRef.current = false
const card = cardRef.current
if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return
const height = card.offsetHeight
copyHeightRef.current = height > 0 ? height : null
setCopied(true)
copyTimerRef.current = setTimeout(clearCopied, 1000)
}
const copyable = copyText !== undefined
const card = open && pos !== null && (
<div ref={cardRef} className={css.card} style={pos}>
{content}
<div
ref={cardRef}
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
role={copyable ? 'button' : undefined}
tabIndex={copyable ? 0 : undefined}
aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
onClick={copyable
? (e) => {
const selection = window.getSelection()
if (selection !== null && !selection.isCollapsed) {
for (let i = 0; i < selection.rangeCount; i += 1) {
if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return
}
}
void copy(copyText)
}
: undefined}
onKeyDown={copyable
? (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
void copy(copyText)
}
: undefined}
>
{copied ? <span className={css.copied} aria-hidden="true">{copiedLabel}</span> : content}
</div>
)
@@ -91,21 +183,33 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
className={css.root}
onPointerEnter={() => {
if (disabled) return
// Coming back inside during the grace (the gap, or the card itself)
// keeps the current card rather than restarting the dwell.
cancelClose()
if (open) return
clearTimer()
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
}}
onPointerLeave={() => {
clearTimer()
setOpen(false)
// Leaving a closed card schedules a no-op close; only arm while
// open, matching Menu's shape.
if (open) armClose()
}}
// Any press inside the anchor (row click, menu trigger) dismisses the
// A press inside the anchor (row click, menu trigger) dismisses the
// card immediately, without waiting for the owner to flip `disabled`.
onPointerDownCapture={() => {
// Capture presses reach this handler from the card too — it is a React
// child of the wrapper — but a press there starts a selection, so the
// card must stay mounted under it (and the browser's click with it).
onPointerDownCapture={(e) => {
if (cardRef.current?.contains(e.target as Node)) return
clearTimer()
setOpen(false)
cancelClose()
close()
}}
>
{anchor}
{open && copyable && <span className={css.status} role="status">{copied ? copiedLabel : ''}</span>}
{card !== false && createPortal(card, document.body)}
</span>
)

View File

@@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCheckOutline16 } from './icons/index.tsx'
import { usePointerGrace } from './pointer-grace.ts'
import css from './Menu.module.css'
/** Selectable row (optionally with a nested submenu). */
@@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* from the anchor rect (repositions on scroll/resize while open). Use when an
* ancestor's overflow clipping would crop the in-place list; default false
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.closeOnPointerLeave - close the list once the pointer has left
* both trigger and list for the pointer grace (default false keeps it open
* until outside click/Escape/selection). The grace makes the 4px trigger->list
* gap and a brief overshoot survivable; coming back cancels the close.
* @param props.compact - use reduced menu typography and spacing.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
@@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
const listRef = useRef<HTMLDivElement>(null)
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
// Portal mode: fixed-position the list from the anchor rect before paint;
// track the anchor while open (capture-phase scroll catches nested panes).
@@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
}
}, [open, onClose])
// A close from selection/Escape/outside click outruns a pending grace close;
// left armed it would shut a list reopened inside the grace window. Its own
// effect, not the listener effect above: that one re-runs on every `onClose`
// identity change and would cancel the grace mid-transit.
useEffect(() => {
if (!open) cancelClose()
}, [open, cancelClose])
// The submenu card is absolutely positioned outside the list box; the
// scroll clip would crop it, so only submenu-free menus get the height cap.
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
@@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without
// this stop, an item click re-fires the anchor row's own onClick
// (open/toggle) after onSelect.
@@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
</div>
)
// Pointer-leave dismissal watches the WRAPPER, not the list: React's
// enter/leave traversal runs over the React tree, so trigger and portaled
// list are one region here. Aiming back at the trigger, or crossing the 4px
// gap between them, therefore never counts as leaving.
return (
<span ref={rootRef} className={clsx(css.root, className)}>
<span
ref={rootRef}
className={clsx(css.root, className)}
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
</span>

View File

@@ -24,14 +24,14 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
export type {
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
} from './SearchBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'

View File

@@ -0,0 +1,53 @@
// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing
// Menu). Both float free of their anchor, so the pointer has to cross ground
// that belongs to neither on its way in; closing on the first pointerleave
// makes the popup unreachable. The grace turns that transit into a cancelable
// pending close.
import { useCallback, useEffect, useRef } from 'react'
/**
* Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap
* (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a
* popup lingering once the pointer has genuinely moved on.
*/
export const POINTER_GRACE_MS = 200
/** Cancelable delayed close for a pointer-dismissed popup. */
export interface PointerGrace {
/** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */
arm: () => void
/** Abort a pending close (the pointer came back). */
cancel: () => void
}
/**
* Delay a pointer-dismissed popup's close so the pointer can cross the gap
* between anchor and popup. A pending close is dropped on unmount.
* @param close - runs when the grace elapses with no re-entry; read at fire
* time, so callers may pass a fresh closure each render.
* @returns the {@link PointerGrace} handle.
*/
export function usePointerGrace(close: () => void): PointerGrace {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const closeRef = useRef(close)
closeRef.current = close
const cancel = useCallback(() => {
if (timerRef.current === null) return
clearTimeout(timerRef.current)
timerRef.current = null
}, [])
const arm = useCallback(() => {
cancel()
timerRef.current = setTimeout(() => {
timerRef.current = null
closeRef.current()
}, POINTER_GRACE_MS)
}, [cancel])
useEffect(() => cancel, [cancel])
return { arm, cancel }
}

View File

@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
afterEach(cleanup)
@@ -160,16 +161,77 @@ describe('Menu', () => {
expect(onSelect).toHaveBeenCalledWith('del')
})
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
// Still open through the grace: the pointer may be crossing the gap.
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
expect(onClose).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('coming back inside the grace keeps the list open (trigger and list are one region)', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('a close from selection disarms the pending grace close', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
// The owner closes for its own reason (selection/Escape) mid-grace; the
// armed timer must not survive to shut a list reopened right after.
rerender(
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('leaving a closed list arms nothing', () => {
vi.useFakeTimers()
try {
const onClose = vi.fn()
render(
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(onClose).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {

View File

@@ -2,6 +2,7 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
afterEach(cleanup)
beforeEach(() => { vi.useFakeTimers() })
@@ -16,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
})
}
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
function mount(props: {
openDelayMs?: number
disabled?: boolean
copyText?: string
copyLabel?: string
copiedLabel?: string
} = {}) {
const view = render(
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
)
@@ -25,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
}
/** Install the async browser clipboard and restore its prior host shape. */
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return () => {
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', prior)
}
}
describe('HoverCard', () => {
it('opens after the dwell delay, positioned right of the anchor', () => {
const { wrapper } = mount()
@@ -54,18 +74,47 @@ describe('HoverCard', () => {
expect(screen.queryByText('card body')).toBeNull()
})
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
expect(screen.getByText('card body')).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('reaching the card inside the grace keeps it open without restarting the dwell', () => {
// The portaled card is a React child of the wrapper, so the pointer
// arriving on it re-enters the wrapper — the gesture the 8px anchor gap
// used to make impossible.
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('re-entering while open does not queue a second dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerEnter(wrapper)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
// A dwell restarted by the redundant enter would reopen the card here.
act(() => { vi.advanceTimersByTime(500) })
expect(screen.queryByText('card body')).toBeNull()
})
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
@@ -78,6 +127,237 @@ describe('HoverCard', () => {
expect(screen.queryByText('card body')).toBeNull()
})
it('a press on the card starts a selection instead of dismissing it', () => {
// The card is a React child of the wrapper, so capture-phase presses on
// it reach the wrapper's dismissal handler too; they must not close it,
// or the first pointerdown of a text-selection drag would kill the card.
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerDown(screen.getByText('card body'))
// Still mounted after a grace's worth of time: no close was armed either.
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('keeps a completed card selection instead of treating its click as copy', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
const selection = window.getSelection()
if (selection === null) throw new Error('jsdom selection API unavailable')
try {
const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button', { name: 'Copy: card body' })
const selectedText = screen.getByText('card body')
const cardRange = document.createRange()
cardRange.selectNodeContents(selectedText)
selection.addRange(cardRange)
await act(async () => { fireEvent.click(card) })
expect(writeText).not.toHaveBeenCalled()
expect(selection.toString()).toBe('card body')
expect(screen.getByText('card body')).toBeTruthy()
// Firefox supports multiple selection ranges: any range intersecting
// this card wins, not only the first.
selection.removeAllRanges()
const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: false,
rangeCount: 2,
getRangeAt: vi.fn((index: number) => ({
intersectsNode: () => index === 1,
})),
} as unknown as Selection)
await act(async () => { fireEvent.click(card) })
expect(writeText).not.toHaveBeenCalled()
getSelection.mockRestore()
// A non-collapsed selection elsewhere does not block this card.
const anchorRange = document.createRange()
anchorRange.selectNodeContents(screen.getByText('row'))
selection.addRange(anchorRange)
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledWith('card body')
} finally {
selection.removeAllRanges()
restoreClipboard()
}
})
it('a press while closed leaves the card closed', () => {
mount()
fireEvent.pointerDown(screen.getByText('row'))
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('copies its configured value and shows success only for the feedback window', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({
copyText: '/full/path',
copyLabel: 'Copy path',
copiedLabel: 'Copied',
})
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
const status = screen.getByRole('status')
expect(status.textContent).toBe('')
expect(card.contains(status)).toBe(false)
Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledWith('/full/path')
expect(status.textContent).toBe('Copied')
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
expect(card.style.minHeight).toBe('96px')
// Repeated activation while feedback is visible neither rewrites nor
// extends the one-second success window.
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledOnce()
act(() => { vi.advanceTimersByTime(999) })
expect(status.textContent).toBe('Copied')
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
expect(card.style.minHeight).toBe('')
expect(status.textContent).toBe('')
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('supports button keys and ignores unrelated keys', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button')
fireEvent.keyDown(card, { key: 'Escape' })
expect(writeText).not.toHaveBeenCalled()
await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) })
expect(writeText).toHaveBeenCalledOnce()
act(() => { vi.advanceTimersByTime(1000) })
await act(async () => { fireEvent.keyDown(card, { key: ' ' }) })
expect(writeText).toHaveBeenCalledTimes(2)
} finally {
restoreClipboard()
}
})
it('keeps its content when the clipboard rejects the write', async () => {
const writeText = vi.fn(async () => { throw new Error('denied') })
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(screen.queryByText('Copied')).toBeNull()
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('unmount clears copied feedback', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { view, wrapper } = mount({ copyText: 'value' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(vi.getTimerCount()).toBe(1)
view.unmount()
expect(vi.getTimerCount()).toBe(0)
} finally {
restoreClipboard()
}
})
it('clears copied feedback when the card closes', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(screen.getByRole('status').textContent).toBe('Copied')
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByText('Copied')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('does not create copied feedback after an in-flight write unmounts', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { view, wrapper } = mount({ copyText: 'value' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByRole('button'))
expect(writeText).toHaveBeenCalledOnce()
view.unmount()
await act(async () => { acceptWrite?.() })
expect(vi.getTimerCount()).toBe(0)
} finally {
restoreClipboard()
}
})
it('does not restore copied feedback after an in-flight card closes', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByRole('button'))
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { acceptWrite?.() })
expect(vi.getTimerCount()).toBe(0)
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('coalesces activations while the clipboard write is in flight', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button')
fireEvent.click(card)
fireEvent.click(card)
expect(writeText).toHaveBeenCalledOnce()
await act(async () => { acceptWrite?.() })
expect(screen.getByRole('status').textContent).toBe('Copied')
} finally {
restoreClipboard()
}
})
it('disabled suppresses opening entirely', () => {
const { wrapper } = mount({ disabled: true })
fireEvent.pointerEnter(wrapper)
@@ -135,6 +415,7 @@ describe('HoverCard', () => {
expect(card.style.left).toBe('308px')
expect(card.style.top).toBe('90px')
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByText('card body')).toBeNull()
})

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-trajectory/README.md
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
README.zh.md: 6ddc32f2f27c93f8ccc80b3d9b31d56d3cf4dd94
README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad
README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
## 模型体验

View File

@@ -104,7 +104,8 @@ const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
}
interface TableRecord {
turn: number
turn: number | null
section: number
group: string
groupStart: boolean
turnStart: boolean
@@ -146,7 +147,8 @@ interface ToolCallTextParts {
}
interface SelectedRequest {
turn: number
turn: number | null
section: number
number: number
group: string
}
@@ -320,15 +322,12 @@ export interface TrajectoryTableProps {
onInspectApplied?: (() => void) | undefined
}
/** One request identity paired with its session-global number. */
export interface TrajectoryRequestNumber {
/** Request-inspector fields shared by ordinary generation and compaction. */
interface TrajectoryRequestNumberBase {
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
seq?: number
turn: number
step: number
group: string
number: number
purpose?: 'compaction'
status?: 'complete' | 'running' | 'error'
startedAt?: number
completedAt?: number | null
@@ -344,6 +343,20 @@ export interface TrajectoryRequestNumber {
cumulativeUsage?: TrajectoryUsage
}
/** One purpose-discriminated request identity paired with its session-global number. */
export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & (
| {
purpose?: 'assistant'
turn: number
step: number
}
| {
purpose: 'compaction'
turn: number | null
step: 0
}
)
/** Disjoint provider token buckets for one request or a session prefix. */
export interface TrajectoryUsage {
input?: number
@@ -354,17 +367,18 @@ export interface TrajectoryUsage {
}
function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
return turns.flatMap((turn) => {
let firstInTurn = true
return turns.flatMap((turn, section) => {
let firstInSection = true
const records = turn.groups.flatMap((group) => {
return group.cells.map((cell, index) => {
const turnStart = firstInTurn
const turnStart = firstInSection
&& cell.requestOnly !== true
&& cell.kind !== 'system'
&& cell.kind !== 'compacted'
if (turnStart) firstInTurn = false
&& (cell.kind !== 'compacted' || turn.turn === null)
if (turnStart) firstInSection = false
return {
turn: turn.turn,
section,
group: group.title,
groupStart: index === 0,
turnStart,
@@ -388,18 +402,18 @@ function filterRecords(
record.cell.requestOnly !== true && matches.has(record.cell.index),
)
.map(record => ({ ...record, groupStart: false, turnStart: false, turnEnd: false }))
const startedTurns = new Set<number>()
const startedSections = new Set<number>()
for (const [index, record] of filtered.entries()) {
const previous = filtered[index - 1]
const next = filtered[index + 1]
record.groupStart = previous === undefined
|| previous.turn !== record.turn
|| previous.section !== record.section
|| previous.group !== record.group
record.turnStart = !startedTurns.has(record.turn)
record.turnStart = !startedSections.has(record.section)
&& record.cell.kind !== 'system'
&& record.cell.kind !== 'compacted'
if (record.turnStart) startedTurns.add(record.turn)
record.turnEnd = next === undefined || next.turn !== record.turn
&& (record.cell.kind !== 'compacted' || record.turn === null)
if (record.turnStart) startedSections.add(record.section)
record.turnEnd = next === undefined || next.section !== record.section
}
return filtered
}
@@ -410,10 +424,14 @@ function requestStep(group: string): number | undefined {
return Number.isInteger(value) && value > 0 ? value : undefined
}
function requestKey(turn: number, group: string): string {
function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}`
}
function sectionLabel(turn: number | null): string {
return turn === null ? 'Between turns' : `Turn ${turn}`
}
function indexRequestNumbers(
records: readonly TableRecord[],
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
@@ -455,12 +473,13 @@ function collapseTurnRecords(
if (collapsedTurns.size === 0) return [...records]
const recordsByTurn = new Map<number, TableRecord[]>()
for (const record of records) {
if (record.turn === null) continue
const turnRecords = recordsByTurn.get(record.turn) ?? []
turnRecords.push(record)
recordsByTurn.set(record.turn, turnRecords)
}
return records.flatMap((record) => {
if (!collapsedTurns.has(record.turn)) return [record]
if (record.turn === null || !collapsedTurns.has(record.turn)) return [record]
const turnRecords = recordsByTurn.get(record.turn) ?? [record]
if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
const contentRecords = turnRecords.filter(candidate =>
@@ -1537,6 +1556,7 @@ export function TrajectoryTable({
? []
: allRecords.filter(record =>
record.turn === selectedRequest.turn
&& record.section === selectedRequest.section
&& record.group === selectedRequest.group,
)
const selectedRequestAssistant = selectedRequestRecords.find(
@@ -1588,7 +1608,8 @@ export function TrajectoryTable({
const selectedRequestCumulativeUsage =
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest?.turn ?? selected?.turn
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected)
@@ -1604,6 +1625,7 @@ export function TrajectoryTable({
selected !== undefined && selectedAssistantRequest !== undefined
? {
turn: selected.turn,
section: selected.section,
number: selectedAssistantRequest,
group: selected.group,
}
@@ -1664,7 +1686,7 @@ export function TrajectoryTable({
const openRecordSummary = (target: TableRecord) => {
const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index)
if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
if (target.turn !== null && collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') {
for (let i = targetAt - 1; i >= 0; i--) {
const candidate = allRecords[i]
@@ -1739,7 +1761,7 @@ export function TrajectoryTable({
&& record.cell.index === allRecords[0]?.cell.index
const request = record.groupStart
&& !isCollapsedSummary
&& !collapsedTurns.has(record.turn)
&& (record.turn === null || !collapsedTurns.has(record.turn))
? requestNumbers.get(requestKey(record.turn, record.group))
: undefined
const requestInfo = request === undefined
@@ -1750,7 +1772,11 @@ export function TrajectoryTable({
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.section === record.section
&& selectedRequest.number === request
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
@@ -1780,13 +1806,14 @@ export function TrajectoryTable({
? undefined
: isCollapsedSummary
? () => {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary || isRequestOnly) return
if (collapsedTurns.has(record.turn)) {
if (record.turn !== null && collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
return
@@ -1800,6 +1827,7 @@ export function TrajectoryTable({
return
}
if (!record.turnStart) return
if (record.turn === null) return
if (allRecords.filter(candidate =>
candidate.turn === record.turn
&& candidate.cell.requestOnly !== true
@@ -1812,8 +1840,9 @@ export function TrajectoryTable({
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
return
}
selectRecord(record.cell.index)
@@ -1833,6 +1862,7 @@ export function TrajectoryTable({
event.stopPropagation()
selectRequest({
turn: record.turn,
section: record.section,
number: request,
group: record.group,
})
@@ -1840,7 +1870,9 @@ export function TrajectoryTable({
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{activeTurn === record.turn && !isInitialSystem && (
{record.turn !== null
&& activeTurn === record.turn
&& !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
@@ -1850,17 +1882,23 @@ export function TrajectoryTable({
&& !isRequestOnly
&& record.turnStart && (
<span
className={activeTurn === record.turn
className={sectionActive
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={`Turn ${record.turn}`}
aria-label={sectionLabel(record.turn)}
>
<span className={css.turnLabelFull} aria-hidden="true">
Turn {record.turn}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
{record.turn === null
? sectionLabel(record.turn)
: (
<>
<span className={css.turnLabelFull} aria-hidden="true">
{sectionLabel(record.turn)}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</>
)}
</span>
)}
<div className={css.eventInner}>
@@ -2049,8 +2087,8 @@ export function TrajectoryTable({
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
? `Compaction · Turn ${selectedRequest.turn}`
: `Turn ${selectedRequest.turn}`}
? `Compaction · ${sectionLabel(selectedRequest.turn)}`
: sectionLabel(selectedRequest.turn)}
</span>
</>
)
@@ -2081,8 +2119,8 @@ export function TrajectoryTable({
</span>
<span className={css.detailsLocation}>
{selected.cell.kind === 'compacted'
? `Turn ${selected.turn}`
: `Turn ${selected.turn} · ${selected.group}`}
? sectionLabel(selected.turn)
: `${sectionLabel(selected.turn)} · ${selected.group}`}
</span>
</>
)}

View File

@@ -431,9 +431,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
style={projectedDomainStyle}
>
{model.turnBoundaries
.slice(1)
.filter(boundary =>
boundary.time >= domainStart
boundary.time > model.start
&& boundary.time >= domainStart
&& boundary.time <= domainStart + domainDuration)
.map(boundary => (
<span

View File

@@ -102,7 +102,7 @@ function searchMatches(
...(cell.outputBlocks ?? []),
]
const text = [
`turn ${turn.turn}`,
turn.turn === null ? 'between turns' : `turn ${turn.turn}`,
group.title,
cell.kind,
cell.kind === 'message' ? 'assistant' : undefined,
@@ -383,13 +383,15 @@ export function TrajectoryView({
const collapsibleTurnIds = useMemo(
() => turns
.filter(turn =>
turn.turn !== null
&&
turn.groups.reduce(
(count, group) =>
count + group.cells.filter(cell =>
cell.requestOnly !== true && cell.kind !== 'system').length,
0,
) > 1)
.map(turn => turn.turn),
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
[turns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0

View File

@@ -107,6 +107,8 @@ export function trajectoryBranchContainsRequest(
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.purpose === 'compaction'
&&
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)

View File

@@ -25,9 +25,9 @@ export interface TrajectoryGroupModel {
cells: readonly TrajectoryCellProps[]
}
/** One sticky-turn section. */
/** One sticky turn, or a standalone compaction section between turns. */
export interface TrajectoryTurnModel {
turn: number
turn: number | null
groups: readonly TrajectoryGroupModel[]
}
@@ -67,6 +67,9 @@ interface TurnBucket {
groups: LaidGroup[]
}
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
@@ -85,18 +88,18 @@ type OrderedLayoutEntry =
| {
kind: 'compaction'
seq: number
request: RequestView
request: CompactionRequestView
}
| {
kind: 'system'
seq: number
request: RequestView
request: AssistantRequestView
change: RequestPromptChange
}
| {
kind: 'request'
seq: number
request: RequestView
request: AssistantRequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
@@ -141,6 +144,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (startedAt !== null) callStartById.set(call.callId, startedAt)
}
const turns = new Map<number, TurnBucket>()
const standaloneCompactions: TurnBucket[] = []
let index = 0
let prevAbsTime: number | null = null
let lastAssistantTurn: number | null = null
@@ -196,13 +200,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
nodeIndex,
})),
...requests
.filter(request => request.purpose === 'compaction')
.filter((request): request is CompactionRequestView =>
request.purpose === 'compaction')
.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
...requests.flatMap(request => request.purpose !== 'assistant'
|| request.promptChange === undefined
|| request.prompt === undefined
? []
: [{
kind: 'system' as const,
@@ -211,7 +218,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
change: request.promptChange,
}]),
...requests
.filter(request => request.purpose === 'assistant')
.filter((request): request is AssistantRequestView =>
request.purpose === 'assistant')
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
@@ -302,13 +310,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(request.startedAt),
}
attachUsage(cell, request.usage as UsageLike | undefined)
bucket(request.turn).groups.push({
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
const compaction: TurnBucket = {
groups: [{
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
}],
}],
})
}
if (request.turn === null) standaloneCompactions.push(compaction)
else bucket(request.turn).groups.push(...compaction.groups)
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
continue
}
@@ -451,15 +463,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
turns.set(1, first)
}
for (const entry of turns.values()) {
for (const entry of [...turns.values(), ...standaloneCompactions]) {
for (const group of entry.groups) {
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
}
}
return [...turns.entries()]
.sort(([a], [b]) => a - b)
.map(([turn, entry]) => toTurnModel(turn, entry))
return [
...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry)),
...standaloneCompactions.map(entry => toTurnModel(null, entry)),
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
}
function attachToolSchema(
@@ -473,7 +486,7 @@ function attachToolSchema(
}
function toTurnModel(
turn: number,
turn: number | null,
entry: TurnBucket,
): TrajectoryTurnModel {
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
@@ -487,6 +500,14 @@ function toTurnModel(
return { turn, groups }
}
/** Chronological section position from the fold's monotonically assigned cell indexes. */
function firstCellIndex(turn: TrajectoryTurnModel): number {
return Math.min(
...turn.groups.flatMap(group => group.cells.map(cell => cell.index)),
Number.POSITIVE_INFINITY,
)
}
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
function groupDescription(laid: readonly LaidCell[]): string | undefined {
const parts: string[] = []

View File

@@ -87,10 +87,12 @@ export function deriveTrajectoryTimeline(
group.cells.filter(cell => cell.requestOnly !== true),
)
if (cells.length === 0) continue
turnBoundaries.push({
turn: turn.turn,
time: spans.length,
})
if (turn.turn !== null) {
turnBoundaries.push({
turn: turn.turn,
time: spans.length,
})
}
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
start: spans.length + offset,
end: spans.length + offset + 1,
@@ -150,10 +152,12 @@ function deriveTimedTimeline(
start: span.start - removedUserIdle,
end: (actualDuration ? span.end : span.start) - removedUserIdle,
})))
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
if (turn.turn !== null) {
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
}
previousTurnEnd = previousTurnEnd === null
? turnEnd
: Math.max(previousTurnEnd, turnEnd)

View File

@@ -38,17 +38,22 @@ function request(
resultSeq?: number,
replacementSeq?: number,
): RequestView {
return {
purpose,
const base = {
startSeq,
turn: 1,
step: purpose === 'assistant' ? 1 : 0,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete',
status: 'complete' as const,
...(resultSeq === undefined ? {} : { resultSeq }),
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
return purpose === 'assistant'
? { ...base, purpose, turn: 1, step: 1 }
: {
...base,
purpose,
turn: 1,
step: 0,
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {

View File

@@ -5,7 +5,9 @@
*/
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
@@ -161,6 +163,49 @@ describe('deriveTrajectoryLayout', () => {
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
})
it('places standalone compaction chronologically in its own between-turn section', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'before compaction' }],
},
{ kind: 'user', seq: 5, time: 5_000, content: [{ type: 'text', text: 'second' }], source: null },
{
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'after compaction' }],
},
] as unknown as ConversationSnapshot['nodes']
const compaction: RequestView = {
purpose: 'compaction',
startSeq: 3,
turn: null,
step: 0,
startedAt: 3_000,
completedAt: 4_000,
status: 'complete',
summary: [{ type: 'text', text: 'standalone summary' }],
}
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: null,
runningCalls: [],
requests: [compaction],
})
expect(turns.map(turn => turn.turn)).toEqual([1, null, 2])
expect(turns[1]?.groups).toMatchObject([{
title: 'Compaction 3',
cells: [{
kind: 'compacted',
sourceSeq: 3,
text: 'standalone summary',
}],
}])
})
it('keeps usage and a meaningful summary when assistant has no text block', () => {
const nodes = [
{

View File

@@ -285,6 +285,105 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
})
it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'before' }],
},
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
{
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'after' }],
},
] as unknown as ConversationSnapshot['nodes']
const compaction: RequestView = {
purpose: 'compaction',
startSeq: 3,
turn: null,
step: 0,
startedAt: 3_000,
completedAt: 4_000,
status: 'complete',
summary: [{ type: 'text', text: 'standalone summary' }],
}
const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
const view = mount(b.slots, nodes)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('Between turns')).toBeTruthy()
expect(view.container.textContent).not.toContain('Turn null')
fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' }))
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
expect(view.container.textContent).not.toContain('Turn null')
})
it('activates only the selected standalone compaction section', async () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'before first compaction' }],
},
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
{
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'between compactions' }],
},
{ kind: 'user', seq: 9, time: 9_000, content: [], source: null },
{
kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1,
blocks: [{ kind: 'text', text: 'after second compaction' }],
},
] as unknown as ConversationSnapshot['nodes']
const compactions: RequestView[] = [
{
purpose: 'compaction',
startSeq: 3,
turn: null,
step: 0,
startedAt: 3_000,
completedAt: 4_000,
status: 'complete',
summary: [{ type: 'text', text: 'first standalone summary' }],
},
{
purpose: 'compaction',
startSeq: 7,
turn: null,
step: 0,
startedAt: 7_000,
completedAt: 8_000,
status: 'complete',
summary: [{ type: 'text', text: 'second standalone summary' }],
},
]
const b = await bench(historySnapshot(nodes, { requests: compactions }))
mount(b.slots, nodes)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' })
const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' })
const firstSection = firstRequest.closest('tr')?.querySelector('span')
const secondSection = secondRequest.closest('tr')?.querySelector('span')
expect(firstSection?.textContent).toBe('Between turns')
expect(secondSection?.textContent).toBe('Between turns')
fireEvent.click(firstRequest)
expect(firstSection?.className).toMatch(/turnLabelActive/)
expect(secondSection?.className).not.toMatch(/turnLabelActive/)
expect(screen.getByText('Request #2')).toBeTruthy()
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
fireEvent.click(secondRequest)
expect(firstSection?.className).not.toMatch(/turnLabelActive/)
expect(secondSection?.className).toMatch(/turnLabelActive/)
expect(screen.getByText('Request #4')).toBeTruthy()
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
})
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
const b = await bench()
mount(b.slots)
@@ -558,6 +657,44 @@ describe('timeline projection', () => {
})
})
it('projects between-turn compaction without inventing a turn boundary', () => {
const withStandaloneCompaction = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
}],
},
{
turn: null,
groups: [{
title: 'Compaction 3',
cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
spans: [
{ index: 1, start: 0, end: 1 },
{ index: 2, start: 1, end: 2 },
{ index: 3, start: 2, end: 3 },
],
turnBoundaries: [
{ turn: 1, time: 0 },
{ turn: 2, time: 2 },
],
})
})
it('empty inputs produce no model and the standalone view reports its empty form', () => {
expect(deriveTrajectoryTimeline([])).toBeNull()
render(createElement(

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-workspace/README.md
README.md: cc73214a281c6950acf8846f0bae3214c8726934
README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534
README.md: 8ebc55d7ad202622bbbc50b3d91a6985a1be29b9
README.zh.md: d13a8941889654f05b8cafd3f6f4251a13e0b694

View File

@@ -2,11 +2,13 @@
English | [中文](README.zh.md)
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow.
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow.
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write.
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建流程。
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和添加流程。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。
Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。

View File

@@ -1,11 +1,13 @@
/**
* The workspace/session browsing region filling the sidebar shell's
* `sidebar.workspaces` hole: section header (title + group-by + new
* `sidebar.workspaces` hole: section header (title + group-by + add
* workspace), search, the grouped tree or flat list, and the workspace
* dialogs. Wide state renders the full browser; rail state renders the two
* region icons (search / new workspace), each requesting shell expansion
* through the owner share. The picker menu and create dialogs live in
* WorkspacePicker (same package — direct composition, no slot between them).
* region icons (search / add workspace), each requesting shell expansion
* through the owner share. Adding is the header button's one action, so it
* raises the directory flow with no menu in between; the flow and its error
* dialog live in WorkspacePicker (same package — direct composition, no slot
* between them).
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -20,7 +22,7 @@ import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode } from './tree.ts'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/**
@@ -358,6 +360,9 @@ export function WorkspaceBrowser({
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
// Live occupancy of this surface's directory-flow hole (the same source the
// flow reads): a composition without a picking affordance can add nothing.
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
const groupBy = useStore(s => s.groupBy)
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
@@ -541,21 +546,26 @@ export function WorkspaceBrowser({
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
<Tooltip label={t('workspace.new')} disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('create.confirm')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
{/* Adding is the button's one action, so a composition with no
picking affordance has nothing to offer here: the region hides the
button rather than leaving a dead one in the header. */}
{directoryFlowAvailable && (
<Tooltip label={t('workspace.add')} disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('workspace.add')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
)}
{/* Add flow + its error dialog (same package — direct composition). */}
<WorkspacePickFlow
t={t}
open={wsPickerOpen}
anchorRef={wsPlusRef}
@@ -563,7 +573,7 @@ export function WorkspaceBrowser({
createWorkspace={createWorkspace}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
addOnly
side="right"
onPick={(workspaceId) => {
setWsPickerOpen(false)

View File

@@ -1,35 +1,10 @@
/* Modal form styles mirror the empty state's path/create modals (same figma
* dialog family: field h44, r22, hairline border, pad 14/7) so the two
* entries stay visually identical. */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
/* The adoption error dialog's footer and message styles; the dialog itself is
* the shared Modal (same figma dialog family as the browser's own dialogs). */
.modalAction {
min-width: 72px;
}
.modalError,
.modalStatus,
.menuStatus {
margin-top: 8px;
font-size: 12px;
@@ -40,7 +15,6 @@
color: var(--dsw-alias-state-error-primary);
}
.modalStatus,
.menuStatus {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -1,13 +1,15 @@
/**
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration. Directory picking itself lives in the composed flow
* package's slot occupant (see the contract module doc): this core only
* opens the flow, adopts the picked path, and owns the error surface.
* Workspace pick/add flow. WorkspacePickFlow is the reusable core (menu +
* path error dialog) consumed directly by WorkspaceBrowser (same package) and
* wrapped by WorkspacePicker for the conversation empty-state slot
* registration. Directory picking itself lives in the composed flow package's
* slot occupant (see the contract module doc): this core only opens the flow,
* adopts the picked path, and owns the error surface. Adding a workspace has
* exactly one route — pick a host directory, new or existing — because the
* occupant's own create-folder affordance already covers creating one.
*/
import type { ReactNode, RefObject } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,13 +21,10 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'create' | 'folder-error' | null
const ADD_WORKSPACE = '::add-workspace'
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
export interface WorkspacePickFlowProps {
/** The standard locale seat, forwarded by whichever slot entry hosts the flow. */
t: WorkspacePickerProps['t']
/** Popover visibility (anchor button toggle state, owner-local). */
@@ -34,9 +33,9 @@ export interface WorkspaceCreateFlowProps {
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Selector hook over the workspace list (framework standard hook). */
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
/** Adopt a picked host directory as a real Workspace. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */
useDirectoryFlow: SnapshotSelectorHook<boolean>
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
@@ -44,8 +43,8 @@ export interface WorkspaceCreateFlowProps {
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
/** Only show create actions (open folder / create new), hide existing workspaces. */
createOnly?: boolean
/** Only offer the add action, hide existing workspaces. */
addOnly?: boolean
/** Menu opening direction relative to the anchor. */
side?: 'bottom' | 'top' | 'right'
/** Currently active workspace (trailing check in the picker list). */
@@ -53,11 +52,11 @@ export interface WorkspaceCreateFlowProps {
}
/**
* Render the pick menu plus the two create dialogs.
* Render the pick menu plus the adoption error dialog.
* @param props - owner-controlled flow props.
* @returns menu + dialog elements.
*/
export function WorkspaceCreateFlow({
export function WorkspacePickFlow({
t,
open,
anchorRef,
@@ -67,32 +66,26 @@ export function WorkspaceCreateFlow({
renderDirectoryFlow,
onPick,
onClose,
createOnly = false,
addOnly = false,
side = 'bottom',
selectedId,
}: WorkspaceCreateFlowProps) {
}: WorkspacePickFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
() => anchorRef?.current?.getBoundingClientRect() ?? null,
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [errorOpen, setErrorOpen] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
// One picking interaction at a time: while the flow is open (native chooser
// pending, browse dialog up) or its pick is being adopted, every other
// menu action stays disabled — a late outcome must not race a concurrent
// selection or creation.
// selection or adoption.
const flowBusy = flowOpen || pickingFolder
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). The
@@ -107,27 +100,27 @@ export function WorkspaceCreateFlow({
useEffect(() => {
if (flowOpen && !flowAvailable) setFlowOpen(false)
}, [flowOpen, flowAvailable])
const createEntries: MenuEntry[] = [
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: t('menu.openFolder'), icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: t('menu.createWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
const pinCreate = !createOnly && workspaces.length > 0
const items: MenuEntry[] = pinCreate
const addEntries: MenuEntry[] = flowAvailable
? [{ id: ADD_WORKSPACE, label: t('menu.addWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy }]
: []
// With workspaces listed, the add action pins below the scroll region
// (divider + always visible); otherwise it IS the menu.
const pinAdd = !addOnly && workspaces.length > 0
const items: MenuEntry[] = pinAdd
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: flowBusy,
}))
: createEntries
: addEntries
// Nothing listed and nothing to add with (a composition that mounts this
// package without any directory-picker): an empty popover would claim a
// choice that does not exist, so the anchor gesture shows nothing at all.
const menuIsEmpty = items.length === 0
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setErrorOpen(false)
setModalError(null)
}
@@ -143,16 +136,32 @@ export function WorkspaceCreateFlow({
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setModalKind('folder-error')
setErrorOpen(true)
})
const openLocalFolder = (): void => {
const openDirectoryFlow = useCallback((): void => {
onClose()
setModalKind(null)
setErrorOpen(false)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}
}, [onClose])
// A menu exists to disambiguate between targets. With no workspaces listed
// and the add action the only entry left, the anchor gesture IS that action:
// a one-row popover would cost a click and offer nothing to choose between.
// The owner's open request is consumed the same way selecting the entry
// would consume it (close the popover, raise the flow). An empty list is
// only final once the baseline lands — until then the menu stays up with its
// loading status instead of jumping into a flow the arriving list would have
// made unnecessary; the add-only surface lists nothing and never waits.
const listSettled = addOnly || workspaceSnapshot.phase === 'ready'
const addIsTheOnlyEntry = !pinAdd && listSettled && addEntries.length === 1
// `flowBusy` gates this exactly as it disables the equivalent menu entry: a
// pick still being adopted owns the surface until it settles.
useEffect(() => {
if (open && addIsTheOnlyEntry && !flowBusy) openDirectoryFlow()
}, [open, addIsTheOnlyEntry, flowBusy, openDirectoryFlow])
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
const flowOwner: DirectoryFlowOwnerProps = {
@@ -167,53 +176,25 @@ export function WorkspaceCreateFlow({
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setModalKind('folder-error')
setErrorOpen(true)
},
}
const handleSelect = (id: string): void => {
if (id === OPEN_LOCAL_FOLDER) {
openLocalFolder()
return
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('')
setModalError(null)
setModalKind('create')
if (id === ADD_WORKSPACE) {
openDirectoryFlow()
return
}
onPick(id as WorkspaceId)
}
const create = (input: { name: string } | { path: string }): void => {
if (creating) return
setCreating(true)
setModalError(null)
void createWorkspace(input).then((workspace) => {
setCreating(false)
setModalKind(null)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason)
setModalError(`Workspace creation failed: ${message}`)
setCreating(false)
})
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
}
}
return (
<>
<Menu
open={open}
open={open && !addIsTheOnlyEntry && !menuIsEmpty}
anchor={null}
items={items}
{...pinCreate ? { footer: createEntries } : {}}
{...pinAdd ? { footer: addEntries } : {}}
selectedId={selectedId}
onSelect={handleSelect}
onClose={onClose}
@@ -221,10 +202,10 @@ export function WorkspaceCreateFlow({
portal
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
open={errorOpen}
onClose={closeModal}
closeLabel={t('close')}
title={folderConflict ? t('conflict.title') : t('folderError.title')}
@@ -233,7 +214,7 @@ export function WorkspaceCreateFlow({
<Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
{/* Retrying needs an occupant to serve the flow; without one the
* button would open a flow nobody can answer or cancel. */}
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>{t('folderError.retry')}</Button>
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openDirectoryFlow}>{t('folderError.retry')}</Button>
</>
)}
>
@@ -243,49 +224,6 @@ export function WorkspaceCreateFlow({
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
onClose={closeModal}
closeLabel={t('close')}
title={t('menu.createWorkspace')}
description={t('create.desc')}
footer={(
<>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>{t('cancel')}</Button>
<Button
variant="primary"
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
{t('create.confirm')}
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
placeholder={t('field.workspaceName')}
aria-label={t('create.name.aria')}
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
confirmCreate()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">{t('create.pending')}</div>}
{duplicateWorkspaceName && (
<div className={css.modalError} role="alert">{t('conflict.named', { name: normalizedWorkspaceName })}</div>
)}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</>
)
}
@@ -309,7 +247,7 @@ export function WorkspacePicker({
t,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
<WorkspacePickFlow
t={t}
open={open}
anchorRef={anchorRef}

View File

@@ -5,16 +5,19 @@
* the whole browsing region (section header, search, grouped/flat session
* list, workspace dialogs). It registers this package's viewing store and
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
* - WorkspacePicker fills the conversation empty-state hole (menu + error
* dialog shared with the browser).
*
* Each registration also declares one **directory-flow hole** (`single`
* kind): the slot a composed picker package's client half fills with its
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
* menu entry, shown only while the hole is occupied) and the adoption
* browsing dialog. ui-workspace owns the trigger (the "Add workspace…"
* entry, present only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path.
* again); the occupant owns everything between `open` and the picked path,
* including creating a new directory to hand back. That occupant-owned
* creation is why adding a workspace has a single route: an unoccupied hole
* leaves the surface with no add affordance at all.
* Two holes exist because the two menu surfaces are independent slot entries
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
@@ -65,7 +68,7 @@ export type DirectoryFlowSlotName =
* Directory-picking share both trigger surfaces consume. Occupancy rides the
* inject face's reserved `hooks` compartment: the renderer binds the source
* into the `useDirectoryFlow` selector hook, so an empty hole hides the
* "Open local folder…" entry reactively and the surface withdraws an open
* "Add workspace…" entry reactively and the surface withdraws an open
* flow whose occupant unloaded mid-interaction (nobody is left to cancel).
*/
export type DirectoryPickingInjected = {
@@ -125,8 +128,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
* the Host response/changed frame; failures leave the order unchanged.
*/
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
/** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */
@@ -144,8 +147,8 @@ export type WorkspaceBrowserProps =
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Adopt a picked host directory as a real Workspace before targeting a Session. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
}
/**

View File

@@ -1,6 +1,6 @@
/**
* `workspace` namespace dictionaries: the browsing region (section header,
* search, tree rows, dialogs) and the pick/create flow. Runtime failure
* search, tree rows, dialogs) and the pick/add flow. Runtime failure
* messages (wire error strings) pass through untranslated by policy.
*/
@@ -15,7 +15,7 @@ export const zh = {
'groupBy.flat': '单列表',
'empty.none': '暂无会话',
'empty.noMatches': '无匹配结果',
'workspace.new': '新建工作区',
'workspace.add': '添加工作区',
'search.sessions.aria': '搜索会话',
'search.placeholder': '搜索名称、关键词…',
'search.clear': '清除搜索',
@@ -24,18 +24,13 @@ export const zh = {
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
'search.noMatches': '无匹配会话',
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'menu.openFolder': '打开本地文件夹…',
'menu.createWorkspace': '新建工作区',
'menu.addWorkspace': '添加工作区…',
'picker.loading': '正在加载工作区…',
'conflict.title': '已存在同名工作区',
'conflict.hint': '请选择其他名称的文件夹。',
'conflict.named': '已存在名为“{name}”的工作区。',
'folderError.title': '无法打开文件夹',
'folderError.retry': '重新选择',
'create.confirm': '创建工作区',
'create.desc': '该名称将同时用于工作区及其新文件夹。',
'create.name.aria': '新工作区名称',
'create.pending': '正在创建工作区…',
'rename': '重命名',
'rename.workspace.title': '重命名工作区',
'rename.session.title': '重命名会话',
@@ -54,6 +49,7 @@ export const zh = {
'status.running': '进行中',
'status.idle': '空闲',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
'time.now': '刚刚',
'time.minutes': '{n}分钟',
@@ -78,7 +74,7 @@ export const en = {
'groupBy.flat': 'In one list',
'empty.none': 'No sessions yet',
'empty.noMatches': 'No matches',
'workspace.new': 'New Workspace',
'workspace.add': 'Add workspace',
'search.sessions.aria': 'Search sessions',
'search.placeholder': 'Search name, keywords...',
'search.clear': 'Clear search',
@@ -87,18 +83,13 @@ export const en = {
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
'search.noMatches': 'No matching sessions',
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
'menu.openFolder': 'Open local folder…',
'menu.createWorkspace': 'Create a new workspace',
'menu.addWorkspace': 'Add workspace…',
'picker.loading': 'Loading workspaces…',
'conflict.title': 'A workspace with this name already exists',
'conflict.hint': 'Choose a folder with a different name.',
'conflict.named': 'A workspace named “{name}” already exists.',
'folderError.title': 'Couldn’t open folder',
'folderError.retry': 'Choose again',
'create.confirm': 'Create workspace',
'create.desc': 'The name is used for both the workspace and its new folder.',
'create.name.aria': 'New workspace name',
'create.pending': 'Creating workspace…',
'rename': 'Rename',
'rename.workspace.title': 'Rename workspace',
'rename.session.title': 'Rename session',
@@ -117,6 +108,7 @@ export const en = {
'status.running': 'Running',
'status.idle': 'Idle',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',
'time.now': 'now',
'time.minutes': '{n}min',

View File

@@ -158,6 +158,9 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
anchor={ownRow}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} t={t} />}
disabled={menuOpen}
copyText={row.cwd}
copyLabel={t('copy')}
copiedLabel={t('hover.copied')}
/>
)
}
@@ -347,6 +350,9 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} t={t} />}
disabled={menuOpen || drag?.active === true}
copyText={row.blank ? undefined : row.title}
copyLabel={t('copy')}
copiedLabel={t('hover.copied')}
/>
)
}

View File

@@ -107,8 +107,8 @@ describe('ui-workspace apply', () => {
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
await browser.createWorkspace({ path: '/tmp/browser-project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/browser-project' })
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })

View File

@@ -33,6 +33,19 @@ function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
}
}
/** Install the async browser clipboard and restore its prior host shape. */
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return () => {
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', prior)
}
}
const dataTransfer = { effectAllowed: '', dropEffect: '' }
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
@@ -129,8 +142,10 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
it('workspace hover card shows its details and copies the full directory path', async () => {
vi.useFakeTimers()
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
@@ -143,7 +158,11 @@ describe('workspace browser rows', () => {
expect(screen.getAllByText('Project')).toHaveLength(2)
expect(screen.getByText('/projects/project')).toBeTruthy()
expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
expect(writeText).toHaveBeenCalledWith('/projects/project')
expect(screen.getByRole('status').textContent).toBe('已复制')
} finally {
restoreClipboard()
vi.useRealTimers()
}
})
@@ -175,6 +194,7 @@ describe('workspace browser rows', () => {
expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
expect(screen.getByText('空闲')).toBeTruthy()
expect(screen.queryByText('刚刚')).toBeNull()
expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
} finally {
vi.useRealTimers()
}

View File

@@ -500,23 +500,26 @@ describe('WorkspaceBrowser', () => {
}
})
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => {
const expandSidebar = vi.fn()
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
expect(expandSidebar).not.toHaveBeenCalled()
// createOnly: existing workspaces are not listed, only the create actions.
// Adding is the header's only action, so the gesture IS that action: no
// one-row popover, and existing workspaces stay in the tree below.
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
// Toggle: open and close in place.
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
})
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
it('hides the add button when no directory-flow occupant is composed', () => {
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => false, subscribe: () => () => {} }),
})
// Nothing to add with, so the header offers no dead button.
expect(screen.queryByRole('button', { name: '添加工作区' })).toBeNull()
expect(screen.getByText('alpha')).toBeTruthy()
})
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {

View File

@@ -110,8 +110,8 @@ function mount(
}
}
function chooseItem(name: '打开本地文件夹…' | '新建工作区'): void {
fireEvent.click(screen.getByRole('menuitem', { name }))
function chooseAdd(): void {
fireEvent.click(screen.getByRole('menuitem', { name: '添加工作区…' }))
}
describe('WorkspacePicker', () => {
@@ -121,24 +121,12 @@ describe('WorkspacePicker', () => {
expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
})
it('creates a real Workspace from a name and focuses its frontend Session target', async () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseItem('新建工作区')
const input = screen.getByLabelText('新工作区名称')
fireEvent.change(input, { target: { value: 'project-one' } })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
const b = mount([workspace('alpha', 'Alpha')], createWorkspace)
expect(screen.queryByTestId('directory-flow')).toBeNull()
chooseItem('打开本地文件夹…')
chooseAdd()
expect(b.onClose).toHaveBeenCalled()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
@@ -148,9 +136,19 @@ describe('WorkspacePicker', () => {
expect(screen.queryByTestId('directory-flow')).toBeNull()
})
it('treats flow cancellation as a silent no-op', () => {
it('raises the flow straight from the anchor gesture when adding is the only entry', () => {
// Nothing to list and one action left: a one-row menu would offer no
// choice, so the owner's open request lands in the flow itself.
const b = mount([])
chooseItem('打开本地文件夹…')
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
expect(b.onClose).toHaveBeenCalled()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
})
it('treats flow cancellation as a silent no-op', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseAdd()
act(() => { b.probe.owner!.onCancel() })
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
@@ -164,8 +162,8 @@ describe('WorkspacePicker', () => {
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([], createWorkspace)
chooseItem('打开本地文件夹…')
const b = mount([workspace('alpha', 'Alpha')], createWorkspace)
chooseAdd()
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy()
@@ -178,103 +176,57 @@ describe('WorkspacePicker', () => {
expect(b.onPick).not.toHaveBeenCalled()
})
it('reports a non-Error adoption failure in the folder-error surface', async () => {
const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw 'permission denied' }))
chooseAdd()
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('permission denied')
expect(b.onPick).not.toHaveBeenCalled()
})
it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
chooseItem('打开本地文件夹…')
chooseAdd()
// The flow is open but nothing is picked yet: a chooser pending on the
// host display must already block concurrent workspace actions.
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '添加工作区…' }).disabled).toBe(true)
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '打开本地文件夹…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '添加工作区…' }).disabled).toBe(true)
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('shows the flow-reported failure in the folder-error surface', () => {
const b = mount([])
chooseItem('打开本地文件夹…')
const b = mount([workspace('alpha', 'Alpha')])
chooseAdd()
act(() => { b.probe.owner!.onError('no chooser installed') })
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('closes a creation modal when the user cancels', () => {
mount([])
chooseItem('新建工作区')
it('closes the folder-error surface when the user cancels', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseAdd()
act(() => { b.probe.owner!.onError('no chooser installed') })
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseItem('新建工作区')
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('已存在名为“Alpha”的工作区。')
expect(screen.getByRole<HTMLButtonElement>('button', { name: '创建工作区' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('新工作区名称'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseItem('新建工作区')
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
b.rerenderItems([created])
expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
expect(screen.queryByRole('alert')).toBeNull()
await act(async () => { resolve(created); await pending })
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
})
it('exposes creation phase and error text while retaining the modal for retry', async () => {
let reject!: (reason: unknown) => void
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
chooseItem('新建工作区')
const input = screen.getByLabelText('新工作区名称')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).toHaveBeenCalledTimes(1)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('dialog')).toBeTruthy()
await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) })
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable')
expect(b.view.getByRole('dialog')).toBeTruthy()
})
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseItem('新建工作区')
// The name field starts empty (no prefill); a name is required to submit.
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
})
expect(b.onPick).not.toHaveBeenCalled()
})
it('waits to show its menu until an optional anchor is available', () => {
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
/>,
@@ -282,7 +234,7 @@ describe('WorkspacePicker', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('shows list loading through a stable status surface', () => {
it('keeps the menu up while the list baseline is still in flight', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
}
@@ -294,26 +246,58 @@ describe('WorkspacePicker', () => {
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
/>,
)
// An empty list is not final yet: jumping into the directory flow here
// would pre-empt the workspaces about to arrive.
expect(screen.getByRole('status').textContent).toBe('正在加载工作区…')
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy()
})
it('hides the folder entry while the directory-flow hole is empty', () => {
mount([], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: '新建工作区' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
})
it('shows the folder entry when a flow package activates after the first paint', () => {
it('shows no popover at all when nothing is listed and nothing can be added', () => {
// A composition mounting this package without any directory-picker: the
// hero anchor has neither a Workspace to pick nor a way to add one, so it
// must not claim a choice with an empty menu.
const b = mount([], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('holds the anchor gesture while an adoption is still settling', async () => {
// The auto-open path obeys the same busy rule as the disabled menu entry:
// an occupant that re-registers mid-adoption must not raise a second flow.
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
chooseAdd()
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).toBe(true)
// The list empties under the still-settling adoption (the workspace was
// deleted elsewhere), which would otherwise make add the only entry.
act(() => { b.rerenderItems([]) })
expect(b.createWorkspace).toHaveBeenCalledTimes(1)
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('hides the add entry while the directory-flow hole is empty', () => {
mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: 'Alpha' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
})
it('shows the add entry when a flow package activates after the first paint', () => {
const b = mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
// Registration changes flow through the subscription, no re-render needed.
act(() => { b.occupancy.flip(true) })
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy()
})
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
chooseItem('打开本地文件夹…')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw new Error('adoption failed') }))
chooseAdd()
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() })
// The occupant unloads while the error dialog is up: retrying would open
@@ -322,18 +306,18 @@ describe('WorkspacePicker', () => {
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重新选择' }).disabled).toBe(true)
// Cancel stays the way out, and the menu actions are usable again.
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(false)
})
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
const b = mount([])
chooseItem('打开本地文件夹…')
const b = mount([workspace('alpha', 'Alpha')])
chooseAdd()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
// The flow plugin unloads mid-interaction (HMR): nobody is left to
// cancel, so the owner withdraws and the actions come back.
act(() => { b.occupancy.flip(false) })
expect(b.probe.owner!.open).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull()
})
})

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/compact/README.md
README.md: 3c3644adce23c12db37241bf797ea614d273a0fb
README.zh.md: 260a92154ecd33cb127391af5ded399a2bc20038
README.md: aa9fa6d9419de87a7df23a437f5ea8694d981b28
README.zh.md: e771eb4bc76358242737d92f92ec36324f55bf2b

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a human command adapter. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
| `command-compact/` | Human `/compact` command over the backend-independent `compactNow()` seam | (registers on `ctx.commands`) |
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, deterministic pruning at `compact/compact-tool-result-prune/`, and the command at `compact/command-compact/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, command, or automatic callers.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及暂缓实现的面向模型消费方。这些全是**产品**包(package)。
一个压缩(compaction)能力家族(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包(package)。
| 包 | 职责 | ctx key |
|---|---|---|
| `compact/` | 抽象压缩 seam(接口 + `compact/*` 事件 + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | 后端:`ctx.tokenMeter` 压力 + 按 token 预算保留内容 + `llm.stream()` 摘要生成 | (注册 `ctx.compact`) |
| `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` |
| `tool-compact/`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` | (注册到 `ctx.tools`) |
| `command-compact/` | 面向用户的 `/compact` 命令,基于后端无关的 `compactNow()` seam | (注册到 `ctx.commands`) |
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器或调用方。
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session` 和 `dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM(大语言模型)家族服务;基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/compact/command-compact/README.md
README.md: 1445e76f8328a9ac1c5f9dd43094f1c1cd5d2ad4
README.zh.md: 0fb306afb3713e47fb17d2c914a4f691b63b77eb

View File

@@ -0,0 +1,66 @@
# @deepseek-ai/dsh-command-compact
English | [中文](README.zh.md)
Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions.
## Command contract
| Input | Result |
|---|---|
| `/compact` | Summarize one useful balanced older span even below automatic pressure, then report the replaced history-item count and estimated tokens after the standalone bracket is flushed. |
| `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. |
| `/compact <anything>` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. |
The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history.
Expected `ManualCompactionError` codes become stable direct errors:
| Code | Direct result |
|---|---|
| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
| `persistence` | `Compaction finished, but the session could not be saved.` |
The busy result is intentionally process-scoped: a live unmatched marker blocks, while a marker older than the newest `session/end-seed` is stale and does not. Unexpected implementation failures reject dispatch. Cancellation remains authoritative; the backend completes its required close/flush cleanup, and the command settles internally as `Compaction cancelled.` while the command executor stops waiting with its cancellation error. Plugin disposal first unregisters `/compact`, then drains every handler that already started, so root teardown cannot pass an aborted command's close or flush boundary.
Prompts submitted while compaction runs remain accepted in the agent's ordinary FIFO with the same identity and wakeup facts. They start only after the compaction's explicit durability checkpoint and admission release. Idle injected context is not held: it may be logged between `compact/start` and `compact/end`, and positional replacement leaves it visible after the checkpoint.
## Composition
The producer injects `commands` and `compact`. Mount the command registry, one backend, and this plugin:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
```
The TUI example and CLI host mount it beside `compact-basic`. Automation surfaces that compose no command registry keep automatic compaction only.
## Model Experience
### Human `/compact` control
#### What the model sees
The slash input and direct result never enter a model request. An accepted compaction separately replaces an older span with the backend's user-role checkpoint inside a standalone `compact/* { turn: null }` bracket.
#### Token effect
The command lifecycle adds no model tokens. A successful compaction reduces later requests by replacing the selected span with one framed summary; summarization itself is one auxiliary request.
#### KV Cache effect
Discovery and command bookkeeping do not affect the cache. The accepted surface replacement invalidates reuse from the first shadowed history token.
## Known Limitations and Deferred Work
- **Idle-only** — `/compact` reports `busy` when a turn or already accepted waking prompt has right of way; the command itself is not queued.
- **No range or policy arguments** — the argument-free form keeps behavior stable across command adapters. Explicit ranges remain the programmatic `compactRegion()` path.
- **Command adapters only** — surfaces without `ctx.commands` cannot invoke it and rely on automatic pressure compaction.

View File

@@ -0,0 +1,66 @@
# @deepseek-ai/dsh-command-compact
[English](README.md) | 中文
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
## 命令契约
| 输入 | 结果 |
|---|---|
| `/compact` | 即使未达到自动压力,也摘要一段有效、平衡的较早范围;独立标记对 flush 后,报告被替换的历史项数量与估算 token 数。 |
| `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 |
| `/compact <anything>` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 |
该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。
预期的 `ManualCompactionError` 代码会成为稳定的直接错误:
| 代码 | 直接结果 |
|---|---|
| `busy` | `Compaction is unavailable because this process has an active compaction, or the agent is not idle.` |
| `changed` | `The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.` |
| `summary` | `Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.` |
| `commit` | `Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.` |
| `persistence` | `Compaction finished, but the session could not be saved.` |
busy 结果有意限定在进程范围内:活动的未匹配标记会阻塞,而早于最新 `session/end-seed` 的标记已陈旧,不会阻塞。意外实现故障会拒绝分发。取消仍具有最终决定权;后端会完成必需的闭合/flush 清理,命令内部以 `Compaction cancelled.` 结算,而命令执行器会因取消错误停止等待。插件处置会先注销 `/compact`,再等待所有已开始的处理器结算,因此根级 teardown 不会越过已中止命令的闭合或 flush 边界。
压缩运行期间提交的提示词仍会按 agent 的普通 FIFO 获得接纳,保留相同的身份与唤醒信息。它们仅在压缩的显式持久性检查点和接纳预留释放后启动。空闲注入的上下文不受阻塞:它可以记录在 `compact/start` 与 `compact/end` 之间,位置替换会使其在检查点之后保持可见。
## 组合
生产方注入 `commands` 和 `compact`。挂载命令注册表、一个后端与本插件:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
```
TUI 示例与 CLI host 将它挂载在 `compact-basic` 旁。未组合命令注册表的自动化接口只保留自动压缩。
## 模型体验
### 用户 `/compact` 控制
#### 模型看到什么
斜杠输入与直接结果绝不会进入模型请求。已获接纳的压缩会另外在独立的 `compact/* { turn: null }` 标记对内,用后端的 user 角色检查点替换一段较早范围。
#### Token 影响
命令生命周期不会增加模型 token。成功压缩会用一份带框架的摘要替换所选范围,从而减少后续请求;摘要生成本身需要一次辅助请求。
#### KV Cache 影响
命令发现与簿记不会影响缓存。已获接纳的 surface 替换会从第一个被遮蔽的历史 token 起使复用失效。
## 已知限制与暂缓事项
- **仅限空闲状态**:当一个轮次或已获接纳的唤醒提示词拥有优先权时,`/compact` 会报告 `busy`;命令本身不会排队。
- **不接受范围或策略参数**:无参数形式使各命令适配器的行为保持稳定。显式范围仍由编程接口 `compactRegion()` 处理。
- **仅限命令适配器**:没有 `ctx.commands` 的接口无法调用该命令,只能依赖自动压力压缩。

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-command-compact",
"description": "Human-facing slash command for explicit session compaction",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,103 @@
/**
* Human-facing `/compact` command over the backend-independent compaction seam.
* @module @deepseek-ai/dsh-command-compact
*/
import type { Context } from 'cordis'
import { ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
export const name = 'command-compact'
export const inject = ['commands', 'compact']
const USAGE = 'Usage: /compact (no arguments)'
/** Fail loudly if a locally closed union gains an unhandled member. */
/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */
function assertNever(value: never): never {
throw new TypeError(`unknown manual compaction error code: ${String(value)}`)
}
/* v8 ignore stop */
/** Convert expected capability failures into concise human-only outcomes. */
function expectedFailure(error: ManualCompactionError): CommandResult {
switch (error.code) {
case 'busy':
return {
kind: 'error',
text: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
}
case 'changed':
return {
kind: 'error',
text: 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
}
case 'summary':
return {
kind: 'error',
text: 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
}
case 'commit':
return {
kind: 'error',
text: 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
}
case 'persistence':
return {
kind: 'error',
text: 'Compaction finished, but the session could not be saved.',
}
/* v8 ignore next 2 -- ManualCompactionErrorCode is closed and every member is handled above */
default: return assertNever(error.code)
}
}
/** Execute one argument-free manual compaction request. */
async function executeCompact(
ctx: Context,
invocation: CommandInvocation,
): Promise<CommandResult> {
if (invocation.rawInput.trim().length > 0) {
return { kind: 'error', text: USAGE }
}
try {
const result = await ctx.compact.compactNow(invocation.agent, invocation.signal)
if (result === null) return { kind: 'success', text: 'No compactable history yet.' }
return {
kind: 'success',
text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`,
}
} catch (error: unknown) {
if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' }
if (error instanceof ManualCompactionError) return expectedFailure(error)
throw error
}
}
/**
* Register `/compact` for every composed human-command adapter.
* @param ctx - context carrying the command registry and the compaction seam.
*/
export function apply(ctx: Context): void {
const active = new Set<Promise<CommandResult>>()
const handler = (invocation: CommandInvocation): Promise<CommandResult> => {
const operation = executeCompact(ctx, invocation)
active.add(operation)
const retire = (): void => { active.delete(operation) }
// Both branches retire without rethrowing, so the derived observer promise
// cannot become an unhandled mirror of an expected handler rejection.
void operation.then(retire, retire)
return operation
}
ctx.effect(function* () {
// Yield drain before registration: composite teardown is LIFO, so no new
// invocation can enter while already-started handler promises quiesce.
yield async () => { await Promise.allSettled(active) }
yield ctx.commands.register({
name: 'compact',
description: 'Compact older conversation history',
handler,
})
}, 'command-compact lifecycle')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-command-compact`.
* @module @deepseek-ai/dsh-command-compact/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-command-compact'
/** Cordis companion plugin name. */
export const name = 'command-compact-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this command adapter owns no state or event stream; the compaction seam owns
* the balanced durable transaction and the command registry owns registration and dispatch lifecycle.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import {
CompactService,
ManualCompactionError,
type CompactAgentContext,
type CompactionResult,
type CompactionTrigger,
type ManualCompactAgentContext,
} from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
const RESULT: CompactionResult = {
startSeq: 10,
summarySeq: 11,
endSeq: 13,
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: 1, end: 7 },
shadowedSeqs: [1, 3, 7],
shadowedTokenCount: 42,
}
class StubCompactService extends CompactService {
result: CompactionResult | null = RESULT
failure: unknown
operation: (() => Promise<CompactionResult | null>) | undefined
calls: { agent: ManualCompactAgentContext; signal: AbortSignal }[] = []
override compactIfNeeded(
_agent: CompactAgentContext,
_trigger: CompactionTrigger,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
return Promise.resolve(null)
}
override compactRegion(): Promise<CompactionResult> {
return Promise.resolve(RESULT)
}
override compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.calls.push({ agent, signal })
if (this.operation !== undefined) return this.operation()
return this.failure === undefined
? Promise.resolve(this.result)
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
: Promise.reject(this.failure)
}
}
interface Harness {
readonly ctx: Context
readonly compact: StubCompactService
readonly agent: Agent
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
const compact = new StubCompactService(ctx)
const plugin = await ctx.plugin(commandCompact)
const session = new Session(SessionId('command-compact'))
const agent = {
session,
status: 'idle',
options: {},
reserveTurnAdmission: () => () => undefined,
} as unknown as Agent
return { ctx, compact, agent, plugin }
}
async function run(
test: Harness,
suffix = '',
controller = new AbortController(),
): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
const execution = await test.ctx.commands.execute(test.agent, `/compact${suffix}`, controller.signal)
if (execution === undefined) throw new Error('compact command was not registered')
return execution
}
/** Assert the executor-owned lifecycle pair and absence from model history. */
function expectLastLifecycle(
test: Harness,
args: string,
outcome: { readonly kind: 'success' | 'error'; readonly text?: string },
): string {
const lifecycle = test.agent.session.events.slice(-2)
const runEvent = lifecycle[0]
const doneEvent = lifecycle[1]
if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') {
throw new Error(`expected command lifecycle pair, got ${lifecycle.map(event => event.type).join(',')}`)
}
expect(lifecycle.map(event => ({ type: event.type, data: event.data }))).toEqual([
{
type: 'command/run',
data: {
commandId: runEvent.data.commandId,
name: 'compact',
args,
source: { kind: 'user' },
},
},
{
type: 'command/done',
data: {
commandId: runEvent.data.commandId,
...outcome,
},
},
])
expect(doneEvent.data.commandId).toBe(runEvent.data.commandId)
expect(test.agent.session.surface.nodes).toEqual([])
expect(test.agent.session.deriveMessages()).toEqual([])
return runEvent.data.commandId
}
describe('@deepseek-ai/dsh-command-compact registration', () => {
it('registers one argument-free command with Loader-safe exports and disposes it', async () => {
const test = await harness()
expect(commandCompact.name).toBe('command-compact')
expect(commandCompact.inject).toEqual(['commands', 'compact'])
expect('default' in commandCompact).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(commandCompact)).toBe(commandCompact)
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'compact',
description: 'Compact older conversation history',
})
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
})
})
describe('/compact human command', () => {
it('reports success with useful accounting and forwards the exact target and signal', async () => {
const test = await harness()
const controller = new AbortController()
const execution = await run(test, '', controller)
expect(execution.result).toEqual({
kind: 'success',
text: 'Compacted 3 history items (~42 tokens).',
})
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }])
})
it('returns direct no-history and argument-rejection results', async () => {
const test = await harness()
test.compact.result = null
const empty = await run(test)
expect(empty.result).toEqual({
kind: 'success',
text: 'No compactable history yet.',
})
expect(empty.commandId).toBe(expectLastLifecycle(test, '', empty.result))
const rejected = await run(test, ' now')
expect(rejected.result).toEqual({
kind: 'error',
text: 'Usage: /compact (no arguments)',
})
expect(rejected.commandId).toBe(expectLastLifecycle(test, ' now', rejected.result))
expect(test.compact.calls).toHaveLength(1)
})
it.each([
['busy', 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.'],
['changed', 'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.'],
['summary', 'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.'],
['commit', 'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.'],
['persistence', 'Compaction finished, but the session could not be saved.'],
] as const)('maps expected %s failures to direct errors', async (code, text) => {
const test = await harness()
test.compact.failure = new ManualCompactionError(code, 'backend detail')
const execution = await run(test)
expect(execution.result).toEqual({ kind: 'error', text })
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
})
it('preserves cancellation and unexpected implementation failures', async () => {
const cancelled = await harness()
const controller = new AbortController()
const abort = new Error('operator cancelled')
cancelled.compact.operation = () => {
controller.abort(abort)
return Promise.reject(new ManualCompactionError('summary', 'late failure'))
}
await expect(run(cancelled, '', controller)).rejects.toBe(abort)
expectLastLifecycle(cancelled, '', { kind: 'error', text: abort.message })
const unexpected = await harness()
const bug = new Error('unexpected backend bug')
unexpected.compact.failure = bug
await expect(run(unexpected)).rejects.toBe(bug)
expectLastLifecycle(unexpected, '', { kind: 'error', text: bug.message })
})
it('drains an aborted handler through close and flush before plugin disposal settles', async () => {
const test = await harness()
const controller = new AbortController()
const abort = new Error('operator cancelled')
const started = Promise.withResolvers<undefined>()
const allowClose = Promise.withResolvers<undefined>()
const closed = Promise.withResolvers<undefined>()
const allowFlush = Promise.withResolvers<undefined>()
const flushed = Promise.withResolvers<undefined>()
test.compact.operation = async () => {
started.resolve(undefined)
await allowClose.promise
closed.resolve(undefined)
await allowFlush.promise
flushed.resolve(undefined)
throw abort
}
const execution = run(test, '', controller)
await started.promise
controller.abort(abort)
await expect(execution).rejects.toBe(abort)
let disposed = false
const disposal = test.plugin.dispose()
void disposal.then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(test.ctx.commands.find(test.agent, 'compact')).toBeUndefined()
expect(disposed).toBe(false)
allowClose.resolve(undefined)
await closed.promise
await new Promise(resolve => setTimeout(resolve, 0))
expect(disposed).toBe(false)
allowFlush.resolve(undefined)
await flushed.promise
await disposal
expect(disposed).toBe(true)
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
import * as invariant from '@deepseek-ai/dsh-command-compact/invariant'
describe('command-compact invariant companion', () => {
it('registers the package-owned no-op installer', async () => {
const register = vi.fn().mockReturnValue(() => {})
const ctx = { invariants: { register } } as never
const dispose = await invariant.apply(ctx)
expect(invariant.name).toBe('command-compact-invariant')
expect(invariant.inject).toEqual(['invariants'])
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-command-compact', expect.any(Function))
expect(() => {
const install = register.mock.calls[0]![1] as () => void
install()
}).not.toThrow()
expect(dispose).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,134 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import {
CompactService,
type CompactAgentContext,
type CompactionResult,
type CompactionTrigger,
type ManualCompactAgentContext,
} from '@deepseek-ai/dsh-compact'
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
const RESULT: CompactionResult = {
startSeq: 1,
summarySeq: 2,
endSeq: 4,
summary: [{ type: 'text', text: 'loader summary' }],
shadowedRange: { start: 3, end: 8 },
shadowedSeqs: [3, 5, 8],
shadowedTokenCount: 99,
}
class LoaderCompactService extends CompactService {
override compactIfNeeded(
_agent: CompactAgentContext,
_trigger: CompactionTrigger,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
return Promise.resolve(null)
}
override compactRegion(): Promise<CompactionResult> {
return Promise.resolve(RESULT)
}
override compactNow(
_agent: ManualCompactAgentContext,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
return Promise.resolve(RESULT)
}
}
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('command-compact real Loader composition', () => {
it('discovers and executes /compact through the assembled command plane', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-command-compact-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-commands'",
"- name: '@test/compact-backend'",
"- name: '@deepseek-ai/dsh-command-compact'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-commands', CommandService],
['@test/compact-backend', LoaderCompactService],
['@deepseek-ai/dsh-command-compact', commandCompact],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
const session = new Session(SessionId('loader-command-compact'))
const agent = {
session,
status: 'idle',
options: {},
reserveTurnAdmission: () => () => undefined,
} as unknown as Agent
expect(context.commands.list(agent)).toContainEqual({
name: 'compact',
description: 'Compact older conversation history',
})
const execution = await context.commands.execute(agent, '/compact', new AbortController().signal)
if (execution === undefined) throw new Error('Loader composition did not resolve /compact')
expect(execution.result).toEqual({
kind: 'success',
text: 'Compacted 3 history items (~99 tokens).',
})
expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([
{
type: 'command/run',
data: {
commandId: execution.commandId,
name: 'compact',
args: '',
source: { kind: 'user' },
},
},
{
type: 'command/done',
data: {
commandId: execution.commandId,
kind: 'success',
text: 'Compacted 3 history items (~99 tokens).',
},
},
])
expect(session.surface.nodes).toEqual([])
expect(session.deriveMessages()).toEqual([])
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../ui/commands"
},
{
"path": "../compact"
},
{
"path": "../../support/invariants"
}
]
}

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/compact/compact-basic/README.md
README.md: b35e5dc110e908047338054337b309a77b7e0f68
README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2
README.md: 33a0a47346bed98ed0653d53d424ea7cd25c2a24
README.zh.md: 603a3104592e7e6acbf54c67ea9616ed9ab8c7cc

View File

@@ -17,11 +17,11 @@ This backend owns the compaction policy:
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress.
- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)
@@ -46,21 +46,25 @@ An adapter may return no capacity for a valid dynamic route, and resolved capaci
## Usage
`BasicCompactService` requires `ctx.llm`, `ctx.tokenMeter`, and `ctx.sessions`. The composition below receives `ctx.llm` from its host and installs the other two services:
```ts
import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import SessionStore from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
export const name = 'compact-basic'
export const inject = ['llm', 'tokenMeter']
export const inject = ['llm']
export function apply(ctx: Context): void {
ctx.plugin(SessionStore)
ctx.plugin(TokenMeterService)
ctx.plugin(BasicCompactService)
}
```
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure. The sibling [`dsh-command-compact`](../command-compact/README.md) calls `ctx.compact.compactNow(...)`; programmatic callers may also use any seam operation directly.
For example, the same compact plugin can safely serve models with different capacities and one target-specific policy:

View File

@@ -17,11 +17,11 @@
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用。
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,如果表层节点快照已改变,它会拒绝操作,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**:未配对的 `compact/start` 是不起作用的崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。即使已经取得进展,取消仍具有最终决定权。
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`);事务会在 `compact/summary` 上保留这些字段。
## 配置(`BasicCompactConfig`)
@@ -46,21 +46,25 @@
## 用法
`BasicCompactService` 需要 `ctx.llm`、`ctx.tokenMeter` 和 `ctx.sessions`。以下组合从其宿主接收 `ctx.llm`,并安装另外两项服务:
```ts
import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import SessionStore from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
export const name = 'compact-basic'
export const inject = ['llm', 'tokenMeter']
export const inject = ['llm']
export function apply(ctx: Context): void {
ctx.plugin(SessionStore)
ctx.plugin(TokenMeterService)
ctx.plugin(BasicCompactService)
}
```
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩;消费方(未来的 `/compact` 工具)也可直接调用 `ctx.compact.compactIfNeeded(...)` 或 `ctx.compact.compactRegion(...)`。
加载插件会注册 `ctx.compact`。在该插件之前添加同级 [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) 以启用可选的不依赖模型的处理阶段。当 `auto: true`(默认)时,它会在 token 压力下自动压缩。同级 [`dsh-command-compact`](../command-compact/README.md) 调用 `ctx.compact.compactNow(...)`;编程调用方也可以直接使用任一 seam 操作。
例如,同一个压缩插件可以安全服务于容量不同的模型,并应用一项目标特定策略:

View File

@@ -6,11 +6,12 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { CompactService } from '@deepseek-ai/dsh-compact'
import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -20,9 +21,13 @@ import {
resolveTargetPolicy,
TargetPressureConfigError,
} from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import {
assertNoActiveCompaction,
compactSurfaceRegion,
selectCompactableRange,
} from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
import type { SummarizationInput } from './summarizer.ts'
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
import type {
BasicCompactConfig,
ModelCompactPolicyConfig,
@@ -39,6 +44,9 @@ export type {
ResolvedTargetPolicy,
} from './types.ts'
/** The region transaction's view of this service's dynamically dispatched summarizer. */
type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise<SummaryResult>
/** Resolve the exact provider/model durably routed for the latest request. */
function routedTarget(
session: Session,
@@ -92,7 +100,7 @@ const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
* token meter.
*/
export class BasicCompactService extends CompactService {
static inject = ['llm', 'tokenMeter']
static inject = ['llm', 'tokenMeter', 'sessions']
static Config: z<BasicCompactConfig> = z.object({
thresholdRatio: thresholdRatioSchema,
@@ -235,7 +243,7 @@ export class BasicCompactService extends CompactService {
input: SummarizationInput,
agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
): Promise<SummaryResult> {
const target = conversationTarget(agent)
const config = target === undefined
? this.config
@@ -289,6 +297,7 @@ export class BasicCompactService extends CompactService {
}
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context
assertNoActiveCompaction(agent.session, 'automatic pressure compaction')
const targetKey = `${target.provider}/${target.model}`
if (context === undefined) {
throw new TargetPressureConfigError(
@@ -343,11 +352,67 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
const session = agent.session
return compactSurfaceRegion({
return compactSurfaceRegion(
this.regionDependencies(),
agent.session,
start,
end,
agent,
{ owner: 'current-turn', stability: 'whole-surface' },
signal,
)
}
/**
* Force one useful idle-session compaction below the pressure threshold, and
* resolve only after its standalone marker pair is durably checkpointed.
* @param agent - idle agent whose next-turn admission this call reserves.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the committed result, or `null` when no safe useful range exists.
*/
override async compactNow(
agent: Agent,
signal: AbortSignal,
): Promise<CompactionResult | null> {
signal.throwIfAborted()
const releaseTurnAdmission = agent.reserveTurnAdmission()
if (releaseTurnAdmission === undefined) {
throw new ManualCompactionError(
'busy',
'manual compaction requires an idle agent with no waking queued work',
)
}
try {
const range = selectCompactableRange(
agent.session,
this.ctx.tokenMeter.measure(agent.session),
0,
)
if (range === null) return null
return await compactSurfaceRegion(
this.regionDependencies(),
agent.session,
range.start,
range.end,
agent,
{
owner: null,
stability: 'selected-span',
flush: () => this.ctx.sessions.flush(agent.session),
},
signal,
)
} finally {
releaseTurnAdmission()
}
}
/** Bind the effective token meter and dynamically dispatched summarizer hook. */
private regionDependencies(): { meter: TokenMeterService; summarize: RegionSummarize } {
return {
meter: this.ctx.tokenMeter,
summarize: (input, owner, abort) => this.summarize(input, owner, abort),
}, session, start, end, agent, signal)
}
}
}

View File

@@ -1,5 +1,6 @@
/**
* Surface retention selection and the log-recorded compaction transaction.
* Surface retention selection and the shared log-recorded compaction
* transaction for automatic open-turn and manual idle-session compaction.
*
* @module @deepseek-ai/dsh-compact-basic/region
*/
@@ -7,12 +8,13 @@
import { isDeepStrictEqual } from 'node:util'
import {
COMPACT_CHECKPOINT_SOURCE,
ManualCompactionError,
toolPairingBalancedAfter,
toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -24,6 +26,62 @@ interface RegionDependencies {
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
}
/** One validated inclusive span of current surface positions. */
interface SurfaceSelection {
readonly start: number
readonly end: number
readonly startIdx: number
readonly endIdx: number
readonly shadowedSeqs: readonly number[]
}
/** A selection with its priced snapshot and the replay input built from it. */
interface PreparedCompaction extends SurfaceSelection {
readonly measurement: TokenMeasurement
readonly selectedNodes: TokenMeasurement['nodes']
readonly shadowedTokenCount: number
readonly input: SummarizationInput
}
interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
readonly checkpointMessage: UserMessage
}
interface CompactionTransactionOptions {
/** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */
readonly owner: 'current-turn' | null
/** Surface relationship that must survive asynchronous summarization. */
readonly stability: 'whole-surface' | 'selected-span'
/** Optional durability checkpoint after a successfully closed bracket. */
readonly flush?: () => Promise<void>
}
interface CompactionEntryState {
readonly openTurn: number | null
readonly unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined
readonly latestEndSeedSeq: number | undefined
}
/**
* Rejects a summary whose replacement boundaries are no longer the ones it was
* built from, distinguished from summarizer and shrink failures so a manual
* caller can report the two causes differently.
*/
class SurfaceChangedError extends Error {}
/** Whether the summary may still replace the span it was built from. */
type StabilityCheck = (
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
) => void
/** Failure captured after `compact/start` has committed. */
interface TransactionFailure {
readonly error: unknown
readonly stage: 'summary' | 'commit'
}
/**
* Resolve the next head-anchored range while retaining a priced recent tail
* and never splitting an assistant tool-call/result pair.
@@ -71,12 +129,18 @@ export function selectCompactableRange(
}
/**
* Validate and compact one positional surface span.
* Run the single compaction transaction over one selected positional span.
* Selection and validation are read-only. Idle/log validation and
* `compact/start` are synchronously adjacent, so the durable opening marker is
* the compaction lock before summarization yields. Every later failure makes
* exactly one `compact/end` attempt; a failed close deliberately leaves the
* unmatched start detectable.
* @param dependencies - conversation meter and dynamically dispatched summarizer hook.
* @param session - session whose surface is mutated.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - agent used by the summarizer.
* @param options - bracket owner, stability rule, and optional durability checkpoint.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
@@ -86,8 +150,151 @@ export async function compactSurfaceRegion(
start: number,
end: number,
agent: Agent,
options: CompactionTransactionOptions,
signal?: AbortSignal,
): Promise<CompactionResult> {
if (options.owner === null) signal?.throwIfAborted()
const selection = validateSurfaceRegion(session, start, end)
const entryState = inspectCompactionEntryState(session.events)
assertCompactionInactive(
entryState.unmatchedCompactionStart,
entryState.latestEndSeedSeq,
'compaction',
)
let owner: number | null
if (options.owner === null) {
if (entryState.openTurn !== null) {
throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn')
}
owner = null
} else {
if (entryState.openTurn === null) {
throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn')
}
owner = entryState.openTurn
}
const startEvent = session.append('compact/start', { turn: owner })
const assertStable: StabilityCheck = options.stability === 'whole-surface'
? assertWholeSurfaceUnchanged
: assertSelectedSpanStable
let failure: TransactionFailure | undefined
let flushFailure: unknown
let result: CompactionResult | undefined
let closed = false
let closing = false
let stage: TransactionFailure['stage'] = 'summary'
try {
const prepared = prepareCompaction(dependencies, session, selection)
const summarized = await summarizeCompaction(dependencies, prepared, agent, signal)
if (options.owner === null) signal?.throwIfAborted()
assertStable(dependencies, session, summarized)
stage = 'commit'
const pending = commitCompactionBody(session, startEvent, summarized)
closing = true
const endEvent = session.append('compact/end', { turn: owner })
closed = true
result = completeCompaction(pending, endEvent)
} catch (error: unknown) {
failure = { error, stage: closing ? 'commit' : stage }
if (!closing) {
closing = true
try {
session.append('compact/end', { turn: owner, error: errorChain(error) })
closed = true
} catch (closeError: unknown) {
failure = { error: closeError, stage: 'commit' }
}
}
}
if (closed && options.flush !== undefined) {
try {
await options.flush()
} catch (error: unknown) {
flushFailure = error
}
}
if (options.owner === null) signal?.throwIfAborted()
if (failure !== undefined) {
if (options.owner === null) throwManualFailure(failure)
throw failure.error
}
if (flushFailure !== undefined) {
throw new ManualCompactionError(
'persistence',
'manual compaction durability checkpoint failed',
{ cause: flushFailure },
)
}
/* v8 ignore next -- every path without a result records and throws a failure above. */
if (result === undefined) throw new Error('compaction committed without a result')
return result
}
/** Classify one closed manual attempt without weakening cancellation precedence. */
function throwManualFailure(failure: TransactionFailure): never {
if (failure.stage === 'commit') {
throw new ManualCompactionError(
'commit',
'manual compaction did not commit cleanly',
{ cause: failure.error },
)
}
if (failure.error instanceof SurfaceChangedError) {
throw new ManualCompactionError(
'changed',
'the compacted history changed during manual compaction',
{ cause: failure.error },
)
}
throw new ManualCompactionError(
'summary',
'manual compaction could not produce a smaller summary',
{ cause: failure.error },
)
}
/**
* Reject a durable unmatched compaction marker unless a later constructor-seed
* boundary proves that its owner belongs to an earlier session lifecycle.
* @param unmatchedCompactionStart - latest unmatched opening marker, if any.
* @param latestEndSeedSeq - newest constructor-seed boundary, if any.
* @param stage - operation label included in the busy diagnostic.
*/
function assertCompactionInactive(
unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined,
latestEndSeedSeq: number | undefined,
stage: string,
): void {
if (unmatchedCompactionStart === undefined
|| (latestEndSeedSeq !== undefined
&& latestEndSeedSeq > unmatchedCompactionStart.seq)) return
throw new ManualCompactionError(
'busy',
`${stage}: compaction already in progress; the session compaction lock is already active`,
)
}
/**
* Recheck the durable compaction lock after an asynchronous policy decision.
* @param session - session whose latest marker state is inspected.
* @param stage - operation label included in the busy diagnostic.
*/
export function assertNoActiveCompaction(session: Session, stage: string): void {
const entryState = inspectCompactionEntryState(session.events)
assertCompactionInactive(
entryState.unmatchedCompactionStart,
entryState.latestEndSeedSeq,
stage,
)
}
/** Validate one requested surface-position span before asynchronous work begins. */
function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection {
const nodes = session.surface.nodes
const startIdx = nodes.indexOf(start)
const endIdx = nodes.indexOf(end)
@@ -107,75 +314,145 @@ export async function compactSurfaceRegion(
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
const tail = inspectTurnTail(session.events)
if (tail.compactionInProgress) throw new Error('compaction already in progress')
if (tail.turn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
}
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
const startEvent = session.append('compact/start', { turn: tail.turn })
/** Snapshot pricing and replay input for a validated surface range. */
function prepareCompaction(
dependencies: RegionDependencies,
session: Session,
selection: SurfaceSelection,
): PreparedCompaction {
const measurement = dependencies.meter.measure(session)
const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1)
if (selectedNodes.length !== selection.shadowedSeqs.length
|| selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) {
throw new SurfaceChangedError('compaction: selected surface changed before summarization began')
}
return {
...selection,
measurement,
selectedNodes,
shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
input: buildSummarizationInput(session, selection.shadowedSeqs),
}
}
/** Run the summarizer and frame its replacement checkpoint. */
async function summarizeCompaction(
dependencies: RegionDependencies,
prepared: PreparedCompaction,
agent: Agent,
signal?: AbortSignal,
): Promise<SummarizedCompaction> {
const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
const checkpointMessage = createUserMessage({
content: frameSummary(summaryResult.summary),
source: COMPACT_CHECKPOINT_SOURCE,
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
)
}
return {
...prepared,
...summaryResult,
checkpointMessage,
}
}
/** Reject a summary prepared against any earlier surface generation. */
function assertWholeSurfaceUnchanged(
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
): void {
const current = dependencies.meter.measure(session)
if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) {
throw new SurfaceChangedError('compaction: session surface changed during summarization')
}
}
/**
* Require only that the selected span remain the same present, contiguous,
* equally priced, balanced replacement target. Nodes added outside it remain
* visible and do not invalidate the summary.
*/
function assertSelectedSpanStable(
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
): void {
let current: SurfaceSelection
try {
// Capture after the lock event so a later surface mutation invalidates the
// async selection before replacement. Unrelated log-only facts may append.
const lockedMeasurement = dependencies.meter.measure(session)
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
throw new Error('compaction: selected surface changed before summarization began')
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
const {
summary, rawOutput, provider, model, maxTokens, usage,
} = await dependencies.summarize(summarizationInput, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
throw new Error('compaction: session surface changed during summarization')
}
const framedSummary = frameSummary(summary)
const checkpointMessage = createUserMessage({
content: framedSummary,
source: COMPACT_CHECKPOINT_SOURCE,
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
)
}
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compact/end', { turn: tail.turn })
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
}
current = validateSurfaceRegion(session, prepared.start, prepared.end)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn: tail.turn, error: message })
throw error
throw new SurfaceChangedError(
'compaction: the selected span is no longer a valid replacement target',
{ cause: error },
)
}
if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) {
throw new SurfaceChangedError('compaction: the selected span changed during summarization')
}
const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1)
if (!isDeepStrictEqual(measured, prepared.selectedNodes)) {
throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization')
}
}
/** Append one already-summarized provenance and replacement body without yielding. */
function commitCompactionBody(
session: Session,
startEvent: SessionEvent<'compact/start'>,
summarized: SummarizedCompaction,
): Omit<CompactionResult, 'endSeq'> {
const {
start,
end,
shadowedSeqs,
shadowedTokenCount,
summary,
rawOutput,
provider,
model,
maxTokens,
usage,
checkpointMessage,
} = summarized
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
}
}
/** Attach the successfully appended close event to a pending result. */
function completeCompaction(
pending: Omit<CompactionResult, 'endSeq'>,
endEvent: SessionEvent<'compact/end'>,
): CompactionResult {
return { ...pending, endSeq: endEvent.seq }
}
/**
@@ -206,25 +483,38 @@ function buildSummarizationInput(
}
}
/** Inspect the current turn boundary and latest compaction bracket once. */
function inspectTurnTail(
events: readonly SessionEvent[],
): { turn: number | null; compactionInProgress: boolean } {
let compactionInProgress = false
let compactionStateKnown = false
/** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState {
let openTurn: number | null = null
let openTurnStateKnown = false
let unmatchedCompactionStart: SessionEvent<'compact/start'> | undefined
let compactionEntryStateKnown = false
let latestEndSeedSeq: number | undefined
for (let index = events.length - 1; index >= 0; index -= 1) {
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (!compactionStateKnown) {
if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
latestEndSeedSeq = event.seq
}
if (!compactionEntryStateKnown) {
if (event.type === 'compact/start') {
compactionInProgress = true
compactionStateKnown = true
unmatchedCompactionStart = event
compactionEntryStateKnown = true
} else if (event.type === 'compact/end') {
compactionStateKnown = true
compactionEntryStateKnown = true
}
}
if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
if (event.type === 'turn/end') return { turn: null, compactionInProgress }
if (!openTurnStateKnown) {
if (event.type === 'turn/start') {
openTurn = event.data.turn
openTurnStateKnown = true
} else if (event.type === 'turn/end') {
openTurnStateKnown = true
}
}
if (openTurnStateKnown
&& compactionEntryStateKnown
&& latestEndSeedSeq !== undefined) break
}
return { turn: null, compactionInProgress }
return { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
}

View File

@@ -21,7 +21,7 @@ import type {
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -1782,6 +1782,7 @@ describe('automatic listener and loader composition', () => {
it('loads and disposes the real zero-config service stack', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
const meterFiber = await ctx.plugin(TokenMeterService)
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
@@ -55,6 +57,7 @@ describe('real Loader composition', () => {
it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-token-meter'",
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
' config:',
@@ -91,6 +94,7 @@ describe('real Loader composition', () => {
it('rejects stale compact-basic config after Schemastery normalization', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(SessionStore)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
models: { legacy: { thresholdRatio: 0.5 } },
@@ -100,6 +104,7 @@ describe('real Loader composition', () => {
it('rejects a capacity-independent merged ratio conflict during plugin load', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(SessionStore)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
retainRatio: 0.2,
@@ -114,6 +119,7 @@ describe('real Loader composition', () => {
it('rejects an incomplete model-policy summarization pair during plugin load', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(SessionStore)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
summarizationProvider: 'default-provider',

View File

@@ -0,0 +1,831 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant'
import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import {
createAssistantMessage,
createUserMessage,
LlmAdapter,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
LlmResolvedModelInfo,
Message,
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
SummarizationInput,
SummaryResult,
} from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
const MODEL = 'mock'
const SIGNAL = new AbortController().signal
const PROMPT = 'older conversation history '.repeat(60)
/** A summarizer under test control: it can block, fail, or mutate mid-call. */
class GatedCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'checkpoint' }]
rawOutput: ContentBlock[] | undefined
usage: TokenUsage | undefined
error: unknown
gate: Promise<undefined> | undefined
duringSummary: (() => void) | undefined
calls: SummarizationInput[] = []
override async summarize(
input: SummarizationInput,
_agent: Agent,
_signal?: AbortSignal,
): Promise<SummaryResult> {
this.calls.push(input)
this.duringSummary?.()
if (this.gate !== undefined) await this.gate
if (this.error !== undefined) throw this.error
return {
summary: this.summary,
...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
provider: 'summary-provider',
model: 'summary-model',
...this.usage === undefined ? {} : { usage: this.usage },
}
}
}
/** One text answer per request, with a context window large enough to avoid pressure. */
class TextAdapter extends LlmAdapter {
readonly requests: Message[][] = []
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: 100_000 },
})
}
override async * stream(options: { messages: readonly Message[] }): AsyncIterable<StreamChunk> {
this.requests.push([...options.messages])
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
interface LoopHarness {
readonly ctx: Context
readonly agent: Agent
readonly compact: GatedCompactService
readonly adapter: TextAdapter
readonly log: string[]
}
/** Real loop, session store, and invariant companions around manual compaction. */
async function loopHarness(): Promise<LoopHarness> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
await ctx.plugin(CompactInvariant)
await ctx.plugin(CompactBasicInvariant)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
const adapter = new TextAdapter()
ctx.llm.registerAdapter([MODEL], adapter)
const compact = new GatedCompactService(ctx, { auto: false })
const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
const log: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
if (event.type === 'turn/end') log.push('turn/end')
if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
if (event.type === 'compact/summary') log.push('compact/summary')
if (event.type === 'compact/end') log.push(`compact/end:${String(event.data.turn)}`)
if (event.type === 'user/message') log.push('user/message')
})
ctx.on('session/flush', () => { log.push('flush') })
return { ctx, agent, compact, adapter, log }
}
/** Drive one real turn so the closed history holds a compactable older span. */
async function seedHistory(harness: LoopHarness): Promise<void> {
harness.agent.followup(createUserMessage({
content: [{ type: 'text', text: PROMPT }],
source: { kind: 'user' },
}))
await harness.agent.whenIdle()
harness.log.length = 0
}
/** Text of every derived model-visible message, in request order. */
function derivedText(session: Session): string[] {
return session.deriveMessages().map((message: Message) => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
}
/** Await one classified manual-compaction rejection. */
async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
const caught: unknown = await operation.then(
(value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
(error: unknown) => error,
)
if (!(caught instanceof ManualCompactionError)) {
throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
}
return caught
}
/** The Error a classified failure wraps. */
function causeOf(error: ManualCompactionError): Error {
const { cause } = error
if (!(cause instanceof Error)) throw new Error(`expected an Error cause, got ${String(cause)}`)
return cause
}
function deferred(): { promise: Promise<undefined>; resolve: () => void } {
const { promise, resolve } = Promise.withResolvers<undefined>()
return { promise, resolve: () => { resolve(undefined) } }
}
/** A closed-tail session with compactable exchanges and no live agent. */
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
for (let index = 1; index <= turns; index += 1) {
const turn = index === turns ? lastTurnNumber : index
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn, step: 1 })
if (index === 1) {
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
}
session.append('assistant/message', {
turn,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: `answer ${turn}` }],
source: { provider: MODEL, model: MODEL },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return session
}
/** A fake idle agent whose admission reservation is scripted per test. */
function fakeAgent(
session: Session,
reserve: () => (() => void) | undefined,
): Agent {
return {
session,
options: { provider: MODEL, model: MODEL },
reserveTurnAdmission: reserve,
} as unknown as Agent
}
/** Service over a store-detached session for failure classification. */
function detachedService(): { ctx: Context; compact: GatedCompactService; flushes: () => number } {
const ctx = new Context()
void new LlmService(ctx)
void new SessionStore(ctx)
void new TokenMeterService(ctx)
ctx.llm.registerAdapter([MODEL], new TextAdapter())
let flushes = 0
vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
flushes += 1
return Promise.resolve()
})
return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes }
}
function compactEvents(session: Session): Array<Session['events'][number]> {
return session.events.filter(event => event.type.startsWith('compact/'))
}
describe('compactNow through the real loop', () => {
it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => {
const harness = await loopHarness()
const { agent, compact, adapter, log } = harness
await seedHistory(harness)
const gate = deferred()
compact.gate = gate.promise
const running = compact.compactNow(agent, SIGNAL)
await Promise.resolve()
expect(log).toEqual(['compact/start:null'])
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'after compaction' }],
source: { kind: 'user' },
}))
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(1)
expect(log).toEqual(['compact/start:null'])
gate.resolve()
const result = await running
expect(result).not.toBeNull()
await agent.whenIdle()
const start = log.indexOf('compact/start:null')
const summary = log.indexOf('compact/summary')
const end = log.indexOf('compact/end:null')
const flush = log.indexOf('flush')
const nextTurn = log.indexOf('turn/start:message')
expect(start).toBeLessThan(summary)
expect(summary).toBeLessThan(end)
expect(end).toBeLessThan(flush)
expect(flush).toBeLessThan(nextTurn)
expect(adapter.requests).toHaveLength(2)
const second = (adapter.requests[1] ?? []).map(message => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
expect(second[0]).toContain('checkpoint')
expect(second.at(-1)).toBe('after compaction')
expect(second.some(text => text.includes(PROMPT))).toBe(false)
})
it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
const harness = await loopHarness()
const { agent, compact } = harness
await seedHistory(harness)
compact.duringSummary = () => {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'INJECTED CONTEXT' }],
source: { kind: 'plugin', plugin: 'test' },
}))
}
const result = await compact.compactNow(agent, SIGNAL)
expect(result).not.toBeNull()
const start = agent.session.events.findLast(event => event.type === 'compact/start')
const injected = agent.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
const end = agent.session.events.findLast(event => event.type === 'compact/end')
expect(start).toBeDefined()
expect(injected).toBeDefined()
expect(end).toBeDefined()
expect(start!.seq).toBeLessThan(injected!.seq)
expect(injected!.seq).toBeLessThan(end!.seq)
expect(result?.shadowedSeqs).not.toContain(injected?.seq)
const messages = derivedText(agent.session)
expect(messages[0]).toContain('checkpoint')
expect(messages.at(-1)).toContain('INJECTED CONTEXT')
expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
})
it('keeps the marker order when listeners attempt a re-entrant injection', async () => {
const harness = await loopHarness()
const { ctx, agent, compact } = harness
await seedHistory(harness)
const attempts: string[] = []
ctx.on('session/event', (_session, event) => {
if (event.type !== 'compact/start' && event.type !== 'compact/summary') return
attempts.push(event.type)
agent.inject(createUserMessage({
content: [{ type: 'text', text: `from ${event.type}` }],
source: { kind: 'plugin', plugin: 'listener' },
}))
})
const result = await compact.compactNow(agent, SIGNAL)
expect(attempts).toEqual(['compact/start', 'compact/summary'])
expect(result).not.toBeNull()
expect(derivedText(agent.session)[0]).toContain('checkpoint')
expect(agent.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0)
const types = compactEvents(agent.session).map(event => event.type)
expect(types).toEqual(['compact/start', 'compact/summary', 'compact/end'])
})
it('reports busy without summarizing when a prompt already owns the next turn', async () => {
const harness = await loopHarness()
const { agent, compact, adapter } = harness
await seedHistory(harness)
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'first in line' }],
source: { kind: 'user' },
}))
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'compact/start')).toBe(false)
})
it('releases turn admission after a summarizer failure and records the failed attempt', async () => {
const harness = await loopHarness()
const { agent, compact, adapter } = harness
await seedHistory(harness)
compact.error = new Error('summarizer unavailable')
const before = [...agent.session.surface.nodes]
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('summary')
expect(agent.session.surface.nodes).toEqual(before)
const markers = compactEvents(agent.session)
expect(markers.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(markers[1]?.type === 'compact/end' && markers[1].data.error)
.toContain('summarizer unavailable')
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'runs after the failure' }],
source: { kind: 'user' },
}))
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
})
})
describe('compactNow transaction and failure classification', () => {
it('returns null without writing a bracket for history that cannot be compacted', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('empty'))
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
expect(await compact.compactNow(agent, SIGNAL)).toBeNull()
expect(released).toBe(1)
expect(compact.calls).toHaveLength(0)
expect(compactEvents(session)).toEqual([])
})
it('commits a standalone bracket without consuming a turn number and checkpoints durability', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2, 7)
const agent = fakeAgent(session, () => () => undefined)
const result = await compact.compactNow(agent, SIGNAL)
expect(result).not.toBeNull()
expect(flushes()).toBe(1)
expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7)
expect(session.events.findLast(event => event.type === 'compact/start')?.data)
.toEqual({ turn: null })
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toEqual({ turn: null })
})
it('reports a live unmatched bracket as busy without summarizing', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('compact/start', { turn: null })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('busy')
expect(error.message).toContain('compaction lock is already active')
expect(compact.calls).toHaveLength(0)
})
it('ignores an unmatched bracket inherited before a later end-seed marker', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
const orphan = reloaded.events.find(event => event.type === 'compact/start')
const agent = fakeAgent(reloaded, () => () => undefined)
expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
expect(compact.calls).toHaveLength(1)
})
it('scans a stale orphan independently of later repaired turn state', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
const agent = fakeAgent(reloaded, () => () => undefined)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
expect(compact.calls).toHaveLength(1)
})
it('refuses an open turn in the log', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = fakeAgent(session, () => () => undefined)
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('busy')
expect(error.message).toContain('already has an open turn')
})
it('reports busy and skips summarization when admission is unavailable', async () => {
const { compact } = detachedService()
const agent = fakeAgent(closedConversation(2), () => undefined)
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(0)
})
it('rejects a selected span replaced during summarization and records an error close', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
compact.duringSummary = () => {
const [head] = session.surface.nodes
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'competing replacement' }],
source: { kind: 'plugin', plugin: 'rival' },
}), {
surfaceOp: { op: 'replace', start: head!, end: head! },
sourceEventSeqs: [head!],
})
}
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
expect(released).toBe(1)
expect(flushes()).toBe(1)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
})
it('rejects a selected span whose middle node was replaced during summarization', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
const agent = fakeAgent(session, () => () => undefined)
compact.duringSummary = () => {
const middle = session.surface.nodes[1]
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'rewritten middle node' }],
source: { kind: 'plugin', plugin: 'rival' },
}), {
surfaceOp: { op: 'replace', start: middle!, end: middle! },
sourceEventSeqs: [middle!],
})
}
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('changed')
expect(causeOf(error).message).toContain('span changed during summarization')
})
it('revalidates the selected span after the summarizer continuation settles', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
const gate = deferred()
compact.gate = gate.promise
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
const head = session.surface.nodes[0]!
const generation = session.surface.replaceGeneration
const running = compact.compactNow(agent, SIGNAL)
await Promise.resolve()
expect(compact.calls).toHaveLength(1)
gate.resolve()
queueMicrotask(() => {
queueMicrotask(() => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late competing replacement' }],
source: { kind: 'plugin', plugin: 'rival' },
}), {
surfaceOp: { op: 'replace', start: head, end: head },
sourceEventSeqs: [head],
})
})
})
const error = await rejection(running)
expect(error.code).toBe('changed')
expect(causeOf(error).message).toContain('selected span')
expect(released).toBe(1)
expect(flushes()).toBe(1)
expect(session.surface.replaceGeneration).toBe(generation + 1)
expect(session.surface.nodes).not.toContain(head)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(session.events.some(event => event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source))).toBe(false)
})
it('classifies a failing compact/end as commit failure and leaves one orphan', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/end') throw new Error('boundary rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('boundary rejected')
vi.restoreAllMocks()
expect(flushes()).toBe(0)
expect(session.events.findLast(event => event.type.startsWith('compact/'))?.type)
.toBe('compact/summary')
expect(compactEvents(session).filter(event => event.type === 'compact/start')).toHaveLength(1)
const calls = compact.calls.length
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
expect(compact.calls).toHaveLength(calls)
})
it('keeps a failed error-close as the commit failure and does not flush', async () => {
const { compact, flushes } = detachedService()
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
compact.error = new Error('summary rejected')
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/end') throw new Error('error boundary rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
const error = await rejection(compact.compactNow(agent, SIGNAL))
vi.restoreAllMocks()
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('error boundary rejected')
expect(released).toBe(1)
expect(flushes()).toBe(0)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start'])
})
it('rejects a selected span whose pricing changed during summarization', async () => {
const { ctx, compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
const meter = ctx.tokenMeter
const original = meter.measure.bind(meter)
compact.duringSummary = () => {
vi.spyOn(meter, 'measure').mockImplementationOnce((target) => {
const measurement = original(target)
return {
...measurement,
nodes: measurement.nodes.map((node, index) =>
index === 0 ? { ...node, tokens: node.tokens + 1 } : node),
}
})
}
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('changed')
vi.restoreAllMocks()
})
it('classifies a commit-body failure and still releases admission', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/summary') throw new Error('provenance rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
const error = await rejection(compact.compactNow(agent, SIGNAL))
vi.restoreAllMocks()
expect(error.code).toBe('commit')
expect(released).toBe(1)
const end = session.events.findLast(event => event.type === 'compact/end')
expect(end?.type === 'compact/end' && end.data.error).toContain('provenance rejected')
expect(end?.type === 'compact/end' && end.data.turn).toBeNull()
})
it('keeps a commit failure when the durability checkpoint also fails', async () => {
const { ctx, compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
const append = session.append.bind(session)
vi.spyOn(session, 'append').mockImplementation(((type: string, ...rest: never[]) => {
if (type === 'compact/summary') throw new Error('provenance rejected')
return (append as (...args: never[]) => unknown)(type as never, ...rest)
}) as never)
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
const error = await rejection(compact.compactNow(agent, SIGNAL))
expect(error.code).toBe('commit')
expect(causeOf(error).message).toBe('provenance rejected')
vi.restoreAllMocks()
})
it('compacts a session with no durable turn boundary without creating one', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('turnless'))
for (const text of [PROMPT, 'recent tail']) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
const agent = fakeAgent(session, () => () => undefined)
const result = await compact.compactNow(agent, SIGNAL)
expect(result).not.toBeNull()
expect(session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(session.events.find(event => event.type === 'compact/start')?.data)
.toEqual({ turn: null })
})
it('classifies a durability failure after the standalone bracket committed', async () => {
const { ctx, compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
vi.spyOn(ctx.sessions, 'flush').mockRejectedValueOnce(new Error('disk full'))
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence')
vi.restoreAllMocks()
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toEqual({ turn: null })
})
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
const cases = [
{ name: 'busy', session: closedConversation(2), release: undefined },
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
] as const
for (const testCase of cases) {
const { ctx, compact } = detachedService()
const reserve = vi.fn(() => testCase.release)
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
const agent = fakeAgent(testCase.session, reserve)
const before = [...testCase.session.events]
const reason = Object.freeze({ kind: 'cancelled', case: testCase.name })
const controller = new AbortController()
controller.abort(reason)
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
expect(reserve).not.toHaveBeenCalled()
expect(measure).not.toHaveBeenCalled()
expect(compact.calls).toHaveLength(0)
expect(testCase.session.events).toEqual(before)
vi.restoreAllMocks()
}
})
it('preserves the exact cancellation reason when the summarizer also rejects', async () => {
const { compact, flushes } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled by the caller')
let released = 0
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => { released += 1 })
compact.duringSummary = () => { controller.abort(reason) }
compact.error = new Error('summarizer aborted')
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
expect(released).toBe(1)
expect(flushes()).toBe(1)
const events = compactEvents(session)
expect(events.map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(events[1]?.type === 'compact/end' && events[1].data.error)
.toContain('summarizer aborted')
})
it('aborts before committing when cancellation lands after summarization', async () => {
const { compact } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled by the caller')
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
compact.duringSummary = () => { controller.abort(reason) }
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
expect(compactEvents(session).map(event => event.type)).toEqual(['compact/start', 'compact/end'])
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})
it('waits for the durability checkpoint before cancellation wins and admission releases', async () => {
const { ctx, compact } = detachedService()
const controller = new AbortController()
const reason = new Error('cancelled during flush')
const flushGate = Promise.withResolvers<undefined>()
const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise)
const session = closedConversation(2)
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
const running = compact.compactNow(agent, controller.signal)
let settled = false
void running.then(
() => { settled = true },
() => { settled = true },
)
await vi.waitFor(() => {
expect(flush).toHaveBeenCalledWith(session)
})
controller.abort(reason)
await Promise.resolve()
expect(settled).toBe(false)
expect(released).toBe(0)
flushGate.resolve(undefined)
await expect(running).rejects.toBe(reason)
expect(released).toBe(1)
})
it('preserves raw output and usage in the manual summary event', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
compact.rawOutput = [
{ type: 'text', text: 'checkpoint' },
{ type: 'reasoning', text: 'hidden reasoning' },
]
compact.usage = { inputTokens: 40, outputTokens: 5 }
await compact.compactNow(agent, SIGNAL)
const summary = session.events.find(event => event.type === 'compact/summary')
expect(summary?.type === 'compact/summary' && summary.data.rawOutput).toEqual(compact.rawOutput)
expect(summary?.type === 'compact/summary' && summary.data.usage).toEqual(compact.usage)
})
it('makes duration derivable from the opening and closing marker times', async () => {
const { compact } = detachedService()
const session = closedConversation(2)
const agent = fakeAgent(session, () => () => undefined)
compact.gate = new Promise<undefined>((resolve) => {
setTimeout(() => { resolve(undefined) }, 5)
})
await compact.compactNow(agent, SIGNAL)
const start = session.events.findLast(event => event.type === 'compact/start')
const end = session.events.findLast(event => event.type === 'compact/end')
expect(start).toBeDefined()
expect(end).toBeDefined()
expect(end!.time - start!.time).toBeGreaterThan(0)
})
it('excludes concurrent automatic and manual compaction of one session', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
const agent = fakeAgent(session, () => () => undefined)
const gate = deferred()
compact.gate = gate.promise
const manual = compact.compactNow(agent, SIGNAL)
await Promise.resolve()
const nodes = session.surface.nodes
await expect(compact.compactRegion(
nodes[0]!,
nodes[1]!,
agent,
)).rejects.toThrow('compaction lock is already active')
gate.resolve()
compact.gate = undefined
const result: CompactionResult | null = await manual
expect(result).not.toBeNull()
})
it('excludes a manual request while an explicit region compaction runs', async () => {
const { compact } = detachedService()
const session = closedConversation(3)
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = fakeAgent(session, () => () => undefined)
const gate = deferred()
compact.gate = gate.promise
const nodes = session.surface.nodes
const region = compact.compactRegion(nodes[0]!, nodes[1]!, agent)
await Promise.resolve()
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
gate.resolve()
compact.gate = undefined
await expect(region).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 2) })
})
})

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/compact/compact/README.md
README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866
README.zh.md: 7763faad101a4f7f6f8034b76dff9284909667e2
README.md: cfb65f2a786dd58d38a7020a8caefeb3d7372f52
README.zh.md: e069bea9ef40d2e1ba7beead5b76324cfd56b839

View File

@@ -10,22 +10,25 @@ This package is the interface tier of the compaction capability, split so each c
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers |
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
| `@deepseek-ai/dsh-command-compact` | the human `/compact` command over `ctx.compact.compactNow()` |
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
## Service API (`ctx.compact`)
Both methods are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
All three operations are **abstract** — the backend owns trigger policy, retention, event sequencing, and summarization. Reusable request measurement is a separate service, [`ctx.tokenMeter`](../../llm/token-meter/README.md), rather than part of this interface.
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
`ManualCompactionError.code` is the closed set `busy | changed | summary | commit | persistence`. `changed` and `summary` mean the selected conversation surface was not replaced, but their failed attempt is still recorded in the session log. `commit` is deliberately neutral about partial mutation, and `persistence` means the in-memory bracket closed but its explicit flush failed.
## Tool-pairing boundaries
@@ -45,11 +48,15 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
The marker pair names lock acquisition and release, not an exclusive event container. An idle `inject()` may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.
`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
## Blocking
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
Compaction is serialized by one log-recorded lock shared by all entry points. Tail inspection independently finds the latest unmatched `compact/start` and the newest `session/end-seed`. An unmatched start after that boundary is live and reports `busy`; an older unmatched start is stale evidence from a prior process lifecycle and does not block. The same end-seed transition clears the invariant companion's replay trace. A live bracket cannot cross a `turn/start` or `turn/end`; during adoption, repair boundaries in the inherited prefix remain replayable when the later end-seed proves their open bracket stale.
The lock is the durable bracket, not a `WeakSet`, wrapper mutex, or client-side anchor. `compact/start` is appended synchronously before summarization yields. Every later failure makes exactly one `compact/end { error }` attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted. A successfully closed manual attempt is flushed even when it reports `changed` or `summary`, preserving the recorded attempt before turn admission is released.
## Events
@@ -57,7 +64,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
## Implementing a backend
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Recognizing a checkpoint outside the host program (`./checkpoint`)
@@ -81,6 +88,6 @@ A successful backend replacement invalidates reuse from the first shadowed histo
## Known Limitations and Deferred Work
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
- **Human command, not a model tool** — `@deepseek-ai/dsh-command-compact` exposes argument-free `/compact` through `ctx.commands`; no model-facing compaction tool is registered.
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.

View File

@@ -10,22 +10,25 @@
|---|---|
| `@deepseek-ai/dsh-compact`(本包) | 接口:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper |
| `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-tool-compact`(暂缓) | 面向模型的 `/compact` 工具,基于 `ctx.compact` 实现 |
| `@deepseek-ai/dsh-command-compact` | 面向用户的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session` 和 `@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的,并记录在 [压缩能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
## 服务 API(`ctx.compact`)
两个方法都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
三个操作都是**抽象方法**:触发策略、保留、事件顺序与摘要均属于后端。可复用的请求测量是独立服务 [`ctx.tokenMeter`](../../llm/token-meter/README.md),而非本接口的一部分。
| 成员 | 语义 |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 |
| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 |
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。
`compactIfNeeded` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放) 会停止进行中的摘要,不会留下越过取消时点继续运行的遗留模型调用。可以从所拥有会话的日志(当前尚未结束的轮次)恢复 `compact/*` 事件所属轮次,因此后端从日志中标记该值,而不信任调用方提供的值。
`compactIfNeeded` 和 `compactNow` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放)会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`。
`ManualCompactionError.code` 是封闭集合 `busy | changed | summary | commit | persistence`。`changed` 和 `summary` 表示所选会话表层未被替换,但日志仍会记录失败尝试。`commit` 有意不判断是否发生了部分变更;`persistence` 表示内存中的 bracket 已闭合,但显式 flush 失败。
## 工具配对边界
@@ -45,11 +48,15 @@
表层变更(第 4 步)位于锁的起止范围**内**:`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start` 与 `compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`。
这对标记表示获取和释放锁的时间点,并非排他的事件容器。手动摘要等待期间,空闲的 `inject()` 可以在 start 与 end 之间追加不相关的上下文。因此,手动稳定性检查会重新验证所选 span,而不要求整个表层相等;位置替换会让该注入上下文在检查点之后保持可见。自动压缩则要求其活动轮次内的整个表层保持相等。
`deriveMessages()` 随后将摘要渲染为 user 角色消息,再跟上已保留节点。已遮蔽事件仍保留在原始日志中,因此回放具有确定性。
## 阻塞
压缩通过日志记录的锁串行化:`compactRegion` 会拒绝启动,条件是最后一个 `compact/start` 之后没有匹配的 `compact/end`。锁由日志记录(而非内存 mutex),因此回放后仍然有效,持久化后端也可以在重新加载时检测遗留 `compact/start`。锁会覆盖**整个**操作:摘要、`compact/summary` 溯源记录*以及* `user/message` 表层替换全部发生在 `compact/end` 之前,因此 `session/event` listener 即使在 `compact/end` 时触发,也绝不会看到锁已释放而表层变更仍在等待。基础后端会在摘要后重新验证已选表层:表层变更会导致拒绝,不相关的仅日志追加不会使替换失效。即使摘要抛出异常,也会追加 `compact/end`,因此失败绝不会将锁卡死。
压缩由所有入口点共享的一个日志记录锁串行化。尾部检查会分别查找最新的未匹配 `compact/start` 和最新的 `session/end-seed`。位于该边界之后的未匹配 start 是活动锁并报告 `busy`;更早的未匹配 start 是先前进程生命周期留下的陈旧证据,不会阻塞。同一个 end-seed 转换会清除不变量配套组件的回放追踪状态。活动标记对不能跨越 `turn/start` 或 `turn/end`;在接管会话时,如果后续 end-seed 证明打开的标记对已经陈旧,则继承前缀中的修复边界仍可回放。
锁就是持久标记对,而非 `WeakSet`、包装层 mutex 或客户端侧锚点。`compact/start` 会在摘要让出控制权之前同步追加。之后每次失败都会恰好尝试一次 `compact/end { error }`;如果追加该闭合事件本身失败,未匹配 start 会继续作为有意保留的 busy 信号,并且不会尝试 flush。已成功闭合的手动尝试即使报告 `changed` 或 `summary` 也会 flush,从而在释放轮次接纳预留前保留该记录。
## 事件
@@ -57,7 +64,7 @@
## 实现后端
继承 `CompactService`,实现 `compactIfNeeded` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`)
@@ -81,6 +88,6 @@
## 已知限制与暂缓事项
- **尚无面向模型的消费方层**:`@deepseek-ai/dsh-tool-compact`(`/compact` 工具)已暂缓;只能通过直接 `ctx.compact` 调用或后端的自动 listener 进行压缩。
- **面向用户的命令,而非模型工具**:`@deepseek-ai/dsh-command-compact` 通过 `ctx.commands` 暴露无参数 `/compact`;不会注册面向模型的压缩工具。
- **部分单元溢出不在契约内**:平衡摘要压缩无法拆分一个不可分单元。当闭合工具对中可移除的主要部分是承载文本的工具结果时,可选剪枝配套服务仍可修复该工具对;无法压缩大型非工具节点,或不可剪枝剩余部分过大的工具单元。
- **单独接近窗口大小的 envelope 不属于表层压缩工作**:压缩缩减派生历史,绝不缩减系统提示词、工具或会话前缀。

View File

@@ -21,12 +21,47 @@ export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoi
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
/** Expected failure classes for an explicit idle-session compaction request. */
export type ManualCompactionErrorCode = 'busy' | 'changed' | 'summary' | 'commit' | 'persistence'
/**
* Expected manual-compaction failure suitable for a direct human-command result.
* Shared durable-lock entry assertions may also throw the `busy` subtype from
* automatic compaction paths.
*/
export class ManualCompactionError extends Error {
override readonly name = 'ManualCompactionError'
/**
* Create one classified compaction failure.
* @param code - stable failure class; `busy` may originate from any compaction entry path.
* @param message - backend diagnostic retained as the Error message.
* @param options - optional original failure.
*/
constructor(
readonly code: ManualCompactionErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
session: Session
options: { provider?: string; model?: string }
}
/**
* Agent capability required to serialize an explicit idle-session compaction
* against driver turns. The durable `compact/start` marker separately excludes
* other compaction transactions.
*/
export interface ManualCompactAgentContext extends CompactAgentContext {
reserveTurnAdmission(): (() => void) | undefined
}
declare module 'cordis' {
interface Context {
compact: CompactService
@@ -65,6 +100,29 @@ export abstract class CompactService extends Service {
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Explicitly compact useful history even below automatic pressure thresholds.
* Implementations reserve idle turn admission synchronously before any
* asynchronous work, select a useful range without writing on a no-op, then
* append a standalone `compact/start` before summarization. That durable
* marker is the compaction lock until one `compact/end` attempt. Later waking
* prompts remain accepted in FIFO order and start only after the optional
* durability checkpoint and admission release. Context injected while the
* summary runs may sit between the marker pair; only the selected span must
* remain stable.
*
* @param agent - idle agent whose durable history should be compacted.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the compaction result, or `null` when no safe useful range exists.
* @throws {@link ManualCompactionError} for expected busy, changed-span,
* summarization/shrink, commit-stage, or persistence failures, and the exact
* abort reason when cancelled. Failed attempts remain visible in the log.
*/
abstract compactNow(
agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null>
/**
* Forcibly compact a range of surface nodes into a single summary node.
* `start` and `end` name an inclusive span by surface position, not numeric seq

View File

@@ -13,7 +13,8 @@ export const name = 'compact-invariant'
export const inject = ['invariants']
interface CompactionTrace {
turn: number
startSeq: number
turn: number | null
summarized: boolean
}
@@ -23,9 +24,73 @@ interface SessionTrace {
}
type CompactionTransition =
| { kind: 'start'; turn: number }
| { kind: 'summary'; turn: number }
| { kind: 'start'; startSeq: number; turn: number | null }
| { kind: 'summary'; startSeq: number; turn: number | null }
| { kind: 'end' }
| { kind: 'end-seed' }
/** Compaction starts still unmatched when a later seed boundary made them stale. */
function inheritedOrphanStartSeqs(
events: readonly SessionEvent[],
): ReadonlySet<number> {
const stale = new Set<number>()
let openStartSeq: number | undefined
for (const event of events) {
if (event.type === 'compact/start') {
openStartSeq = event.seq
} else if (event.type === 'compact/end') {
openStartSeq = undefined
} else if (event.type === 'session/end-seed') {
if (openStartSeq !== undefined) stale.add(openStartSeq)
openStartSeq = undefined
}
}
return stale
}
/** Keep every live compaction bracket on one side of each turn boundary. */
function validateTurnBoundary(
trace: SessionTrace,
event: SessionEvent,
fail: InvariantFailure,
): void {
if (
(event.type !== 'turn/start' && event.type !== 'turn/end')
|| trace.compaction === undefined
) return
const owner = trace.compaction.turn === null
? 'standalone compaction'
: `compaction for turn ${trace.compaction.turn}`
fail(`${event.type} cannot cross an open ${owner}`)
}
/** Advance the committed turn cursor after its boundary has been accepted. */
function applyTurnBoundary(trace: SessionTrace, event: SessionEvent): boolean {
if (event.type === 'turn/start') {
trace.openTurn = event.data.turn
return true
}
if (event.type === 'turn/end') {
trace.openTurn = null
return true
}
return false
}
/** Require a numbered bracket inside its exact turn, or a standalone bracket between turns. */
function validateOwner(
owner: number | null,
openTurn: number | null,
eventType: 'compact/start' | 'compact/summary' | 'compact/end',
fail: InvariantFailure,
): void {
if (owner === null) {
if (openTurn !== null) fail(`${eventType} is standalone but turn ${openTurn} is open`)
return
}
if (openTurn === null) fail(`${eventType} for turn ${owner} appended outside any open turn`)
if (owner !== openTurn) fail(`${eventType} names turn ${owner} but open turn is ${openTurn}`)
}
/** Validate one compaction event without advancing committed trace state. */
function validateCompactionEvent(
@@ -33,23 +98,22 @@ function validateCompactionEvent(
event: SessionEvent,
fail: InvariantFailure,
): CompactionTransition | undefined {
if (event.type === 'session/end-seed') return { kind: 'end-seed' }
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') {
return undefined
}
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`)
const open = trace.compaction
if (event.type === 'compact/start') {
if (open !== undefined) fail(`compact/start for turn ${event.data.turn} while turn ${open.turn} is still compacting`)
if (event.data.turn !== trace.openTurn) {
fail(`compact/start names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
if (open !== undefined) {
const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}`
fail(`compact/start while ${owner} is still compacting`)
}
return { kind: 'start', turn: event.data.turn }
validateOwner(event.data.turn, trace.openTurn, event.type, fail)
return { kind: 'start', startSeq: event.seq, turn: event.data.turn }
}
if (event.type === 'compact/summary') {
if (open === undefined) fail('compact/summary has no matching compact/start')
if (open.turn !== trace.openTurn) {
fail(`compact/summary belongs to turn ${open.turn} but open turn is ${trace.openTurn}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (open.summarized) fail('compact/summary repeated within one compaction')
const seqs = event.data.shadowedSeqs
if (seqs.length === 0) fail('compact/summary shadowedSeqs must be non-empty')
@@ -59,15 +123,13 @@ function validateCompactionEvent(
if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) {
fail('compact/summary shadowedTokenCount must be a non-negative safe integer')
}
return { kind: 'summary', turn: open.turn }
return { kind: 'summary', startSeq: open.startSeq, turn: open.turn }
}
if (open === undefined) fail('compact/end has no matching compact/start')
if (event.data.turn !== open.turn) {
fail(`compact/end turn ${event.data.turn} does not match compact/start turn ${open.turn}`)
}
if (event.data.turn !== trace.openTurn) {
fail(`compact/end names turn ${event.data.turn} but open turn is ${trace.openTurn}`)
fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`)
}
validateOwner(open.turn, trace.openTurn, event.type, fail)
if (event.data.error === undefined && !open.summarized) {
fail('successful compact/end requires one compact/summary')
}
@@ -78,8 +140,20 @@ function validateCompactionEvent(
function applyCompactionTransition(
transition: CompactionTransition,
): CompactionTrace | undefined {
if (transition.kind === 'start') return { turn: transition.turn, summarized: false }
if (transition.kind === 'summary') return { turn: transition.turn, summarized: true }
if (transition.kind === 'start') {
return {
startSeq: transition.startSeq,
turn: transition.turn,
summarized: false,
}
}
if (transition.kind === 'summary') {
return {
startSeq: transition.startSeq,
turn: transition.turn,
summarized: true,
}
}
return undefined
}
@@ -92,11 +166,20 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
const seed = (session: Session): SessionTrace => {
const trace: SessionTrace = { openTurn: null, compaction: undefined }
traces.set(session, trace)
const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events)
for (const event of session.events) {
if (event.type === 'turn/start') trace.openTurn = event.data.turn
else if (event.type === 'turn/end') trace.openTurn = null
// Constructor-seed repair boundaries can precede the end-seed marker
// that proves an inherited orphan stale. Replay that inherited prefix
// without letting the soon-to-be-cleared bracket veto its repair.
if (
trace.compaction === undefined
|| !staleOrphanStartSeqs.has(trace.compaction.startSeq)
) {
validateTurnBoundary(trace, event, fail)
}
const transition = validateCompactionEvent(trace, event, fail)
if (transition !== undefined) trace.compaction = applyCompactionTransition(transition)
applyTurnBoundary(trace, event)
}
return trace
}
@@ -106,15 +189,12 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('session/event', (session, event) => {
const trace = traceFor(session)
if (event.type === 'turn/start') {
trace.openTurn = event.data.turn
return
}
if (event.type === 'turn/end') {
trace.openTurn = null
return
}
if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') return
validateTurnBoundary(trace, event, fail)
if (applyTurnBoundary(trace, event)) return
if (event.type !== 'session/end-seed'
&& event.type !== 'compact/start'
&& event.type !== 'compact/summary'
&& event.type !== 'compact/end') return
const candidate = staged.get(event)
/* v8 ignore next -- internal/dispatch stages every compaction event */
if (candidate === undefined || candidate.session !== session) return fail('compaction event published without pre-commit validation')
@@ -124,7 +204,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const transition = validateCompactionEvent(traceFor(session), event, fail)
const trace = traceFor(session)
validateTurnBoundary(trace, event, fail)
const transition = validateCompactionEvent(trace, event, fail)
if (transition !== undefined) staged.set(event, { session, transition })
}, { global: true })
}, { inject: ['sessions'] })

View File

@@ -11,8 +11,12 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
'compact/start': { turn: number }
/**
* Marks the start of a compaction — log-only, holds the lock until
* `compact/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compact/start': { turn: number | null }
/**
* Provenance record of a completed summarization — log-only, no surfaceOp.
* The summary content is in `data.summary`; the actual surface replacement
@@ -40,8 +44,11 @@ declare module '@deepseek-ai/dsh-session' {
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
}
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
'compact/end': { turn: number; error?: string }
/**
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.
*/
'compact/end': { turn: number | null; error?: string }
}
}

View File

@@ -9,6 +9,7 @@ import {
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
import type { ManualCompactAgentContext } from '@deepseek-ai/dsh-compact'
/**
* A trivial concrete CompactService implementing the abstract contract. The
@@ -29,6 +30,14 @@ class StubCompactService extends CompactService {
return null
}
override async compactNow(
_agent: ManualCompactAgentContext,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
return null
}
override async compactRegion(
start: number,
end: number,
@@ -98,6 +107,12 @@ describe('CompactService seam', () => {
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
const signal = new AbortController().signal
expect(await svc.compactNow({
...stubAgent(session),
reserveTurnAdmission: () => () => undefined,
}, signal)).toBeNull()
expect(svc.lastSignal).toBe(signal)
})
it('compact/* events merge into SessionEventMap and are log-only', async () => {

View File

@@ -41,6 +41,103 @@ describe('compaction invariants', () => {
failed.append('compact/end', { turn: 2, error: 'provider failed' })
})
it('accepts standalone successful and failed compaction lifecycles between turns', async () => {
const ctx = await setup()
const success = ctx.sessions.create()
success.append('compact/start', { turn: null })
success.append('compact/summary', summary())
success.append('compact/end', { turn: null })
const failed = ctx.sessions.create()
failed.append('compact/start', { turn: null })
failed.append('compact/end', { turn: null, error: 'provider failed' })
})
it('clears an inherited open compaction trace at end-seed during replay', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-compaction-source'))
source.append('compact/start', { turn: null })
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type))
.toEqual(['compact/start', 'session/end-seed'])
await ctx.plugin(InvariantService)
await ctx.plugin(CompactInvariant)
expect(() => {
replayed.append('compact/start', { turn: null })
replayed.append('compact/end', { turn: null, error: 'new attempt failed' })
}).not.toThrow()
})
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-numbered-compaction-source'))
startTurn(source)
source.append('compact/start', { turn: 1 })
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type))
.toEqual(['turn/start', 'compact/start', 'session/end-seed'])
await ctx.plugin(InvariantService)
await ctx.plugin(CompactInvariant)
expect(() => replayed.append(
'turn/end',
{ turn: 1, reason: { kind: 'interrupted' } },
)).not.toThrow()
})
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-repaired-compaction-source'))
source.append('compact/start', { turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.map(event => event.type)).toEqual([
'compact/start',
'turn/start',
'turn/end',
'session/end-seed',
])
await ctx.plugin(InvariantService)
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).resolves.toBeUndefined()
expect(() => {
startTurn(replayed, 2)
replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('closed-nested-compaction-source'))
source.append('compact/start', { turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
seed: source.events,
})
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
await ctx.plugin(InvariantService)
await expect(ctx.plugin(CompactInvariant).then(() => undefined))
.rejects.toThrow(/turn\/start cannot cross an open standalone compaction/)
})
it('rebuilds an open trace when the companion loads after the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -78,6 +175,26 @@ describe('compaction invariants', () => {
expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/)
})
it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => {
const ctx = await setup()
const open = ctx.sessions.create()
startTurn(open)
expect(() => open.append('compact/start', { turn: null }))
.toThrow(/standalone but turn 1 is open/)
const idle = ctx.sessions.create()
expect(() => idle.append('compact/start', { turn: 1 }))
.toThrow(/outside any open turn/)
})
it('attributes a nested standalone start to the standalone owner', async () => {
const ctx = await setup()
const session = ctx.sessions.create()
session.append('compact/start', { turn: null })
expect(() => session.append('compact/start', { turn: null }))
.toThrow(/standalone compaction is still compacting/)
})
it('rejects an unenclosed compaction event when replaying an existing session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -89,23 +206,30 @@ describe('compaction invariants', () => {
await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
})
it('rejects an open compaction that crosses into another turn', async () => {
it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => {
const ctx = await setup()
const summarySession = ctx.sessions.create()
startTurn(summarySession)
summarySession.append('compact/start', { turn: 1 })
summarySession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
startTurn(summarySession, 2)
expect(() => summarySession.append('compact/summary', summary()))
.toThrow(/belongs to turn 1 but open turn is 2/)
const standalone = ctx.sessions.create()
standalone.append('compact/start', { turn: null })
expect(() => { startTurn(standalone) })
.toThrow(/turn\/start cannot cross an open standalone compaction/)
standalone.append('compact/end', { turn: null, error: 'cancelled' })
expect(() => {
startTurn(standalone)
standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
const endSession = ctx.sessions.create()
startTurn(endSession)
endSession.append('compact/start', { turn: 1 })
endSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
startTurn(endSession, 2)
expect(() => endSession.append('compact/end', { turn: 1, error: 'late' }))
.toThrow(/names turn 1 but open turn is 2/)
const numbered = ctx.sessions.create()
startTurn(numbered)
numbered.append('compact/start', { turn: 1 })
expect(() => numbered.append(
'turn/end',
{ turn: 1, reason: { kind: 'completed' } },
)).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
numbered.append('compact/end', { turn: 1, error: 'cancelled' })
expect(() => numbered.append(
'turn/end',
{ turn: 1, reason: { kind: 'completed' } },
)).not.toThrow()
})
it.each([

View File

@@ -50,6 +50,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -106,6 +106,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
session.append('user/message', input, { surfaceOp: 'append' })
},
send: () => {},
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

View File

@@ -185,6 +185,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
cancel() {},
whenIdle: () => Promise.resolve(),
}

Some files were not shown because too many files have changed in this diff Show More