Merge remote-tracking branch 'origin/master' into mergebot/pr1016

This commit is contained in:
imccyu
2026-07-31 14:51:15 +08:00
74 changed files with 1663 additions and 149 deletions

View File

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

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (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).
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).
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。

View File

@@ -66,33 +66,45 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
right with a stepped trail — flat keyframe holds, no tweening. Phase
offsets come from per-rect animation-delay (index * -250ms) set inline
by the component. */
.turnDots {
/* Turn activity keeps the former loader's one-line footprint. A pale
brand-blue band sweeps from left to right; reduced-motion keeps it static. */
.turnStatus {
align-self: flex-start;
flex: none;
display: flex;
display: inline-flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450);
font: var(--dsw-font-s-strong-14);
white-space: nowrap;
background: linear-gradient(
90deg,
var(--dsw-static-deepseek-500) 0%,
var(--dsw-static-deepseek-500) 40%,
var(--dsw-static-deepseek-200) 50%,
var(--dsw-static-deepseek-500) 60%,
var(--dsw-static-deepseek-500) 100%
);
background-position: 100% 0;
background-size: 250% 100%;
background-clip: text;
color: transparent;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: dsh-turn-status-shimmer 1.8s linear infinite;
}
.turnDotCell {
fill: currentColor;
opacity: 0.15;
animation: dsh-turn-dots-chase 1s infinite;
@keyframes dsh-turn-status-shimmer {
to {
background-position: 0 0;
}
}
@keyframes dsh-turn-dots-chase {
0%, 24.9% { opacity: 1; }
25%, 49.9% { opacity: 0.6; }
50%, 74.9% { opacity: 0.35; }
75%, 100% { opacity: 0.15; }
@media (prefers-reduced-motion: reduce) {
.turnStatus {
background-position: 0 0;
background-size: 100% 100%;
animation: none;
}
}
.hint {

View File

@@ -204,36 +204,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
* per-rect animation-delay. */
const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() {
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus() {
return (
/* The wrapper is a 26px line box (message line height) so the loader
occupies one text line and centers the dots inside it. */
<div className={css.turnDots} aria-hidden="true">
<svg
width="17.5"
height="2.5"
viewBox="0 0 17.5 2.5"
shapeRendering="crispEdges"
>
{LOADER_CELLS.map((x, index) => (
<rect
key={x}
className={css.turnDotCell}
x={x}
y="0"
width="2.5"
height="2.5"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
/>
))}
</svg>
<div className={css.turnStatus} role="status" aria-live="polite">
Deep diving...
</div>
)
}
@@ -491,7 +466,7 @@ export function ChatView({
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
{running && <TurnStatus />}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>

View File

@@ -107,6 +107,10 @@
border-radius: 8px;
}
.row + .row {
box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
}
.preview,
.editor {
flex: 1 1 auto;

View File

@@ -193,6 +193,18 @@
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
/* These three MUST wrap at one width, because InputBar mirrors a single
scroll offset between .input and .backdrop and a layer that wraps onto
more lines is taller, has a larger scroll maximum, and clamps the mirrored
offset below the caret. Only .input scrolls, so only .input can lose
content width to a scrollbar that consumes layout space.
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
removed after measuring: WebKit applies it to overflow-y:auto but not to
the overflow:hidden layers, so it left .input at 768 against 776 — the
same gap it was meant to close — while costing chromium 8px of text width
unconditionally. The gap it would have closed is measured and recorded in
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
closing it needs one geometry every engine agrees on, not this property. */
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */

View File

@@ -62,6 +62,7 @@ export function InputBar({
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const backdropRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -91,11 +92,22 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
// the inert state renders the same element disabled).
//
// wheel — active conversation scrollport: chain the gesture. While the
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
// direction, keep the native scroll; only at its own edge forward delta to
// the host so a short draft never traps the gesture and a long draft stays
// scrollable. Hero mounts have no host and keep native wheel scrolling.
//
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place. Every way the box moves ends
// in a `scroll` event, edits included (the caret is scrolled into view), and
// the layers share an extent, so a draft that shrinks past the offset clamps
// both to the same maximum — one listener covers the coupling.
useEffect(() => {
const el = inputRef.current
if (el === null) return
@@ -108,8 +120,16 @@ export function InputBar({
e.preventDefault()
host.scrollTop += e.deltaY
}
const onScroll = (): void => {
const backdropEl = backdropRef.current
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -355,6 +375,22 @@ export function InputBar({
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
// Trailing-line sentinel, the same one the mirror div carries and for the
// same reason: a textarea reserves a line box for the caret after a final
// newline, while `white-space: pre-wrap` collapses a text node's trailing
// newline and generates none. Without it a draft ending in a newline makes
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
// offset at the very bottom clamps and the glyphs sit a line behind the
// caret. The extra newline is absorbed by that same collapse when the draft
// does not end in one, so it costs no height in the ordinary case.
//
// The mirror only fails one way — a backdrop SHORTER than the textarea
// clamps the assignment, while a taller one takes every offset exactly and
// hides the surplus below the clip. That is why the ghost hint needs no
// handling of its own: it can only add content after the draft and before
// this sentinel, never remove a line box, so it moves the pair to equal or
// to the safe side.
backdrop.push('\n')
}
return (
@@ -376,7 +412,7 @@ export function InputBar({
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}

View File

@@ -5,7 +5,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
@@ -254,16 +254,16 @@ describe('ChatView', () => {
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)
const disclosure = view.container.querySelector('details')
expect(disclosure?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
const disclosure = view.container.querySelector('details') as HTMLDetailsElement
expect(disclosure.dataset.active).toBe('true')
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
})
expect(view.getAllByRole('status')).toHaveLength(1)
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
expect(view.container.querySelector('details')).toBe(disclosure)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
act(() => {
h.set({
@@ -277,14 +277,15 @@ describe('ChatView', () => {
running: false,
})
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
expect(disclosure.dataset.active).toBeUndefined()
expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toContain('重试已取消')
const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement
expect(cancelledDisclosure.dataset.active).toBeUndefined()
expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
@@ -448,6 +449,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(view.getByText('cmd-r1')).toBeTruthy()
expect(view.getByRole('status').textContent).toBe('Deep diving...')
})
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {

View File

@@ -289,6 +289,37 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Every later move tracks too, including back to the top — a one-shot
// mirror would leave the glyphs parked at the first offset it saw.
textarea.scrollTop = 0
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(0)
})
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
// (the browser scenario owns that); what is checkable is that the backdrop's
// text is the draft plus exactly one newline. A textarea reserves a line box
// after a final newline and `pre-wrap` collapses one, so without the
// sentinel a draft ending in a newline leaves the backdrop a line short and
// the mirrored offset clamps.
const withNewline = bench({ draft: 'alpha\nbeta\n' })
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
const withoutNewline = bench({ draft: 'alpha\nbeta' })
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(plain.textContent).toBe('alpha\nbeta\n')
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')

View File

@@ -2211,6 +2211,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PtyWaitReason',
declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
},
{
name: 'ReadFileLine',
declaration: 'export interface ReadFileLine {\n number: number;\n text: string;\n}',
},
{
name: 'ReadResultView',
declaration: 'export interface ReadResultView {\n card: \'read\';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n}',
},
{
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
@@ -2849,7 +2857,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolResultView',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;',
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;',
},
{
name: 'ToolRunContext',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
README.md: dcce455f9551318f3871e3df84c29789078fef7c
README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99

View File

@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.

View File

@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。

View File

@@ -74,6 +74,7 @@ export type {
ToolCallKind,
FileLocation,
FileDiff,
ReadFileLine,
ToolCallView,
GenericCallView,
TerminalCallView,
@@ -82,6 +83,7 @@ export type {
GenericResultView,
TerminalResultView,
DiffResultView,
ReadResultView,
WebResultView,
WebSearchResultView,
WebFetchResultView,

View File

@@ -117,6 +117,18 @@ export interface DiffCallView {
locations?: FileLocation[]
}
/**
* One numbered line of a file, the unit a {@link ReadResultView} carries so a
* capable UI can render a syntax-highlighted, line-numbered code view. `number`
* is the 1-based line number in the file (a window past `offset` keeps the file's
* own numbering, not a 1-based re-count); `text` is the line without its trailing
* newline, already truncated to the read tool's per-line cap.
*/
export interface ReadFileLine {
number: number
text: string
}
/**
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
@@ -125,7 +137,7 @@ export interface DiffCallView {
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView
/**
* The default completed card: an optional replacement title and reformatted
@@ -177,6 +189,47 @@ export interface DiffResultView {
diffs: FileDiff[]
}
/**
* A completed file read rendered as a line-numbered, optionally syntax-highlighted
* code view by a capable UI. Set by a tool whose call reads file text (e.g.
* `read`); the pending state stays a {@link GenericCallView} (`kind: 'read'`)
* because a call carries no content until `execute` returns. The structured
* `lines`/`path`/`lang`/`totalLines` fields cannot be reconstructed from the
* model-facing result text alone, so the read tool projects them through its
* `output.presentationMeta` (persisted with the session log) and `presentResult`
* narrows that metadata back into this view on live and replay paths alike. A UI
* without the read capability falls back to `content` (the model-facing text with
* its envelope stripped), so this view degrades to the generic text card.
*/
export interface ReadResultView {
card: 'read'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The read file's path (the model-facing path; the bridge relativizes it). */
path: string
/**
* The 1-based first line the window requested, preserved even when `lines` is
* empty (a byte cap below the first selected line yields an empty window) so a
* UI knows where the window starts and where a continuation resumes.
*/
offset: number
/** The returned window's lines, in file order, each keeping its file line number. */
lines: ReadFileLine[]
/** Exact total line count in the file, so a UI can show a "showing N of M" affordance. */
totalLines: number
/**
* A syntax-highlighting language hint derived from the file extension (e.g.
* `ts`, `py`), or omitted when the extension maps to no known language so a UI
* renders the lines as plain text.
*/
lang?: string
/**
* The model-facing result content with its envelope stripped, for a UI without
* the read capability. Omit to let such a UI render the raw result content.
*/
content?: ContentBlock[]
}
/**
* One citeable source in a completed {@link WebSearchResultView}, the faithful
* projection of one web-search source. The presentation projection of `dsh-web`'s

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259
README.md: c00b59fed06249e6d9479c4a809cdf7d78f93239
README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6

View File

@@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps.
Field names are snake_case to match Claude Code and existing harness tool schemas.
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
## The tool is the executor; policy is an event gate

View File

@@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re
字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
## 工具就是执行器;策略是事件门禁

View File

@@ -168,3 +168,105 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome):
${body}
</content>`
}
/**
* Lowercased file-extension to syntax-highlighting language hint. Keys are the
* extension without its dot; a UI treats an absent key as plain text. The map is
* intentionally small — common source, config, and markup extensions a
* line-numbered code view benefits from highlighting — not an exhaustive registry.
*/
const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
json: 'json', jsonc: 'json',
py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
sh: 'sh', bash: 'sh', zsh: 'sh',
yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
md: 'md', markdown: 'md', mdx: 'mdx',
html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
sql: 'sql', xml: 'xml', lua: 'lua',
}
/**
* Derive a syntax-highlighting language hint from a read path's file extension.
* Pure and case-insensitive on the extension; a dotfile with no extension
* (`.gitignore`) and an unknown extension both yield `undefined`.
* @param path - the model-facing path the read reported.
* @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none.
*/
export function langFromPath(path: string): string | undefined {
const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
const dot = base.lastIndexOf('.')
// A leading dot is a dotfile (no extension), not an empty extension.
if (dot <= 0) return undefined
const ext = base.slice(dot + 1).toLowerCase()
// Own-property check only: a filename whose extension is an Object.prototype
// key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the
// inherited member — otherwise a function would reach `lang` and fail the
// tool-output JSON validation.
return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
}
/**
* The `read` tool's private `tool/result` `meta` payload: the structured
* line-numbered window a capable UI renders as a code view. Attached opaquely (as
* `unknown`) on the tool result and persisted with the session log — it must be
* JSON-serializable (the session validates this at `append`), so `presentResult`
* reproduces the read card on replay when the raw structured output is no longer
* on the wire. The producing tool owns and narrows this opaque shape.
*/
export interface FsReadMeta {
/** The read file's model-facing path. */
path: string
/** The 1-based first line the window requested, kept even when `lines` is empty. */
offset: number
/** The returned window's lines, each keeping its file line number. */
lines: FileTextLine[]
/** Exact total line count in the file. */
totalLines: number
/** Syntax-highlighting language hint from the extension, or omitted for plain text. */
lang?: string
}
/**
* Whether `value` is a valid {@link FileTextLine} (defensive narrowing from
* opaque `meta`). `number` must be a 1-based integer line number, since a card
* rendered from a zero, fractional, or non-finite line number would violate the
* 1-based numbering contract the read window promises.
*/
function isFileTextLine(value: unknown): value is FileTextLine {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const { number, text } = value as Record<string, unknown>
return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
}
/**
* Narrow opaque live or replayed result metadata to a structured read window.
* Malformed metadata returns `undefined` so presentation can fall back to the
* generic text card instead of throwing during replay. Beyond shape, the
* semantic contract of a read window is enforced against replayed JSON that is
* well-typed but out of range: `offset` must be a 1-based integer, `totalLines`
* must be a non-negative integer, each line number must be a 1-based integer no
* less than `offset`, the line numbers must strictly increase, and no line number
* may exceed `totalLines`. Any violation declines to the generic fallback rather
* than emitting a card that misnumbers or overcounts.
* @param meta - result metadata.
* @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data.
*/
export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
const { path, offset, lines, totalLines, lang } = meta as Record<string, unknown>
if (typeof path !== 'string' || typeof totalLines !== 'number' || typeof offset !== 'number') return undefined
if (!Number.isInteger(offset) || offset < 1) return undefined
if (!Number.isInteger(totalLines) || totalLines < 0) return undefined
if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined
if (lang !== undefined && typeof lang !== 'string') return undefined
let previous = offset - 1
for (const { number } of lines) {
if (number <= previous || number > totalLines) return undefined
previous = number
}
return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } }
}

View File

@@ -6,11 +6,11 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow, formatReadOutput } from './read-render.ts'
import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
@@ -118,6 +118,19 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
}),
}]
},
// Project the structured window into persisted `meta` so a UI's read card
// survives replay: the raw canonical output object is not on the wire, only
// the model-facing text, from which the line/lang data cannot be recovered.
presentationMeta: (_args, value) => {
const lang = langFromPath(value.path)
return {
path: value.path,
offset: value.offset,
lines: value.lines.map(({ number, text }) => ({ number, text })),
totalLines: value.totalLines,
...lang === undefined ? {} : { lang },
}
},
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
@@ -154,15 +167,32 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.emit('fs/observed', target, info.version, exec)
return outcome
},
presentResult(_args, result: ToolResult): GenericResultView | undefined {
// Result-time display: a `read` card carrying the structured line window a
// capable UI renders as a line-numbered, syntax-highlighted view. The
// structured data is narrowed from the persisted `meta` (replay-safe); the
// envelope-stripped model-facing text rides along as `content` so a UI without
// the read capability still shows the file text. A malformed or absent meta,
// or a result whose text is not the read envelope, declines to `undefined`
// (the generic fallback), never throwing on replay of obsolete logged output.
presentResult(_args, result: ToolResult): ReadResultView | undefined {
if (result.isError) return undefined
const meta = readMetaFromMeta(result.meta)
if (meta === undefined) return undefined
const only = result.content.length === 1 ? result.content[0] : undefined
const text = only?.type === 'text' ? only.text : undefined
if (text === undefined) return undefined
// Group 1 always captures (possibly empty) when the envelope matches.
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
if (body === undefined) return undefined
return { card: 'generic', content: [{ type: 'text', text: body }] }
return {
card: 'read',
path: meta.path,
offset: meta.offset,
lines: meta.lines,
totalLines: meta.totalLines,
...meta.lang === undefined ? {} : { lang: meta.lang },
content: [{ type: 'text', text: body }],
}
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the

View File

@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import type { ReadWindow } from '../src/read-render.ts'
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
@@ -116,3 +116,103 @@ describe('buildWindow', () => {
})
})
})
describe('langFromPath', () => {
it('maps a known extension to its language hint, case-insensitively', () => {
expect(langFromPath('src/a.ts')).toBe('ts')
expect(langFromPath('src/a.TSX')).toBe('tsx')
expect(langFromPath('/abs/module.mjs')).toBe('js')
expect(langFromPath('conf.yml')).toBe('yaml')
expect(langFromPath('README.md')).toBe('md')
})
it('reads the extension after the last path segment and last dot', () => {
expect(langFromPath('a.py.bak')).toBeUndefined()
expect(langFromPath('archive.tar.gz')).toBeUndefined()
expect(langFromPath('/dir.py/plain')).toBeUndefined()
expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
})
it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
expect(langFromPath('.gitignore')).toBeUndefined()
expect(langFromPath('/etc/hosts')).toBeUndefined()
expect(langFromPath('data.unknownext')).toBeUndefined()
expect(langFromPath('trailingdot.')).toBeUndefined()
})
it('returns undefined for a filename whose extension is an Object.prototype key', () => {
// Own-property lookup only: these must not resolve to the inherited member
// (a function/object), which would fail the tool-output JSON validation.
expect(langFromPath('foo.constructor')).toBeUndefined()
expect(langFromPath('foo.__proto__')).toBeUndefined()
expect(langFromPath('foo.toString')).toBeUndefined()
expect(langFromPath('foo.hasOwnProperty')).toBeUndefined()
})
})
describe('readMetaFromMeta', () => {
const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
it('narrows a well-formed read meta, with and without a lang hint', () => {
expect(readMetaFromMeta(good)).toEqual(good)
const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 }
expect(readMetaFromMeta(noLang)).toEqual(noLang)
})
it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => {
const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 }
expect(readMetaFromMeta(empty)).toEqual(empty)
})
it('returns undefined for absent, non-object, or array meta', () => {
expect(readMetaFromMeta(undefined)).toBeUndefined()
expect(readMetaFromMeta(null)).toBeUndefined()
expect(readMetaFromMeta('nope')).toBeUndefined()
expect(readMetaFromMeta([good])).toBeUndefined()
})
it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
})
it('rejects an offset that is not a 1-based integer', () => {
expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined()
})
it('rejects a first line number below offset', () => {
expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).toBeUndefined()
})
it('rejects a line number that is not a 1-based integer', () => {
expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined()
})
it('rejects a totalLines that is not a non-negative integer', () => {
expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined()
})
it('rejects lines that do not strictly increase or exceed totalLines', () => {
const twoLines = { path: '/abs/a', offset: 1, lang: 'ts' }
// Duplicate line numbers.
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined()
// Out-of-order line numbers.
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined()
// A line number past totalLines.
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined()
})
})

View File

@@ -320,6 +320,39 @@ describe('read tool', () => {
expect(text(result)).toContain('Output capped.')
})
it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
const result = await call(ctx, 'read', { file_path: 'a.ts' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read success')
// The extension drives the lang hint; the window rides on persisted meta.
expect(result.meta).toEqual({
path: '/abs/a.ts',
offset: 1,
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'ts',
})
const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
expect(view).toEqual({
card: 'read',
path: '/abs/a.ts',
offset: 1,
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'ts',
content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
})
})
it('omits the lang hint in meta for an extension that maps to no language', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:notes', 'plain')
const result = await call(ctx, 'read', { file_path: 'notes' })
if (result.isError) throw new Error('expected read success')
expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
})
})
describe('formatReadOutput footer variants', () => {
@@ -450,33 +483,72 @@ describe('tool-owned presentation (pure presentCall)', () => {
})
})
it('read: completed presentation removes the model-facing XML envelope', async () => {
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
// The structured line data rides on persisted meta (the raw output object is
// not on the wire); presentResult narrows it and appends the stripped text as
// the no-capability `content` fallback.
const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
isError: false,
meta,
})).toEqual({
card: 'generic',
card: 'read',
path: '/tmp/a.ts',
offset: 1,
lines: [{ number: 1, text: 'hello' }],
totalLines: 1,
lang: 'ts',
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
})
expect(await presentResult('read', { file_path: 'a.txt' }, {
// A window whose extension maps to no language omits `lang` from the card.
expect(await presentResult('read', { file_path: 'notes' }, {
content: [{ type: 'text', text: '<path>/tmp/notes</path>\n<type>file</type>\n<content>\nbody\n</content>' }],
isError: false,
meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 },
})).toEqual({
card: 'read',
path: '/tmp/notes',
offset: 1,
lines: [{ number: 1, text: 'body' }],
totalLines: 1,
content: [{ type: 'text', text: 'body' }],
})
// Malformed envelope text with valid meta still declines (the fallback text is unavailable).
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: 'malformed replay' }],
isError: false,
meta,
})).toBeUndefined()
// Valid envelope but absent/malformed meta declines to the generic fallback.
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
isError: false,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
isError: false,
meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
})).toBeUndefined()
})
it('read: completed presentation declines errors and non-single-text content', async () => {
const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }],
isError: true,
meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
isError: false,
meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'reasoning', text: envelope }],
isError: false,
meta,
})).toBeUndefined()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry-otel/README.md
README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db
README.zh.md: 76de1bf1ad58a0239907f3b63c672177874c7966
README.md: ad4a97868c28dc3873c839490aa506271459e249
README.zh.md: f1ad73ddf66aacc30a9024a9290df2c44686efe9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use.
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use, plus `user.id` — the harness home's anonymous user id this package owns (`src/user-id.ts`: `$DSH_HOME/.userid`, a random UUID minted on first use; deleting the file resets the identity), carried once per export batch on the Resource rather than per record.
## Config

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。
[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源;另有 `user.id`——本包自有的 harness home 匿名用户 id(`src/user-id.ts`:`$DSH_HOME/.userid`,首用生成随机 UUID;删除该文件即重置身份),随 Resource 每批导出携带一次而非逐条携带。
## 配置

View File

@@ -36,16 +36,20 @@
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-telemetry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -17,6 +17,7 @@ import z from 'schemastery'
import type { Context } from 'cordis'
import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from './user-id.ts'
import {
BatchLogRecordProcessor,
LoggerProvider,
@@ -118,6 +119,10 @@ export class TelemetryOtel extends Telemetry {
resource: resourceFromAttributes({
'service.name': APP_IDENTITY.product,
'service.version': APP_IDENTITY.version,
// OTel semconv's standard user attribute, carried once per export
// batch on the Resource rather than per record: the collector
// aggregates by Resource, and the id is process-stable anyway.
'user.id': getOrCreateAnonymousUserId(),
}),
processors: [
new BatchLogRecordProcessor({

View File

@@ -0,0 +1,103 @@
/**
* Per-harness-home anonymous user id for the OTel Resource.
*
* The id is a random UUID persisted as a bare line in `.userid` inside the
* harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`),
* and never derived from the hostname, network address, git remote, or any
* other identifying source — a derived id would make "anonymous" a fiction.
* The id is scoped to the harness home, not the machine: every process
* sharing one `$DSH_HOME` reports the same id, and deleting the file simply
* mints a fresh identity on the next launch (loss is accepted by design).
* This identity belongs to the OTel feed alone; the dsh-sdk launcher
* telemetry keeps its own separate store.
*
* Reads and writes are synchronous so the backend constructor can call this
* on its boot path, and the result is memoized per resolved file path: one
* process touches the disk once, and a file deleted mid-run keeps the
* process's id until the next launch.
*
* @module @deepseek-ai/dsh-session-telemetry-otel/user-id
*/
import { randomUUID } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** A harness-home-scoped anonymous user id (random UUID v4). */
export type AnonymousUserId = Branded<'AnonymousUserId'>
/** File inside the harness home storing the id: a bare UUID line, no wrapper format. */
export const USER_ID_FILE_NAME = '.userid'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Ambient seams for locating and generating the id; every field has a default. */
export interface AnonymousUserIdOptions {
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
randomUUID?: () => string
}
/** Process-lifetime memo keyed by resolved file path, so distinct test homes never share an id. */
const memo = new Map<string, AnonymousUserId>()
/** Read a valid persisted id from the file, or `undefined` when absent/corrupt. */
function readPersistedId(file: string): AnonymousUserId | undefined {
let text: string
try {
text = readFileSync(file, 'utf8')
} catch {
// Absent or unreadable: the caller mints and persists a fresh id.
return undefined
}
const value = text.trim()
return UUID_PATTERN.test(value) ? (value as AnonymousUserId) : undefined
}
/**
* Return the harness home's anonymous user id, creating and persisting one on
* first use. A concurrent first launch is settled by an exclusive-create
* write: the loser rereads the winner's id. (A reread landing in the winner's
* narrow create-to-write window can still yield two per-process ids for that
* run; the next launch converges on the persisted one.) Persistence is
* best-effort — a write failure (read-only home) still returns a usable id
* for the current run so telemetry is never blocked.
* @param options - home-location and UUID-generation seams.
* @returns the stable per-harness-home anonymous user id.
*/
export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId {
const file = join(resolveDshHome(undefined, options.env ?? process.env), USER_ID_FILE_NAME)
const cached = memo.get(file)
if (cached !== undefined) return cached
let id = readPersistedId(file)
if (id === undefined) {
const generate = options.randomUUID ?? randomUUID
const created = generate() as AnonymousUserId
try {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' })
id = created
} catch {
// A wx refusal (EEXIST) covers both a concurrent winner and a
// pre-existing corrupt file: the reread adopts a valid winner, and an
// invalid reread falls through to the overwrite path. Non-EEXIST
// failures (read-only home) land there too, accepted best-effort below.
id = readPersistedId(file)
if (id === undefined) {
try {
writeFileSync(file, `${created}\n`, 'utf8')
} catch {
// Best-effort persistence: keep the fresh id in memory even when the
// home is unwritable, so this run still reports a consistent id.
}
id = created
}
}
}
memo.set(file, id)
return id
}

View File

@@ -5,11 +5,15 @@
* for the default-exported Service class.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createServer, type Server } from 'node:http'
import { once } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { Context } from 'cordis'
import { getOrCreateAnonymousUserId } from '../src/user-id.ts'
import Loader from '@cordisjs/plugin-loader'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TelemetryOtel, { Config } from '../src/index.ts'
@@ -37,6 +41,21 @@ interface OtlpLogsRequest {
const servers: Server[] = []
// The backend resolves the harness home's anonymous user id at construction;
// pin DSH_HOME to a temp dir so the suite never touches the ambient ~/.dsh.
let tempHome: string
let previousDshHome: string | undefined
beforeAll(() => {
tempHome = mkdtempSync(join(tmpdir(), 'dsh-otel-home-'))
previousDshHome = process.env.DSH_HOME
process.env.DSH_HOME = tempHome
})
afterAll(() => {
if (previousDshHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousDshHome
rmSync(tempHome, { recursive: true, force: true })
})
afterEach(async () => {
for (const server of servers.splice(0)) {
server.close()
@@ -104,6 +123,7 @@ describe('TelemetryOtel wire', () => {
const resource = first.body.resourceLogs[0]!.resource.attributes
expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } })
expect(resource).toContainEqual({ key: 'user.id', value: { stringValue: getOrCreateAnonymousUserId() } })
const records = allRecords(captures)
const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel')

View File

@@ -0,0 +1,107 @@
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
USER_ID_FILE_NAME,
getOrCreateAnonymousUserId,
} from '../src/user-id.ts'
const dirs: string[] = []
function tempHome(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-userid-'))
dirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
chmodSync(dir, 0o700)
rmSync(dir, { recursive: true, force: true })
}
})
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('getOrCreateAnonymousUserId', () => {
it('creates, persists, and returns a bare UUID line on first use', () => {
const home = tempHome()
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(id).toMatch(UUID)
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('creates the home directory when missing', () => {
const home = join(tempHome(), 'nested', 'home')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('returns the persisted id on subsequent calls, tolerating surrounding whitespace', () => {
const home = tempHome()
const existing = '01234567-89ab-4cde-8f01-23456789abcd'
writeFileSync(join(home, USER_ID_FILE_NAME), ` ${existing}\n\n`, 'utf8')
expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(existing)
})
it('overwrites a corrupt file with a fresh id', () => {
const home = tempHome()
writeFileSync(join(home, USER_ID_FILE_NAME), 'not-a-uuid\n', 'utf8')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(id).toMatch(UUID)
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('adopts a concurrent winner: exclusive create loses to an id written after the initial read', () => {
const home = tempHome()
const winner = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const file = join(home, USER_ID_FILE_NAME)
// The generator seam runs between the initial read (absent) and the wx
// write, so planting the winner here simulates the concurrent first launch.
const id = getOrCreateAnonymousUserId({
env: { DSH_HOME: home },
randomUUID: () => {
writeFileSync(file, `${winner}\n`, 'utf8')
return 'ffffffff-0000-4000-8000-000000000000'
},
})
expect(id).toBe(winner)
})
it('returns a usable id when the home is unwritable, without persisting', () => {
const home = tempHome()
const blocked = join(home, 'blocked')
mkdirSync(blocked)
chmodSync(blocked, 0o500)
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } })
expect(id).toMatch(UUID)
expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false)
})
it('memoizes per resolved home for the process lifetime: one read, deletion-proof', () => {
const home = tempHome()
const first = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
rmSync(join(home, USER_ID_FILE_NAME))
expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(first)
})
it('keeps distinct homes on distinct ids', () => {
const a = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } })
const b = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } })
expect(a).not.toBe(b)
})
it('reads process.env by default', () => {
const home = tempHome()
const previous = process.env.DSH_HOME
process.env.DSH_HOME = home
try {
const id = getOrCreateAnonymousUserId()
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
} finally {
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
}
})
})

View File

@@ -26,6 +26,12 @@
{
"path": "../session-telemetry"
},
{
"path": "../../util/brand"
},
{
"path": "../../util/paths"
},
{
"path": "../../support/invariants"
}

View File

@@ -402,12 +402,15 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
// A generic card's own content, or a web card's fallback to the raw result
// content (the `web` view carries no `content` copy), both render as one dim
// Markdown block below, so links/lists/headings keep the unified dim styling
// rather than reading as bare text. Terminal and diff cards own their body
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic'
// A generic card's own content, or a read card's `content` fallback (the
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
// read renders exactly as before the read card existed), or a web card's
// fallback to the raw result content (the `web` view carries no `content`
// copy), all render as one dim Markdown block below, so links/lists/headings
// keep the unified dim styling rather than reading as bare text. Terminal and
// diff cards own their body styling, so they are excluded (mirrors
// renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic' || view.card === 'read'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
@@ -532,11 +535,12 @@ export class ToolCardComponent implements Component {
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
// The web card carries no `content` copy, so a `web` result view falls back
// to the raw result content here (`view.card === 'generic'` narrows the
// generic union arm; a `web` card takes the same fallback, mirroring the
// `markdownContent` selection in render()).
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
// A generic or read card carries its own envelope-stripped `content`; a `web`
// card carries no `content` copy and falls back to the raw result content
// here. (Mirrors the `markdownContent` selection in render(); a read card has
// no dedicated TUI rendering, so its `content` takes the same body path,
// keeping read output as it was before the read card existed.)
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []
// The presenter title headlines the body now that the header is a fixed