diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 26108d6850..cede7ea5d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 558d60cf6f6638c3e776396dd754d31b95819b28 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 2eb216fd1970700fb54a2aa17ec5ce515869e5b0 +2026-07-30-web-transcript-log-ordered-projection.md: 3b7aaeb1178ff79e38a1b9646a9dc78efaeae48b +2026-07-30-web-transcript-log-ordered-projection.zh.md: acd198b6d3f3c5d57e233e09ee66f5d151e4f8f1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 558d60cf6f..3b7aaeb117 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -18,9 +18,11 @@ Node order is seq-monotonic by construction, and three things follow. The log-on `foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended. -The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text. +The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves them. -No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. +The [manual compaction command](../feature/2026-07-30-queued-manual-compaction.md) returns the summary event's seq as the successful `CommandResult.sourceEventSeq`, and `command/done` persists that optional reference. Chat pairs only a successful named `/compact` command whose reference equals exactly one loaded `CompactionSummaryNode.summaryEventSeq`. The running command first renders `compact · Compacting context…`; after the checkpoint lands, the same React key renders one collapsed `compact` disclosure at the checkpoint's flow position with the count and token estimate. Input rejection, no compactable history, cancellation, and failure remain generic command rows with complete handler-authored text. Automatic compaction has no command reference and keeps the standalone context-compacted marker. + +The explicit event reference matters because manual compaction permits durable context injection while its asynchronous summary is running: command and checkpoint rows are not guaranteed to be adjacent. The command lifecycle event gains one optional field, but the compaction transaction, RPC envelope, and model-visible surface do not change; pre-release persisted logs without the field keep the former two-row soft-fall and require no migration. ## Recognizing a checkpoint: one declaration, pinned at compile time @@ -57,6 +59,10 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b **Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract. +**Pair the nearest `/compact` row with the next checkpoint.** Rejected: context injection may land between them, and concurrent or malformed lifecycle records must degrade without stealing another checkpoint. The command result instead names the authoritative summary event, and ambiguous references pair nothing. + +**Parse the English settlement text for item and token counts.** Rejected: handler copy is presentation text, not a stable data contract. The marker reads the structured `compact/summary` payload already owning both values. + ## Consequences Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files. @@ -65,8 +71,8 @@ Compaction no longer erases web history; a session compacted several times shows The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. -The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface. +The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface. ## Deferred -The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress. +The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 2eb216fd19..acd198b6d3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -18,9 +18,11 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 `foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。 -标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。 +标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出它们。 -没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。 +[手动压缩命令](../feature/2026-07-30-queued-manual-compaction.md)会把摘要事件的 seq 作为成功结果的 `CommandResult.sourceEventSeq` 返回,`command/done` 则持久化这项可选引用。Chat 只会配对成功且名称为 `/compact`、其引用恰好等于唯一一个已加载 `CompactionSummaryNode.summaryEventSeq` 的命令。运行中的命令先渲染为 `compact · Compacting context…`;检查点落地后,同一个 React key 会在检查点的消息流位置渲染一条收起的 `compact` 展开项,并显示条目数量和 token 估算值。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行,并保留处理器撰写的完整文本。自动压缩没有命令引用,继续使用独立的上下文已压缩标记。 + +显式事件引用之所以重要,是因为手动压缩允许在异步摘要运行期间注入持久上下文:命令行与检查点行不保证相邻。命令生命周期事件增加一个可选字段,但压缩事务、RPC 信封和模型可见 surface 均不变化;不含该字段的预发布持久日志继续采用原先的两行软退让,无须迁移。 ## 识别检查点:同一份声明,在编译期钉住 @@ -57,6 +59,10 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' **把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。 +**把最近的 `/compact` 行与下一个检查点配对。** 已拒绝:两者之间可能落入上下文注入,并发或格式异常的生命周期记录也必须降级而不误取其他检查点。命令结果则指明权威摘要事件;引用存在歧义时不配对任何内容。 + +**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据契约。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。 + ## Consequences 压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。 @@ -65,8 +71,8 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。 -Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。 +Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。 ## Deferred -终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。 +终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 11ba23a99f..6a8ff82ff1 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -620,8 +620,9 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id } /** - * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and - * decode-throughput volatility collapse to stable tokens. + * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, + * decode-throughput, and path-sensitive compaction estimates collapse to + * stable tokens. * * Throughput needs a token for the same reason durations do, and no fixture * can supply one: the figure divides a replayed step's output tokens by the @@ -648,6 +649,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { duration => duration.startsWith('约') ? duration : '{{duration}}', ) .replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}') + // Seeded compaction prices realized file paths, whose length differs + // between local worktrees and CI scratch directories. + .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2') // Message IconActions clocks widen by calendar day/year; collapse every // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 799503ef1c..fac240fbe1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -4,8 +4,9 @@ // history RPC, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). The cold session also carries the one -// keyless command-row surface: an Access-chip pick runs `/permission` on the -// host, so the settled row's copy has a golden here. The seed is a recorded +// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds +// into its checkpoint, while an Access-chip pick later runs `/permission` on +// the host. The seed is a recorded // fixture under the // same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) @@ -39,18 +40,18 @@ const SEED_ID = 'seeded-history-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' /** - * Append a complete, valid compaction transaction over the recorded turn's own - * surface. The recording stays model-authentic and reusable; replay adds this - * deterministic condition before seeding it cold, so the scenario pins the bug - * this change fixes — a landed compaction must not erase history the reader - * already saw — through the real host and the real browser. + * Append a complete manual `/compact` lifecycle and valid compaction transaction + * over the recorded turn's own surface. The recording stays model-authentic and + * reusable; replay adds this deterministic condition before seeding it cold, so + * the scenario pins both the log-preserving marker and its single-card command + * presentation through the real host and browser. * @param raw - the seed fixture text, already realized (placeholder-free) so * the shadow price below is computed from the exact strings the host folds. * @param meter - the composed token meter; the appended `compact/summary`'s * shadow price must be the exact heuristic price of the shadowed nodes, the * way compact-basic derives it, because the token-meter projections subtract * it verbatim. - * @returns the fixture with a compacted turn appended. + * @returns the fixture with a manual compaction lifecycle appended. */ function withCompaction(raw: string, meter: TokenMeterService): string { const lines = raw.trimEnd().split('\n') @@ -73,14 +74,10 @@ function withCompaction(raw: string, meter: TokenMeterService): string { if (first === undefined || last === undefined || tail === undefined) { throw new Error('seeded-history compaction requires a non-empty closed surface') } - // The transaction opens the turn after the recording's last closed one; read - // it from the fixture so a re-recording with a different turn count stays - // valid instead of appending a duplicate turn number. const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn if (typeof lastTurn !== 'number') { throw new Error('seeded-history compaction requires a recording ending on a closed turn') } - const turn = lastTurn + 1 let seq = tail.seq + 1 let time = tail.time + 1 /** @@ -93,8 +90,12 @@ function withCompaction(raw: string, meter: TokenMeterService): string { lines.push(JSON.stringify({ ...event, seq: taken, time: time++ })) return taken } - at({ type: 'turn/start', data: { turn } }) - const startSeq = at({ type: 'compact/start', data: { turn } }) + const commandId = 'cmd-seeded-manual-compact' + at({ + type: 'command/run', + data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, + }) + const startSeq = at({ type: 'compact/start', data: { turn: null } }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -146,8 +147,21 @@ function withCompaction(raw: string, meter: TokenMeterService): string { surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn } }) - at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'command/done', + data: { + commandId, + kind: 'success', + text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`, + sourceEventSeq: summarySeq, + }, + }) + // The persistence seed helper requires a terminal turn/end. Keep the manual + // command standalone, then add a closed zero-step fixture boundary after it. + const closureTurn = lastTurn + 1 + at({ type: 'turn/start', data: { turn: closureTurn } }) + at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } }) return `${lines.join('\n')}\n` } @@ -239,7 +253,11 @@ describe('web e2e: seeded history renders through cold resume', () => { await sessionRow.click() // Settled barrier for history: the recorded final assistant text renders. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { + timeout: 10_000, + }).toBe(1) + expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). const toolRows = page.locator('[data-variant], [data-sample]') @@ -363,7 +381,7 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) - const marker = page.getByRole('button', { name: /Context compacted/ }) + const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) await marker.waitFor({ timeout: 10_000 }) expect(await marker.getAttribute('aria-expanded')).toBe('false') await marker.click() diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index fe58587913..3aa9e2d738 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -31,9 +31,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": - - img - - text: Context compacted View compaction summary +- button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index ce2921eac2..a30ae29e1e 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,9 +31,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Context compacted View compaction summary": - - img - - text: Context compacted View compaction summary +- button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img - img diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6b79018c22..5a43d60d96 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -343,7 +343,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:172`](../../packages/ui/commands/src/index.ts) ## `credentials/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8820a0329c..c44efaafa4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -451,7 +451,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise Number.isSafeInteger(seq) && (seq as number) >= 0) + ? shadowedSeqs.length + : null, + shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 + ? tokenCount as number + : null, + } +} + /** * One landed checkpoint -> the human-facing compaction marker. The summary text * comes from the checkpoint's own provenance (`sourceEventSeqs` names the @@ -172,13 +195,28 @@ function materializeCompaction( ): CompactionSummaryNode { const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs let summary: string | null = null + let summaryEventSeq: number | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null for (const seq of sources ?? []) { const candidate = eventIndex.get(seq) if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - summary = compactSummaryText(candidate) + const details = compactSummaryDetails(candidate) + summary = details.summary + summaryEventSeq = candidate.seq + shadowedItemCount = details.shadowedItemCount + shadowedTokenCount = details.shadowedTokenCount break } - return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary } + return { + kind: 'compaction', + seq: checkpoint.seq, + time: checkpoint.time, + summary, + summaryEventSeq, + shadowedItemCount, + shadowedTokenCount, + } } /** Log-ordered human transcript over a paged raw event window (never consults surface order). */ @@ -323,9 +361,22 @@ export class TranscriptAdapter { return true } if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string } + const data = event.data as unknown as { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } const run = this.commandIdx.get(data.commandId) - const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } } + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + const outcome = { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } if (run === undefined) { // Cross-window cut: the run page fell out of the window — build the // node from the done alone (same soft-fall as a call-less tool result). diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts index ddc6c8adc5..aed658af51 100644 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => { it('recognizes a checkpoint carrying the seam-canonical source', () => { const adapter = new TranscriptAdapter() adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it("agrees with the seam's own predicate on the source it recognizes", () => { diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 51e982e8f4..bc3c10e762 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -92,8 +92,19 @@ export const ev = { at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }), - commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => - at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }), + commandDone: ( + seq: number, + commandId: string, + kind: 'success' | 'error' = 'success', + text?: string, + sourceEventSeq?: number, + ): SessionEvent => + at(seq, { type: 'command/done', data: { + commandId, + kind, + ...text === undefined ? {} : { text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + } }), /** A compaction's log-only `compact/summary` provenance record. */ compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent => at(seq, { type: 'compact/summary', data: { diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index a5b423c58d..05dc2855ca 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -245,8 +245,14 @@ describe('TranscriptAdapter', () => { checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), ]) expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' }, - { kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, + { + kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -318,7 +324,7 @@ describe('TranscriptAdapter', () => { ...(summary === undefined ? [] : [summary]), checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) - expect(adapter.nodes()).toEqual([ + expect(adapter.nodes()).toMatchObject([ { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, ]) }) @@ -332,7 +338,10 @@ describe('TranscriptAdapter', () => { checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), ]) expect(adapter.nodes()).toEqual([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' }, + { + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', + summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, + }, ]) }) @@ -346,7 +355,10 @@ describe('TranscriptAdapter', () => { source: { kind: 'plugin', plugin: 'compact' }, }), })]) - expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }]) + expect(adapter.nodes()).toEqual([{ + kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, + summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, + }]) }) it('skips a non-summary provenance seq before reaching the real one', () => { @@ -490,20 +502,22 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) }) - it('renders the /compact row alongside the marker its own command produced', () => { - // The row that reports the compaction is a command node; dropping command - // folding would delete it together with every other slash-command row. + it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { const adapter = new TranscriptAdapter() adapter.reset([ ev.user(0, '压缩前的问题'), ev.commandRun(1, 'cmd-compact', 'compact'), compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩'), + ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), ]) const nodes = adapter.nodes() expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } }) + expect(nodes[1]).toMatchObject({ + name: 'compact', + outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, + }) + expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b6bf25d410..db7db5db5f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013 -README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17 +README.md: 6b541b840ed67ee6fd735a0643dde8c60f1ec22d +README.zh.md: 01692c395cdb0f50e0fd41ab92f51d9e3ceecb4f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0cf50146cc..6b541b840e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. @@ -64,7 +64,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bfb96bb93..01692c395c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 @@ -64,7 +64,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu ## 已知限制与暂缓事项 -- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..18fba234de 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' @@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec /** One command lifecycle row: keyed dispatch on the command name with the * generic card as the render-site fallback (zero registration required). A * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { +const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { renderSlot: RenderToolRow node: CommandNode + compaction?: Extract t: ChatViewSlotProps['t'] }) { - const owner = useMemo(() => ({ node }), [node]) + const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) + const fallback = node.name === 'compact' + ? + : return (
{renderSlot('conversation.chat.commandview', owner, { entryKey: node.name ?? '', - fallback: , + fallback, })}
) @@ -580,6 +585,16 @@ export function ChatView({ /> ) } + if (item.kind === 'command-compaction') { + return ( + + ) + } const node: ConversationNode = item.node if (node.kind === 'assistant') { const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined @@ -642,9 +657,17 @@ export function ChatView({
{renderItem(item)}
diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx new file mode 100644 index 0000000000..8012834541 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -0,0 +1,40 @@ +// CompactionCommandCard: the `/compact` command's running row and its +// successful checkpoint disclosure. Outcomes without a checkpoint keep the +// generic command card so no-history, cancellation, and failures retain their +// complete handler-authored text. + +import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' +import { CompactionItem } from './CompactionItem.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import { ToolRow } from './ToolRow.tsx' + +interface CompactionCommandCardProps extends CommandRowOwnerProps { + t: ChatViewSlotProps['t'] +} + +/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */ +export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) { + if (compaction !== undefined) { + return ( + + ) + } + if (node.outcome !== null) return + return ( + } + title="compact" + summary={t('message.compaction.running')} + body={null} + state="running" + /> + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 82922dd97e..5bd5a081cf 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -9,6 +9,7 @@ import { memo, useState } from 'react' import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client' import { + IconApiOutline14, IconChevronDownOutline14, IconChevronRightOutline14, MarkdownText, @@ -18,6 +19,10 @@ import css from './MessageItem.module.css' interface CompactionItemProps { node: CompactionSummaryNode + /** Optional command title for a manual compaction folded into this marker. */ + title?: string + /** Command settlement text used when structured compaction counts are unavailable. */ + fallbackSummary?: string | null /** The owning view's locale seat. */ t: ChatViewSlotProps['t'] } @@ -27,10 +32,22 @@ interface CompactionItemProps { * @param props - the marker node off the snapshot cache. * @returns the marker row, with the summary disclosure when one is available. */ -export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) { +export const CompactionItem = memo(function CompactionItem({ + node, + title, + fallbackSummary, + t, +}: CompactionItemProps) { const [expanded, setExpanded] = useState(false) const expandable = node.summary !== null const open = expandable && expanded + const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null + ? t('message.compaction.completed', { + items: node.shadowedItemCount, + tokens: node.shadowedTokenCount, + }) + : fallbackSummary + ?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')) return (
{open && node.summary !== null &&
} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 5c07ace71e..c6ca35bb2c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -33,9 +33,9 @@ padding: 2px 0; } -/* Compaction marker: one dim 24px row with a chevron disclosure for the - summary body. Dimmed title (not label-primary) — the row is a boundary - notice, not conversation content. */ +/* Compaction marker: one dim 24px row with a context icon at rest and a + hover/focus disclosure for the summary body. Dimmed title (not + label-primary) — the row is a boundary notice, not conversation content. */ .compactionRow { padding: 2px 0; } @@ -65,15 +65,36 @@ .compactionLeading { flex: none; - display: inline-flex; - align-items: center; - justify-content: center; + display: inline-grid; + place-items: center; width: 16px; height: 16px; margin-right: 6px; color: var(--dsw-alias-label-secondary); } +.compactionContextIcon, +.compactionDisclosureIcon { + display: inline-flex; + grid-area: 1 / 1; + align-items: center; + justify-content: center; +} + +.compactionDisclosureIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionContextIcon, +.compactionButton:not(:disabled):focus-visible .compactionContextIcon { + opacity: 0; +} + +.compactionButton:not(:disabled):hover .compactionDisclosureIcon, +.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon { + opacity: 1; +} + .compactionTitle { flex: none; font-size: 14px; diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index e3b8e5ba2c..a9b12cbe2d 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -9,13 +9,48 @@ * flow share their gates. */ import type { - AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode, + AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' /** One renderable flow item; key is the React key and the parent's identity unit. */ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } + | { + kind: 'command-compaction' + key: string + command: CommandNode + compaction: CompactionSummaryNode + } + +/** Match explicit command outcome references to exactly one compaction checkpoint. */ +function commandCompactionPairs(nodes: readonly ConversationNode[]): { + readonly byCommandId: ReadonlyMap + readonly byCompactionSeq: ReadonlyMap +} { + const commandsBySource = new Map() + for (const node of nodes) { + if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue + const source = node.outcome.sourceEventSeq + if (source === undefined) continue + commandsBySource.set(source, commandsBySource.has(source) ? null : node) + } + const compactionsBySummary = new Map() + for (const node of nodes) { + if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue + const summary = node.summaryEventSeq + compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) + } + const byCommandId = new Map() + const byCompactionSeq = new Map() + for (const [source, command] of commandsBySource) { + const compaction = compactionsBySummary.get(source) + if (command === null || compaction === undefined || compaction === null) continue + byCommandId.set(command.commandId, compaction) + byCompactionSeq.set(compaction.seq, command) + } + return { byCommandId, byCompactionSeq } +} /** * True when the node has model-visible text content worth IconActions chrome. @@ -115,9 +150,28 @@ export function assistantBranchSeqs( */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] + const pairs = commandCompactionPairs(nodes) let group: ToolResultNode[] | null = null for (const node of nodes) { if (rendersNothing(node)) continue + if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { + continue + } + if (node.kind === 'compaction') { + group = null + const command = pairs.byCompactionSeq.get(node.seq) + if (command !== undefined) { + items.push({ + kind: 'command-compaction', + key: `c${command.commandId}`, + command, + compaction: node, + }) + } else { + items.push({ kind: 'node', key: `n${node.seq}`, node }) + } + continue + } if (node.kind === 'tool-result') { if (group === null) { group = [node] @@ -138,7 +192,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem } } else { group = null - items.push({ kind: 'node', key: `n${node.seq}`, node }) + items.push({ + kind: 'node', + key: node.kind === 'command' && node.name === 'compact' + ? `c${node.commandId}` + : `n${node.seq}`, + node, + }) } } return items diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 0284784e6e..be57f08523 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' @@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'> /** * Owner share of the per-command row slot: the frozen {@link CommandNode} * slice off the snapshot (cache-stable reference — memo premise). The node - * carries the whole lifecycle (structured name/args, pairing id, - * outcome-or-executing), so a - * registrant needs no second data channel; domain state arrives through its - * own projection cell. + * carries the whole lifecycle (structured name/args, pairing id, and + * outcome-or-executing). A successful domain command may also carry the + * explicitly linked projection node needed to fold two log records into one + * presentation row. */ export interface CommandRowOwnerProps { /** Folded command lifecycle node (run + optional done). */ node: CommandNode + /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */ + compaction?: CompactionSummaryNode } /** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index df107d2cd2..11e852a8b6 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -80,6 +80,8 @@ export const zh = { 'message.context.recall.truncated': '已截断', 'message.steering': '插话', 'message.compaction': '上下文已压缩', + 'message.compaction.running': '正在压缩…', + 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', 'message.unknownSurface': '未知 surface 事件:{type}', @@ -220,6 +222,8 @@ export const en = { 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', + 'message.compaction.running': 'Compacting context…', + 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', 'message.unknownSurface': 'Unknown surface event: {type}', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 28b0501141..41cbd61afc 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -691,11 +691,15 @@ describe('MessageItem arms', () => { , ) const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() expect(view.queryByText(/保留的事实/)).toBeNull() fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') @@ -705,7 +709,10 @@ describe('MessageItem arms', () => { }) it('a marker whose provenance fell outside the window is not expandable', () => { - const view = render() + const view = render() const row = view.getByRole('button', { name: /上下文已压缩/ }) expect(row).toHaveProperty('disabled', true) expect(row.getAttribute('aria-expanded')).toBeNull() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..f42aed6731 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { - AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, + AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -93,6 +93,19 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) +const command = (over: Partial = {}): CommandNode => ({ + kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], + name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, + ...over, +}) +const compaction = (over: Partial = {}): CompactionSummaryNode => ({ + kind: 'compaction', seq: 8, time: 8_000, + summary: '## 压缩摘要\n\n保留的事实。', + summaryEventSeq: 7, + shadowedItemCount: 16, + shadowedTokenCount: 11_309, + ...over, +}) /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { @@ -212,6 +225,79 @@ describe('chat-flow derivation', () => { expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) }) + it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { + const running = command({ + seq: 1, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') + + const settled = { + ...running, + outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, + } + const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) + const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) + expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') + expect(items.at(-1)).toEqual({ + kind: 'command-compaction', + key: 'ccmd-compact', + command: settled, + compaction: checkpoint, + }) + }) + + it('does not split adjacent tool results around a folded /compact command', () => { + const folded = command({ + seq: 2, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 4 }, + }) + const items = deriveChatFlow([ + toolResult(1, 'a'), + folded, + toolResult(3, 'b'), + compaction({ seq: 5, summaryEventSeq: 4 }), + ]) + expect(flowKeys(items)).toBe('g1|ccmd-compact') + expect( + items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId), + ).toEqual(['a', 'b']) + }) + + it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { + const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) + expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') + + const first = command({ + seq: 3, + commandId: 'cmd-a' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const second = command({ + seq: 4, + commandId: 'cmd-b' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 9 }, + }) + const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) + expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') + + const sole = command({ + seq: 11, + commandId: 'cmd-sole' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 12 }, + }) + const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) + const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) + expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') + }) + it('skips render-nothing assistant nodes so tool runs stay one group', () => { // A tool-call-only step message (and blank text/reasoning) renders nothing: // it must not split the run into two groups with an empty line between. @@ -1172,11 +1258,6 @@ describe('ChatView', () => { }) it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { - const command = (over: Partial): CommandNode => ({ - kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], - name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, - ...over, - }) // Settled success: the bare command name is the title, the outcome text // the summary — neither the dispatched `/` nor its arguments reach the row // (the settlement text already says what the command did). @@ -1211,4 +1292,65 @@ describe('ChatView', () => { expect(ov.getByText('命令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) + + it('renders /compact as one stateful disclosure from running through completion', () => { + const running = command({ + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: null, + }) + const h = makeHarness({ nodes: [running] }) + const view = render() + expect(view.getByText('正在压缩…')).toBeTruthy() + expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() + + act(() => { + h.set({ + nodes: [{ + ...running, + outcome: { + kind: 'success', + text: 'Compacted 16 history items (~11309 tokens).', + sourceEventSeq: 7, + }, + }, compaction()], + }) + }) + + expect(view.queryByText('正在压缩…')).toBeNull() + expect(view.queryByText('上下文已压缩')).toBeNull() + expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy() + const row = view.getByRole('button', { name: /compact/ }) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull() + expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull() + expect(view.queryByText('保留的事实。')).toBeNull() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull() + expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy() + }) + + it('keeps /compact no-history and error settlements on the generic command row', () => { + const noHistory = makeHarness({ + nodes: [command({ + name: 'compact', + outcome: { kind: 'success', text: 'No compactable history yet.' }, + })], + }) + const noHistoryView = render() + expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy() + expect(noHistoryView.queryByRole('button')).toBeNull() + + const failed = makeHarness({ + nodes: [command({ + commandId: 'cmd-compact-failed' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'error', text: 'Compaction cancelled.' }, + })], + }) + const failedView = render() + expect(failedView.getByText('Compaction cancelled.')).toBeTruthy() + expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) }) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index bd25cc4d51..544199e345 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -324,7 +324,10 @@ describe('deriveTrajectoryLayout', () => { }, // A landed compaction renders no cell, but is still a real log position, // so it moves the cursor after the visible context row. - { kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' }, + { + kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts', + summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, + }, { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], diff --git a/packages/compact/command-compact/README.i18n.yaml b/packages/compact/command-compact/README.i18n.yaml index c39570db18..35ebe66e2b 100644 --- a/packages/compact/command-compact/README.i18n.yaml +++ b/packages/compact/command-compact/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/command-compact/README.md -README.md: a32a6aeb9957f0fd5f8cff58b1edbb9bc29a4e3d -README.zh.md: c678f522115d9b0fd414b2f290b3cb54ce690722 +README.md: 54f341e39447a423964b7d7435cfb638857eda6e +README.zh.md: d4a122b8a19cdf907212ad019b2528ae52d03886 diff --git a/packages/compact/command-compact/README.md b/packages/compact/command-compact/README.md index a32a6aeb99..54f341e394 100644 --- a/packages/compact/command-compact/README.md +++ b/packages/compact/command-compact/README.md @@ -12,7 +12,7 @@ Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The | `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. | | `/compact ` | `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. +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. On success, `command/done.sourceEventSeq` names the transaction's `compact/summary` event so a presentation can fold the command lifecycle into its checkpoint without parsing result text or assuming adjacent rows. Expected `ManualCompactionError` codes become stable direct errors: diff --git a/packages/compact/command-compact/README.zh.md b/packages/compact/command-compact/README.zh.md index c678f52211..d4a122b8a1 100644 --- a/packages/compact/command-compact/README.zh.md +++ b/packages/compact/command-compact/README.zh.md @@ -12,7 +12,7 @@ | `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 | | `/compact ` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 | -该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。 +该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent(智能体)就是操作的确切目标,发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。成功时,`command/done.sourceEventSeq` 会指明该事务的 `compact/summary` 事件,让呈现层无须解析结果文本或假定两行相邻,即可将命令生命周期归并到对应检查点中。 预期的 `ManualCompactionError` 代码会成为稳定的直接错误: diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 2390833bff..4ac171a689 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -68,6 +68,7 @@ async function executeCompact( return { kind: 'success', text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`, + sourceEventSeq: result.summarySeq, } } catch (error: unknown) { if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' } diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 71af9534e4..6922778a26 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -2,7 +2,7 @@ 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 CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { CompactService, ManualCompactionError, @@ -15,9 +15,9 @@ 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, + startSeq: 1, + summarySeq: 2, + endSeq: 3, summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start: 1, end: 7 }, shadowedSeqs: [1, 3, 7], @@ -49,10 +49,24 @@ class StubCompactService extends CompactService { this.calls.push({ agent, signal }) if (this.operation !== undefined) return this.operation() return this.failure === undefined - ? Promise.resolve(this.result) + ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result)) // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. : Promise.reject(this.failure) } + + private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult { + agent.session.append('compact/start', { turn: null }) + agent.session.append('compact/summary', { + summary: result.summary, + shadowedRange: result.shadowedRange, + shadowedSeqs: result.shadowedSeqs, + shadowedTokenCount: result.shadowedTokenCount, + provider: 'command-test', + model: 'command-test', + }) + agent.session.append('compact/end', { turn: null }) + return result + } } interface Harness { @@ -91,9 +105,11 @@ async function run( function expectLastLifecycle( test: Harness, args: string, - outcome: { readonly kind: 'success' | 'error'; readonly text?: string }, + outcome: CommandResult, ): string { - const lifecycle = test.agent.session.events.slice(-2) + const lifecycle = test.agent.session.events + .filter(event => event.type === 'command/run' || event.type === 'command/done') + .slice(-2) const runEvent = lifecycle[0] const doneEvent = lifecycle[1] if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') { @@ -149,6 +165,7 @@ describe('/compact human command', () => { expect(execution.result).toEqual({ kind: 'success', text: 'Compacted 3 history items (~42 tokens).', + sourceEventSeq: RESULT.summarySeq, }) expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result)) expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }]) diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index bbd9bcfcb1..5a5d37d8b1 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -21,7 +21,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' const RESULT: CompactionResult = { startSeq: 1, summarySeq: 2, - endSeq: 4, + endSeq: 3, summary: [{ type: 'text', text: 'loader summary' }], shadowedRange: { start: 3, end: 8 }, shadowedSeqs: [3, 5, 8], @@ -42,9 +42,19 @@ class LoaderCompactService extends CompactService { } override compactNow( - _agent: ManualCompactAgentContext, + agent: ManualCompactAgentContext, _signal: AbortSignal, ): Promise { + agent.session.append('compact/start', { turn: null }) + agent.session.append('compact/summary', { + summary: RESULT.summary, + shadowedRange: RESULT.shadowedRange, + shadowedSeqs: RESULT.shadowedSeqs, + shadowedTokenCount: RESULT.shadowedTokenCount, + provider: 'loader-test', + model: 'loader-test', + }) + agent.session.append('compact/end', { turn: null }) return Promise.resolve(RESULT) } } @@ -108,6 +118,7 @@ describe('command-compact real Loader composition', () => { expect(execution.result).toEqual({ kind: 'success', text: 'Compacted 3 history items (~99 tokens).', + sourceEventSeq: RESULT.summarySeq, }) expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ { @@ -119,12 +130,32 @@ describe('command-compact real Loader composition', () => { source: { kind: 'user' }, }, }, + { + type: 'compact/start', + data: { turn: null }, + }, + { + type: 'compact/summary', + data: { + summary: RESULT.summary, + shadowedRange: RESULT.shadowedRange, + shadowedSeqs: RESULT.shadowedSeqs, + shadowedTokenCount: RESULT.shadowedTokenCount, + provider: 'loader-test', + model: 'loader-test', + }, + }, + { + type: 'compact/end', + data: { turn: null }, + }, { type: 'command/done', data: { commandId: execution.commandId, kind: 'success', text: 'Compacted 3 history items (~99 tokens).', + sourceEventSeq: RESULT.summarySeq, }, }, ]) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b7fd6d3c5a..01ccdc5bf6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1793,7 +1793,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandResult', - declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: number;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, { name: 'CompactAgentContext', diff --git a/packages/ui/commands/README.i18n.yaml b/packages/ui/commands/README.i18n.yaml index 751344084c..be55a19ca3 100644 --- a/packages/ui/commands/README.i18n.yaml +++ b/packages/ui/commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/commands/README.md -README.md: 3105ae1a866e03f3c8f621bfe588df15ee38957e -README.zh.md: 704a2daefb65fde12ca85d1c9051ad762c5ccc70 +README.md: 1709bdcdce4e43d98cfea5ff3972ab95bfd3c33b +README.zh.md: 569f2aa8293793b26d63ee16e3ea7600e04a8397 diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 3105ae1a86..1709bdcdce 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -8,11 +8,11 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl `ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. -Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. +Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. ## Composition diff --git a/packages/ui/commands/README.zh.md b/packages/ui/commands/README.zh.md index 704a2daefb..569f2aa829 100644 --- a/packages/ui/commands/README.zh.md +++ b/packages/ui/commands/README.zh.md @@ -8,11 +8,11 @@ `ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 `parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 -处理器返回 `success` 或 `error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 +处理器返回 `success` 或 `error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 ## 组合 diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index b6dea581eb..64a9f8e8c8 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -47,7 +47,12 @@ export interface CommandInvocation { /** Expected command outcome rendered directly by the dispatching UI. */ export type CommandResult = - | { readonly kind: 'success'; readonly text?: string } + | { + readonly kind: 'success' + readonly text?: string + /** Earlier authoritative domain event that owns a richer presentation. */ + readonly sourceEventSeq?: number + } | { readonly kind: 'error'; readonly text: string } /** @@ -140,9 +145,15 @@ declare module '@deepseek-ai/dsh-session' { /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the - * rendered failure); presentation stays client-computed at render time. + * rendered failure). A successful command may identify the earlier + * authoritative domain event for a richer client-computed presentation. */ - 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } + 'command/done': { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } } } @@ -262,12 +273,20 @@ function normalizeResult(command: string, value: unknown): CommandResult { if (typeof value !== 'object' || value === null || !('kind' in value)) { throw new TypeError(`command "${command}" handler must return a CommandResult`) } - const result = value as { kind?: unknown; text?: unknown } + const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown } if (result.kind === 'success') { if (result.text !== undefined && typeof result.text !== 'string') { throw new TypeError(`command "${command}" success text must be a string when supplied`) } - return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text }) + if (result.sourceEventSeq !== undefined + && (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) { + throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`) + } + return Object.freeze({ + kind: 'success', + ...result.text === undefined ? {} : { text: result.text }, + ...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number }, + }) } if (result.kind === 'error') { if (typeof result.text !== 'string' || result.text.trim().length === 0) { @@ -389,6 +408,9 @@ export class CommandService extends Service { this.appendLifecycle(agent.session, 'command/done', { commandId, kind: result.kind, ...result.text === undefined ? {} : { text: result.text }, + ...result.kind === 'success' && result.sourceEventSeq !== undefined + ? { sourceEventSeq: result.sourceEventSeq } + : {}, }) return Object.freeze({ commandId, result }) } diff --git a/packages/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts index 858c31591c..792733c199 100644 --- a/packages/ui/commands/src/invariant.ts +++ b/packages/ui/commands/src/invariant.ts @@ -34,6 +34,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (runIds.get(session)?.has(event.data.commandId) !== true) { fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } + const source = event.data.sourceEventSeq + const sourceEvent = source === undefined ? undefined : session.events[source] + if (source !== undefined + && (event.data.kind !== 'success' + || !Number.isSafeInteger(source) || source < 0 || source >= event.seq + || sourceEvent?.seq !== source + || sourceEvent.type === 'command/run' + || sourceEvent.type === 'command/done')) { + fail(`command/done ${JSON.stringify(event.data.commandId)} has invalid sourceEventSeq ${String(source)}`) + } } for (const session of ctx.sessions.list()) { for (const event of session.events) validateEvent(session, event) diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 7412325ab6..54b4227d19 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -320,6 +320,25 @@ describe('CommandService', () => { ]) }) + it('preserves an earlier authoritative domain-event reference on successful settlement', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const source = agent.session.append('turn/start', { turn: 1 }) + ctx.commands.register({ + name: 'linked', + description: 'Link outcome', + handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }), + }) + + const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal) + + expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }) + expect(lifecycleOf(agent)).toMatchObject([ + { type: 'command/run', data: { name: 'linked' } }, + { type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } }, + ]) + }) + it('omits raw input from command/run when an authoritative domain event owns it', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') @@ -427,6 +446,9 @@ describe('CommandService', () => { [null, /CommandResult/], [{}, /CommandResult/], [{ kind: 'success', text: 1 }, /success text/], + [{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/], + [{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/], [{ kind: 'error', text: '' }, /error text/], [{ kind: 'error', text: 1 }, /error text/], [{ kind: 'future', text: 'x' }, /unknown result kind/], diff --git a/packages/ui/commands/tests/invariant.spec.ts b/packages/ui/commands/tests/invariant.spec.ts new file mode 100644 index 0000000000..556be701e8 --- /dev/null +++ b/packages/ui/commands/tests/invariant.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { CommandId } from '@deepseek-ai/dsh-commands' + +async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('commands-invariant')) + await ctx.plugin(InvariantService, { enabled: true }) + if (installCompanion) await ctx.plugin(CommandInvariant) + return { ctx, session } +} + +function appendRun(session: Session, id: string): void { + session.append('command/run', { + commandId: CommandId(id), + name: 'linked', + args: '', + source: { kind: 'user' }, + }) +} + +describe('command lifecycle invariants', () => { + it('accepts a success outcome linked to an earlier non-command domain event', async () => { + const { session } = await mount() + const source = session.append('turn/start', { turn: 1 }) + appendRun(session, 'cmd-valid') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-valid'), + kind: 'success', + sourceEventSeq: source.seq, + }) + }).not.toThrow() + }) + + it.each([-1, 1.5, 1])('rejects invalid or non-prior sourceEventSeq %s', async (sourceEventSeq) => { + const { session } = await mount() + appendRun(session, 'cmd-invalid') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-invalid'), + kind: 'success', + sourceEventSeq, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + })) + }) + + it('rejects an error settlement carrying a success-only source reference', async () => { + const { session } = await mount() + const source = session.append('turn/start', { turn: 1 }) + appendRun(session, 'cmd-error-source') + + expect(() => { + session.append('command/done', { + commandId: CommandId('cmd-error-source'), + kind: 'error', + text: 'failed', + sourceEventSeq: source.seq, + }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + })) + }) + + it('attributes an invalid durable prefix during late companion loading', async () => { + const { ctx, session } = await mount(false) + appendRun(session, 'cmd-late') + session.append('command/done', { + commandId: CommandId('cmd-late'), + kind: 'success', + sourceEventSeq: 0, + }) + + await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-commands', + }) + }) +})