Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
#	apps/web/tests/scaffold.ts
#	docs/module-graph.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/package.json
#	packages/client/test-runtime/README.i18n.yaml
#	packages/client/test-runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-theme/README.i18n.yaml
#	packages/client/ui-theme/README.md
#	packages/client/ui-theme/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 12:50:43 +08:00
3485 changed files with 86192 additions and 25255 deletions
@@ -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-deliverables/README.md
README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043
README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d
+4 -2
View File
@@ -4,10 +4,12 @@ English | [中文](README.zh.md)
Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost.
`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row.
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
## Model Experience
None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request.
@@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests.
## Known Limitations and Deferred Work
- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up.
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.
+6 -4
View File
@@ -2,11 +2,13 @@
[English](README.md) | 中文
产物文件的功能属主:把"完成的一轮以其产出文件收尾"的产物行注册进 chat 视图的 `conversation.chat.turnTail` 空位。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该交互面,属主视图以零成本渲染一个空的空位。
产出文件功能的属主:把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot 中。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该界面,属主视图无需额外开销即可渲染空 slot。
`producedForClosing` 从 tail 空位的 owner 通货——定稿的快照节点与收尾 assistant 的 seq——推导一轮产出的文件。词表是改写工具自身的跟随 `locations`,绝不是收尾正文:无论模型是否记得点名,产出文件都会被列出。改写按渲染意图识别而非工具名——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此新的改写工具靠声明自己做了什么加入。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。
`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签、至多六个标签项(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
收尾正文承载同一份词表。本插件提供供 chat 视图按收尾消息查询的 `chatFileMentions` 服务:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享同一 basename 时,文本保持不可点击而不作猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式:静止时为链接蓝色,悬停时显示下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在链接内部或流式文本中。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
## 模型体验
@@ -18,4 +20,4 @@
## 已知限制与暂缓事项
- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持不可点击;等真实的收尾消息形态产生需求后再放宽匹配规则。
@@ -6,18 +6,13 @@
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
const SHOWN = 6
/** Trailing path segment, the part that identifies the file at a glance. */
function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** Matched paths plus the opener and locale seats needed to present them. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]
@@ -1,16 +1,20 @@
/**
* Deliverables plugin, browser half: registers the produced-files row into
* the chat view's turn-tail hole. All policy lives here — the derivation
* from the mutation tools' `locations`, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes the surface entirely; the
* owning view renders an empty hole at zero cost.
* the chat view's turn-tail chain, and provides the `chatFileMentions`
* service that links inline-code mentions of produced files in the closing
* prose. All policy lives here — the derivation from the mutation tools'
* `locations`, the mention matching, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ProducedFiles } from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import { selectProducedFiles } from './turn-deliverables.ts'
import {
deliverablesDefinition, producedFileMentions, selectProducedFiles,
} from './turn-deliverables.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -23,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'conversationEvents']
/**
* Client plugin body: register the dictionaries and the turn-tail entry.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
'conversation.chat.turnTail',
@@ -39,4 +44,17 @@ export function apply(ctx: ClientContext): void {
locale: NS,
}, ProducedFiles),
)
// The prose side of the same vocabulary: the chat view reaches this face
// via ctx.get, so its absence — this plugin composed out — is the off state.
const t = ctx.locale.bind(NS)
const mentions: ChatFileMentions = {
forClosing(owner) {
// Same claim test the turn-tail chain entry runs: no produced files,
// no vocabulary — the two surfaces agree by construction.
const paths = selectProducedFiles(owner)
if (paths === null) return undefined
return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
},
}
ctx.provide('chatFileMentions', mentions)
}
@@ -1,16 +1,44 @@
/**
* Pure derivation of one turn's produced files from finalized snapshot
* nodes. Client-only and model-free: the vocabulary is the mutation tools'
* own follow-along `locations`, never the closing prose.
* Turn-scoped produced-file Definition and readers. Client-only and
* model-free: the vocabulary is the mutation tools' own follow-along
* `locations`, never the closing prose.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationNodeDefinition, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
interface ProducedPath {
readonly seq: number
readonly path: string
}
/** Immutable produced-file facts published against one Turn. */
export interface DeliverablesTurnData {
readonly produced: readonly ProducedPath[]
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationTurnDataMap {
/** Successful mutation paths accumulated in this Turn. */
deliverables: DeliverablesTurnData
}
}
interface DeliverablesState extends DeliverablesTurnData {
readonly turn: number
readonly calls: ReadonlyMap<string, ToolResultNode['callView']>
}
/**
* Paths a call view reports having created or changed, by render intent rather
* than tool name: a diff card, or a generic card whose kind is `edit` (the
* shape `str_replace_editor`'s insert presents). Every other card produces
* nothing to open — a read looked, a delete removed, a terminal ran.
* nothing to open — a read looked, a delete removed, a terminal ran. Only
* root call views enter this Turn accumulator; nested Code Mode dispatches
* preserve the pre-assembly behavior and do not contribute independently.
*/
function producedPaths(view: ToolResultNode['callView']): readonly string[] {
if (view === null) return []
@@ -22,9 +50,7 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] {
}
/**
* Files produced by the turn the assistant at `seq` closes — the anchor the
* render site elects, so the row lands under the message that reports the
* work rather than after some mid-turn narration.
* Files produced by one Turn data value.
*
* The source is the mutation tools' own follow-along `locations`, not the
* closing prose: a produced file must be listed whether or not the model
@@ -36,46 +62,26 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] {
* failed calls. Paths keep first-seen order and appear once, so a file written
* and then edited in the same turn is one entry.
*
* Accumulation resets on the turn boundary — a user message, or a node
* reporting a different turn number — so a turn that mutates files and then
* ends without content text cannot spill its paths into the next turn's row,
* nor leave the dedup set suppressing a file the next turn legitimately
* rewrites. Tool results carry no turn of their own; the boundary is read off
* the nodes that do, and a user message resets the tracked turn to undefined
* because the next node to report one is stating the current turn, not
* entering a new one.
* @param nodes - snapshot nodes (surface order).
* @param seq - the closing assistant's seq (the render site's anchor).
* The Conversation Location index owns turn membership before this function
* runs, so paths cannot spill across turns and this derivation does not infer
* boundaries from neighboring presentation Nodes.
* @param data - engine-published Deliverables data for one Turn.
* @param seq - closing Assistant seq; later Tool settlements are excluded.
* @returns Produced paths in first-seen order; empty when the turn wrote nothing.
*/
export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] {
let pending: string[] = []
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.isError) continue
for (const path of producedPaths(node.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(path)
}
continue
}
if (node.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if ('turn' in node) {
if (turn !== undefined && node.turn !== turn) {
pending = []
seen = new Set()
}
turn = node.turn
}
if (node.kind === 'assistant' && node.seq === seq) return pending
export function producedForClosing(
data: Readonly<DeliverablesTurnData> | undefined,
seq = Number.POSITIVE_INFINITY,
): readonly string[] {
if (data === undefined) return []
const paths: string[] = []
const seen = new Set<string>()
for (const produced of data.produced) {
if (produced.seq > seq || seen.has(produced.path)) continue
seen.add(produced.path)
paths.push(produced.path)
}
return []
return paths
}
/**
@@ -84,7 +90,93 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb
* @returns Produced paths as the component's match, or null to decline before mount.
*/
export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null {
const { nodes, seq } = owner
const paths = producedForClosing(nodes, seq)
const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq)
return paths.length === 0 ? null : paths
}
/** Turn-local successful mutation accumulator; it publishes no view Node. */
export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesState> = {
kind: 'deliverables',
match: (event) => {
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
return { id: String(event.data.turn), role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start')
return { turn: match.event.data.turn, calls: new Map(), produced: [] }
},
update: (context, match) => {
if (match.event.type === 'tool/call') {
const calls = new Map(context.state.calls)
calls.set(
String(match.event.data.callId),
match.view?.for === 'call' ? match.view.view : null,
)
return { ...context.state, calls }
}
if (match.event.type !== 'tool/result') return context.state
const result = match.event.data.message.content[0]
if (result.isError === true) return context.state
const callId = String(match.event.data.message.source.callId)
const additions = producedPaths(context.state.calls.get(callId) ?? null)
.map(path => ({ seq: match.event.seq, path }))
return additions.length === 0
? context.state
: { ...context.state, produced: [...context.state.produced, ...additions] }
},
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined
? null
: {
kind: 'turn',
turn: context.state.turn,
key: 'deliverables',
value: { produced: context.state.produced },
},
buildViewNode: () => null,
}
/**
* Trailing path segment, the part that identifies the file at a glance.
* @param path - Slash- or backslash-separated path.
* @returns The final segment, or the whole string when separator-free.
*/
export function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* File-mention vocabulary over one turn's produced paths, for the closing
* message's prose: an inline-code token opens the file it names. A token
* resolves by exact path, or by being exactly the basename of exactly one
* produced path — a basename two paths share stays inert rather than
* guessing, so a mention link can never open the wrong file or 404.
* @param paths - The turn's produced paths (tool order, already deduped).
* @param openFile - The chat view's file opener.
* @param label - Localizes the accessible open-label for a resolved path.
* @returns The resolver MarkdownText consumes; the full path rides `title`,
* the same disambiguator the row's chips carry.
*/
export function producedFileMentions(
paths: readonly string[],
openFile: (path: string) => void,
label: (path: string) => string,
): MarkdownFileMentions {
return {
resolve(value) {
const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value)
if (path === undefined) return undefined
return { open: () => { openFile(path) }, label: label(path), title: path }
},
}
}
/** The single produced path whose basename is exactly `value`, else undefined. */
function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined {
const matches = paths.filter(path => basename(path) === value)
return matches.length === 1 ? matches[0] : undefined
}
@@ -1,21 +1,29 @@
// @vitest-environment jsdom
/**
* ui-deliverables browser half: the derivation contract of
* `producedForClosing` over finalized snapshot nodes, the row's rendering
* `producedForClosing` over engine-published Turn data, the row's rendering
* and opener wiring, and the plugin registrations' fiber-teardown removal
* (HMR safety) against the real SlotsService.
*/
import { Context } from 'cordis'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import {
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, ToolResultNode, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import {
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
type DeliverablesTurnData,
} from '../src/client/turn-deliverables.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
@@ -23,101 +31,238 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user',
seq,
time: seq * 1000,
content: [{ type: 'text', text }] as never,
source: null,
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
): Readonly<ConversationTurnDataMap[Key]> | undefined {
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
}
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
value: ConversationTurnDataMap[Key],
): void {
this.values.set(key, value)
}
}
const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => {
const data = new TestTurnDataStore()
if (deliverables !== undefined) data.set('deliverables', deliverables)
return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data }
}
const produced = (...values: ReadonlyArray<readonly [seq: number, path: string]>): DeliverablesTurnData => ({
produced: values.map(([seq, path]) => ({ seq, path })),
})
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
function tailOwner(
data: DeliverablesTurnData | undefined,
seq: number,
openFile: (path: string) => void = () => {},
turn = 1,
): TurnTailOwnerProps {
return { seq, openFile, turn: turnLocation(turn, data) }
}
interface TimelineSnapshot {
readonly timeline: ConversationTimelineSnapshot
}
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] }
fallbackEntry(): undefined { return undefined }
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] }
}
const timelineViewDefinition: ConversationViewDefinition<ConversationViewNode, TimelineSnapshot> = {
target: 'test',
create: () => {
let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } }
return {
empty: current,
replace: ({ timeline }) => (current = { timeline }),
apply: ({ timeline }) => (current = { timeline }),
}
},
}
function at(
seq: number,
type: string,
data: unknown,
view?: ConversationEventInput['view'],
): ConversationEventInput {
return {
event: {
seq, time: seq * 1_000, type, data,
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
} as ConversationEventInput['event'],
view,
}
}
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
return { ...input, role, location: { kind: 'unresolved' } }
}
function call(
seq: number,
callId: string,
view: ToolResultNode['callView'],
turn = 1,
): ConversationEventInput {
return at(
seq,
'tool/call',
{ turn, step: 1, callId, name: 'fixture', arguments: '{}' },
{ for: 'call', view: view ?? { card: 'generic', title: 'fixture' } },
)
}
function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput {
return at(seq, 'tool/result', {
turn,
step: 1,
message: {
source: { type: 'tool-result', callId },
content: [{ type: 'tool-result', content: [], isError }],
},
})
}
function diff(...paths: string[]): ToolResultNode['callView'] {
return {
card: 'diff', title: `Write ${paths[0] ?? ''}`,
diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
locations: paths.map(path => ({ path })),
},
})
}
}
describe('producedForClosing derivation', () => {
it('attributes each turn’s written files to the assistant that closes it', () => {
const nodes: ConversationNode[] = [
user(1, 'build it'),
assistant(2, 'writing', 1),
wrote(3, 'a', 'out/index.html'),
// Same file touched twice in one turn is one deliverable, in first-seen order.
wrote(4, 'b', 'out/app.css', 'out/index.html'),
// A read is not a deliverable; a failed write has no file to open.
{ ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } },
{ ...wrote(6, 'd', 'out/broken.html'), isError: true },
assistant(7, 'done', 1),
user(8, 'again'),
assistant(9, 'second turn', 2),
]
expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css'])
expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css'])
expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull()
// A turn that produced nothing yields the empty list, and so does an
// anchor the window does not contain.
expect(producedForClosing(nodes, 9)).toEqual([])
expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([])
expect(producedForClosing(nodes, 999)).toEqual([])
function edit(path: string): ToolResultNode['callView'] {
return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
}
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
value.flush()
return value
}
function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly<DeliverablesTurnData> | undefined {
const snapshot = value.snapshot('test') as TimelineSnapshot
return snapshot.timeline.turns.get(turn)?.data.get('deliverables')
}
describe('produced-file Turn data', () => {
it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => {
const data = produced(
[3, 'out/index.html'],
[4, 'out/app.css'],
[4, 'out/index.html'],
[8, 'after.txt'],
)
expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css'])
expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css'])
expect(producedForClosing(undefined)).toEqual([])
expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull()
})
it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
call(2, 'write', diff('out/index.html', 'out/app.css')),
result(3, 'write'),
call(4, 'edit', edit('notes.md')),
result(5, 'edit'),
call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }),
result(7, 'read'),
call(8, 'failed', diff('broken.txt')),
result(9, 'failed', true),
call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }),
result(11, 'locationless'),
])
it('counts a generic edit and never spills across the turn boundary', () => {
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'str_replace_editor'),
// str_replace_editor's insert mutates behind a generic card, so the
// discriminant is the render intent, not the card shape alone.
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
})
const nodes: ConversationNode[] = [
user(1, 'insert a line'),
inserted(2, 'i', 'notes.md'),
assistant(3, 'inserted', 1),
// Turn 2 mutates and then ends with no content text (interrupted, or its
// last text preceded the tool): its paths must not ride into turn 3.
user(4, 'now rewrite it'),
wrote(5, 'w', 'leaked.txt'),
user(6, 'and again'),
wrote(7, 'w2', 'notes.md'),
assistant(8, 'done', 3),
]
expect(producedForClosing(nodes, 3)).toEqual(['notes.md'])
// Turn 3 lists only its own file — and the dedup set did not suppress the
// rewrite of a path an earlier turn already touched.
expect(producedForClosing(nodes, 8)).toEqual(['notes.md'])
expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt')
expect(producedForClosing(deliverablesOf(value))).toEqual([
'out/index.html', 'out/app.css', 'notes.md',
])
})
it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => {
const nodes: ConversationNode[] = [
user(1, 'go'),
// A turnless surface node neither tracks nor resets the boundary.
{ kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null },
wrote(2, 'w', 'turn-one.txt'),
// A view-less result (window truncation) and cards without locations
// contribute nothing rather than crashing the walk.
toolResult(3, 'plain'),
{ ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } },
{ ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } },
assistant(6, 'mid narration', 1),
// Turn number advances with no user message in the window (truncated
// history): the accumulator must reset all the same.
assistant(7, 'closing', 2),
]
expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt'])
expect(producedForClosing(nodes, 7)).toEqual([])
it('ignores calls without mutation locations, orphan results, and replacement results', () => {
const replacement = result(8, 'replacement')
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }),
result(3, 'no-view'),
call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }),
result(5, 'locationless-edit'),
result(6, 'orphan'),
call(7, 'replacement', diff('replaced.txt')),
{
...replacement,
event: {
...replacement.event,
surfaceOp: { op: 'replace', start: 1, end: 1 },
} as ConversationEventInput['event'],
},
at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
expect(producedForClosing(deliverablesOf(value))).toEqual([])
})
it('rejects an invalid start match and preserves state for an unrelated update', () => {
const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start')
const emptyContext: Parameters<typeof deliverablesDefinition.start>[0] = {
key: 'deliverables:1',
kind: 'deliverables',
id: '1',
matches: [startMatch],
start: startMatch,
state: undefined,
current: new Map(),
}
const reader: Parameters<typeof deliverablesDefinition.start>[2] = { previous: () => undefined }
const state = deliverablesDefinition.start(emptyContext, startMatch, reader)
const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update')
const context: Parameters<typeof deliverablesDefinition.update>[0] = { ...emptyContext, state }
expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader))
.toThrow('deliverables start requires turn/start')
expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
})
it('replays a tail page once prepend supplies its missing Turn start', () => {
const value = assembler([
call(10, 'late', diff('history.txt')),
result(11, 'late'),
], true)
expect(deliverablesOf(value)).toBeUndefined()
value.prepend([at(1, 'turn/start', { turn: 1 })], false)
value.flush()
expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt'])
})
it('extends the same Turn data incrementally on live append', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
call(2, 'first', diff('first.txt')),
result(3, 'first'),
])
const first = deliverablesOf(value)
expect(producedForClosing(first)).toEqual(['first.txt'])
value.append(call(4, 'second', diff('second.txt')))
value.append(result(5, 'second'))
value.flush()
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
})
})
@@ -142,6 +287,33 @@ describe('ProducedFiles row', () => {
})
})
describe('producedFileMentions resolver', () => {
const label = (path: string) => `打开 ${path}`
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
const opened: string[] = []
const resolver = producedFileMentions(
['out/index.html', 'a/style.css', 'b/style.css'],
(path) => { opened.push(path) },
label,
)
// Unique basename resolves to its full path; the full path rides title.
const byBasename = resolver.resolve('index.html')
expect(byBasename?.label).toBe('打开 out/index.html')
expect(byBasename?.title).toBe('out/index.html')
byBasename?.open()
expect(opened).toEqual(['out/index.html'])
// An exact path resolves even when its basename is ambiguous.
const exact = resolver.resolve('a/style.css')
expect(exact?.title).toBe('a/style.css')
// A basename two paths share stays unresolved rather than guessing,
// and so does a token naming nothing the turn wrote.
expect(resolver.resolve('style.css')).toBeUndefined()
expect(resolver.resolve('notes.md')).toBeUndefined()
expect(basename('a\\b\\c.txt')).toBe('c.txt')
})
})
describe('package shells', () => {
it('the node half mounts inert and the invariant companion registers ownership', async () => {
// The node half is deliberately inert; mounting it must simply not throw.
@@ -162,6 +334,7 @@ describe('plugin registration', () => {
it('registers the tail entry and fiber disposal removes it', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await ctx.plugin(ConversationEventRegistry).await()
// The owning view's child declaration, stood up by a bench root entry.
ctx.slots.register({
name: 'root',
@@ -174,7 +347,24 @@ describe('plugin registration', () => {
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
// The prose face is live while the plugin is: a produced turn yields a
// resolver whose matches open through the owner-supplied opener.
const opened: string[] = []
const owner = tailOwner(
produced([2, 'site/report.html']),
3,
(path) => { opened.push(path) },
)
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
const mentions = service?.forClosing(owner)
mentions?.resolve('report.html')?.open()
expect(opened).toEqual(['site/report.html'])
// A turn that produced nothing yields no vocabulary at all.
expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
await fiber.dispose()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
})
})