Merge remote-tracking branch 'origin/master' into fix/continuable-subagent-policy-inheritance

This commit is contained in:
Hypatia May
2026-08-10 16:50:09 +08:00
130 changed files with 783 additions and 530 deletions

View File

@@ -4,7 +4,7 @@
#
# A patch replaces the targeted row's whole `config`, so each row below
# restates every key it owns. The `dsh web` launcher alias turns --host/--port/
# --dev/--workspace-root/--trusted-host into further patches over these rows
# --dev/--trusted-host into further patches over these rows
# (`--dev` inserts the dsh-client-hmr row).
# ── surface-specific values the base deliberately omits ─────────────────────

View File

@@ -2350,15 +2350,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
archivedSessionIds: [...archivedSessionIds],
}),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
const existing = workspaces.find(w => w.path === target)
const { path } = request.payload
const existing = workspaces.find(w => w.path === path)
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
const now = new Date().toISOString()
const created: WorkspaceView = {
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
path: target,
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
path,
title: path.split('/').filter(Boolean).at(-1) ?? path,
sessionIds: [],
createdAt: now,
updatedAt: now,

View File

@@ -535,7 +535,7 @@ describe('createFixtureApi', () => {
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
})
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
@@ -546,7 +546,7 @@ describe('createFixtureApi', () => {
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.workspace.create(req({ name: 'nova' }))
const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.created).toBe(true)
expect(created.result.value.workspace).toMatchObject({
@@ -554,16 +554,7 @@ describe('createFixtureApi', () => {
})
await consuming
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
// path spelling falls back to the basename when no title/name rides along.
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
expect(pathOnly.result.value.workspace.title).toBe('base')
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
// no schema gate): both-absent falls back to the bucket dir, and a
// basename-less path serves as its own title.
const bare = await api.workspace.create(req({}))
if (!bare.result.ok) throw new Error('bare failed')
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
// A basename-less path serves as its own title.
const rootPath = await api.workspace.create(req({ path: '/' }))
if (!rootPath.result.ok) throw new Error('rootPath failed')
expect(rootPath.result.value.workspace.title).toBe('/')
@@ -584,7 +575,7 @@ describe('createFixtureApi', () => {
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
await api.workspace.create(req({ name: 'occupied' }))
await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' }))
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
@@ -722,7 +713,7 @@ describe('createFixtureApi', () => {
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
const made = await api.workspace.create(req({ name: 'nova' }))
const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
@@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' })
const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client')
const wsid = workspace.result.value.workspace.workspaceId
@@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
})
const client = new FixtureApiClient()
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
const made = await client.workspace.create({ name: 'query-workspace' })
const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' })
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)

View File

@@ -27,11 +27,11 @@ export interface IWorkspaces {
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
create(input: { path: string }): Promise<WorkspaceView>
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.

View File

@@ -120,7 +120,7 @@ export class WorkspaceManager {
/**
* Create or resolve a real Workspace, then publish its returned snapshot
* without waiting for the changed frame.
* @param input - name under workspaceRoot or an existing absolute path.
* @param input - the existing absolute path to adopt.
* @returns the wire result.
*/
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {

View File

@@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces {
}
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
async create(input: { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace

View File

@@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts'
/** Host input retained by a local Workspace until materialization succeeds. */
export type WorkspaceCreateInput = { name: string } | { path: string }
export type WorkspaceCreateInput = { path: string }
/** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot {
@@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
}
function intentName(input: WorkspaceCreateInput): string {
if ('name' in input) return input.name
const trimmed = input.path.replace(/[\\/]+$/, '')
return trimmed.split(/[\\/]/).pop() ?? input.path
}

View File

@@ -59,7 +59,7 @@ describe('WorkspaceManager', () => {
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
it('creates by path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
@@ -67,8 +67,8 @@ describe('WorkspaceManager', () => {
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))

View File

@@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces {
/**
* Create a Workspace (recorded). The default echoes a view derived from
* the input; stub for failure or list-coupled flows.
* @param input - exactly one Host create spelling.
* @param input - the Host create payload.
* @returns the created Workspace view.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
async create(input: { path: string }): Promise<WorkspaceView> {
this.calls.push({ method: 'create', args: [input] })
const stub = this.stubs.get('create')
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
const title = 'name' in input ? input.name : input.path
return {
workspaceId: `ws-${title}` as WorkspaceId,
title,
path: 'path' in input ? input.path : `/${input.name}`,
workspaceId: `ws-${input.path}` as WorkspaceId,
title: input.path,
path: input.path,
sessionIds: [],
} as unknown as WorkspaceView
}

View File

@@ -568,8 +568,8 @@ describe('workspaces action face', () => {
it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
const runtime = await SlotTestRuntime.create()
const ws = runtime.workspaces
const created = await ws.create({ name: 'alpha' })
expect(created.title).toBe('alpha')
const created = await ws.create({ path: '/tmp/alpha' })
expect(created.title).toBe('/tmp/alpha')
const registered = await ws.create({ path: '/tmp/beta' })
expect(registered.path).toBe('/tmp/beta')
await expect(ws.pickDirectory()).resolves.toBeNull()
@@ -593,7 +593,7 @@ describe('workspaces action face', () => {
ws.stub('openPath', () => Promise.resolve())
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
ws.stub('archiveSession', () => Promise.resolve())
expect((await ws.create({ name: 'y' })).title).toBe('X')
expect((await ws.create({ path: '/y' })).title).toBe('X')
await expect(ws.pickDirectory()).resolves.toBe('/picked')
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)

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: f684f99c9e80a02e3ccad57c9a4d7246df0b192b
README.zh.md: 61c746e35d3a221d34cf9e830a63ce5d8fa0dbfd
README.md: 23dbb1a5492afefe9064d86429f21b926f594a53
README.zh.md: 1d27beed9ca426096692b5710cc8d77396499449

View File

@@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).

View File

@@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态或摘要[历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态或摘要[历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡沿用用户气泡的呈现不加任何装饰transcript 中唯一的 steering 信号是它出现在轮次中途的位置
Think 行默认保持折叠并在不展开思维链的情况下暴露实时推理reasoning吞吐当推理块是流式输出尾部时摘要从结算后的首行切换到最新的非空行其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。

View File

@@ -16,16 +16,6 @@
min-width: 0;
max-width: min(525px, 82%);
}
/* Steering caption above the bubble: mid-turn interjections carry the same
bubble as a turn-opening prompt, so the transcript names which one this is. */
.steeringMark {
padding-right: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 16px;
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: 100%;

View File

@@ -1,7 +1,6 @@
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy IconActions; steering adds the
// interjection caption that names it; branch lives only under assistant
// answers), pending steering (caption + copy only), context injection,
// (right-aligned, with clock + copy IconActions; branch lives only under
// assistant answers), pending steering (copy only), context injection,
// compaction marker, retry disclosure, and unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
@@ -162,7 +161,7 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, imageLoader, actions, pending = false, steering = false, t,
content, imageLoader, actions, pending = false, t,
}: {
content: readonly unknown[]
imageLoader: ImageLoader
@@ -170,8 +169,6 @@ function UserStyleBubble({
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
steering?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, images, rest } = contentParts(content)
@@ -179,7 +176,6 @@ function UserStyleBubble({
const showBubble = text !== '' || rest.length > 0
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{showBubble && <div className={css.bubble}>
@@ -209,7 +205,6 @@ export function PendingSteeringBubble({ content, loadImage, t }: {
content={content}
imageLoader={imageLoader}
pending
steering
t={t}
actions={text => (
<MessageIconActions
@@ -232,7 +227,6 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
<UserStyleBubble
content={data.content}
imageLoader={loadImage}
steering={data.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions

View File

@@ -94,7 +94,6 @@ export const zh = {
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.running': '正在压缩…',
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens',
@@ -252,7 +251,6 @@ export const en = {
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.running': 'Compacting context…',
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',

View File

@@ -229,7 +229,7 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
it('consumed steering renders as a plain user bubble and keeps copy without branch', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -242,7 +242,7 @@ describe('MessageItem arms', () => {
} as never}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))

View File

@@ -466,9 +466,6 @@ describe('ChatView', () => {
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
// Pending and durable steering carry the same interjection caption, so the
// hand-off does not change what the row says it is.
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
@@ -490,7 +487,6 @@ describe('ChatView', () => {
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
// Only the durable steering bubble: the turn is still running, so its
// assistant narration owns no footer yet, and a steering bubble never
// carries a branch action.

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/feedback/command-feedback/README.md
README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d
README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83
README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023
README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f

View File

@@ -8,7 +8,7 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The
| Input | Result |
|---|---|
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded.` |
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. |
| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. |
Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged.
@@ -17,7 +17,7 @@ Surrounding whitespace is discarded, but feedback is otherwise unparsed: no trun
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract.
The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../interaction/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`.
The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../interaction/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. The acknowledgement identifies both the receiving session and the [shared anonymous user](../../session/user-id/); the first accepted feedback for a harness home can create `$DSH_HOME/.userid`. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record` and no user-id lookup.
The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text.

View File

@@ -8,7 +8,7 @@
| 输入 | 结果 |
|---|---|
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 |
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 |
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。
@@ -17,7 +17,7 @@
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。
反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../interaction/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`
反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../interaction/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。确认文本同时标明接收反馈的会话和[共享匿名用户](../../session/user-id/);对于某个 harness home首次接受反馈时可能创建 `$DSH_HOME/.userid`被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`,也不会查找用户 id
权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-user-id": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
@@ -38,6 +39,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -9,6 +9,7 @@
import type { Context } from 'cordis'
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
import type { Session } from '@deepseek-ai/dsh-session'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
export const name = 'command-feedback'
export const inject = ['commands']
@@ -41,14 +42,18 @@ export function recordFeedback(session: Session, text: string): void {
* Validate, record, and acknowledge one feedback entry. Returning an error
* leaves no `feedback/record` event.
* @param invocation - receiving agent, raw command input, and UI cancellation.
* @returns an acknowledgement, or a usage error when no feedback text was supplied.
* @returns an acknowledgement containing the receiving session and anonymous
* user ids, or a usage error when no feedback text was supplied.
*/
function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
if (invocation.rawInput.trim().length === 0) {
return { kind: 'error', text: `Feedback text is required. ${USAGE}` }
}
recordFeedback(invocation.agent.session, invocation.rawInput)
return { kind: 'success', text: 'Feedback recorded.' }
return {
kind: 'success',
text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`,
}
}
/** Register the global `/feedback` command for every composed command adapter. */

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
@@ -7,6 +7,17 @@ import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd'
return { USER_ID, getOrCreateAnonymousUserId: vi.fn(() => USER_ID) }
})
vi.mock('@deepseek-ai/dsh-user-id', () => ({
getOrCreateAnonymousUserId,
}))
beforeEach(() => getOrCreateAnonymousUserId.mockClear())
interface Harness {
readonly ctx: Context
readonly agent: Agent
@@ -93,7 +104,7 @@ describe('/feedback human command', () => {
const test = await harness()
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
kind: 'success',
text: 'Feedback recorded.',
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`,
})
expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
const commandRun = test.session.events.find(event => event.type === 'command/run')
@@ -141,8 +152,8 @@ describe('/feedback human command', () => {
test.ctx.commands.execute(test.agent, '/feedback second', signal),
])
expect(settled.map(item => item?.result)).toEqual([
{ kind: 'success', text: 'Feedback recorded.' },
{ kind: 'success', text: 'Feedback recorded.' },
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` },
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` },
])
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
})
@@ -167,6 +178,7 @@ describe('/feedback human command', () => {
}
await expect(run(test)).resolves.toEqual(expected)
await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
expect(feedbackTexts(test.session)).toEqual([])
const done = test.session.events.filter(event => event.type === 'command/done')
expect(done.map(event => event.data.kind)).toEqual(['error', 'error'])

View File

@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
@@ -11,6 +11,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
let root: string | undefined
let context: Context | undefined
@@ -20,6 +21,7 @@ afterEach(async () => {
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
vi.unstubAllEnvs()
})
/** Register one idle agent over a store-owned session, as an app's spine does. */
@@ -51,6 +53,7 @@ function agent(ctx: Context): Agent {
describe('/feedback real Loader composition through cordis.yml', () => {
it('boots cordis.yml and records feedback without model-visible output', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-'))
vi.stubEnv('DSH_HOME', root)
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
@@ -87,7 +90,11 @@ describe('/feedback real Loader composition through cordis.yml', () => {
expect(context.commands.list(owner).map(command => command.name)).toContain('feedback')
const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal)
expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' })
const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } })
expect(accepted?.result).toEqual({
kind: 'success',
text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`,
})
const rejected = await context.commands.execute(owner, '/feedback', signal)
expect(rejected?.result).toEqual({
kind: 'error',

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session/user-id"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 0e98520149297732da8a4d06608f9b04204a444f
README.zh.md: 27dc0ad668bd2da3d340fd081d698a7d090a94d4
README.md: d5afd21033afd8291c8059fb225deee3965f8b65
README.zh.md: cde0b4fd286579f5b389bc601750ca8303b35f15

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
## The shared Agent default (`agent-default-model` Settings section)
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` remains ApiProxy config because it is a Host launcher fact, not a model preference.
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it.
A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created.
@@ -36,7 +36,7 @@ Session model selection is a session-domain contract. `session.models` returns t
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 约定(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{workspaceRoot?}`,提供 `ctx.apiProxy`。该包在设计上与传输方式无关不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent智能体模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
所有客户端形态共用的 API 网关TS 约定(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`。该包在设计上与传输方式无关不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent智能体模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。`workspaceRoot` 仍属于 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型偏好。
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
@@ -36,7 +36,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering中途引导不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement而注入上下文审批通知、任务完成、附加快照携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found``session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。

View File

@@ -5,7 +5,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { dirname } from 'node:path'
import type { Context } from 'cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -512,8 +512,6 @@ export interface ApiProxyDefaults {
saveDefaultModelSelection?: (selection: ModelSelection) => Promise<void>
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
@@ -904,9 +902,6 @@ class SessionCwdConflict extends Error {
}
}
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** An explicit Host naming operation would duplicate another Workspace title. */
class WorkspaceNameConflictError extends Error {
constructor(readonly workspaceName: string) {
@@ -1476,29 +1471,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
/** Resolve or create one path while holding the Host's workspace-create chain. */
function ensureWorkspace(
path: string,
title: string | undefined,
rejectExistingName = false,
createDirectory = false,
): Promise<{ workspace: Workspace; created: boolean }> {
function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
const operation = workspaceCreationChain.then(async () => {
if (rejectExistingName && title !== undefined
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
throw new WorkspaceNameConflictError(title)
}
if (createDirectory) {
try {
await mkdir(path, { recursive: true })
} catch (error: unknown) {
throw new WorkspaceDirectoryCreationError(
`failed to create workspace directory "${path}": ${String(error)}`,
)
}
}
const existing = await ctx.workspace.resolveByPath(path)
if (existing !== undefined) return { workspace: existing, created: false }
return { workspace: await ctx.workspace.create(path, title), created: true }
return { workspace: await ctx.workspace.create(path), created: true }
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
return operation
@@ -2544,54 +2521,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}))
},
// Exactly one of path/name arrives (schema refine). Existing-folder
// adoption reuses its canonical path; create-by-name rejects a name
// already present in the registry.
// TODO: the create-by-name branch lost its last product consumer when
// the Web picker collapsed onto the directory flow
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
// Delete it with the wire schema's `name` member, this
// `defaults.workspaceRoot`, the client contract that carried the name
// (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
// `intentName`'s name branch, the manager's "name under workspaceRoot"
// contract), and the `dsh web --workspace-root` flag plus its apps/cli
// README lines, which exist only to feed it.
async create(request) {
const { payload } = request
let path: string
if (payload.name !== undefined) {
const name = payload.name.trim()
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
return err(request, {
code: 'workspace-invalid-path',
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
details: { path: payload.name },
})
}
path = join(defaults.workspaceRoot, name)
} else {
path = payload.path as string
}
const { path } = request.payload
try {
const name = payload.name?.trim()
const { workspace, created } = await ensureWorkspace(
path,
name,
name !== undefined,
name !== undefined,
)
const { workspace, created } = await ensureWorkspace(path)
return ok(request, { workspace: workspaceView(workspace), created })
} catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
if (error instanceof WorkspaceDirectoryCreationError) {
return err(request, { code: 'internal', message: error.message, details: {} })
}
// The registry rejects a path that does not resolve to an existing
// directory (realpath ENOENT / not-a-directory) — the business
// error of the typed-path flow, surfaced as a validation failure.

View File

@@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({
archivedSessionIds: z.array(sessionIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
/** workspace.create request payload: the existing directory to adopt. */
export const workspaceCreateRequestSchema = z.object({
path: z.string().optional(),
name: z.string().optional(),
}).refine(
payload => (payload.path === undefined) !== (payload.name === undefined),
{ message: 'workspace.create requires exactly one of path / name' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
path: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
/** workspace.create response value. */
export const workspaceCreateValueSchema = z.object({

View File

@@ -46,19 +46,14 @@ export interface WorkspaceApi {
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
/**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
* `name` (schema-enforced): `path` registers an EXISTING directory (no
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
* `name` is a single path segment the host mkdirs under its default project
* root before registering. Either spelling resolving to a directory already
* owned by a workspace returns that workspace (`created: false`) for the
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; path adoption allows distinct canonical paths
* whose basenames produce the same display title.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
* Creates (or idempotently resolves) a workspace over an EXISTING directory
* (no mkdir — a missing or non-directory path fails with
* `workspace-invalid-path`). A path resolving to a directory already owned
* by a workspace returns that workspace (`created: false`). Adoption allows
* distinct canonical paths whose basenames produce the same display title;
* the registry's basename title default names the new workspace.
*/
create(request: RpcRequest<{ path?: string; name?: string }>):
create(request: RpcRequest<{ path: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
/**

View File

@@ -12,7 +12,6 @@
* service; sessions that have already logged a selection remain unchanged.
*/
import { resolve } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent-default-model'
@@ -34,10 +33,8 @@ declare module 'cordis' {
}
}
/** Gateway plugin config: the Host-only Workspace creation root. */
/** Gateway plugin config for native Host integration. */
export interface Config {
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
/**
* Whether this deployment can hand paths to a native desktop opener —
* the `hasDocument` capability the agent-preset roster reports. Absent,
@@ -51,7 +48,7 @@ export interface Config {
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
* project directory and the fallback parent for name-created Workspaces.
* project directory.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
@@ -60,7 +57,6 @@ export class ApiProxyService extends Service implements ApiProxy {
]
static Config: z<Config> = z.object({
workspaceRoot: z.string(),
nativeOpen: z.boolean(),
})
@@ -80,12 +76,10 @@ export class ApiProxyService extends Service implements ApiProxy {
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const cwd = process.cwd()
const api = createApiProxy(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
cwd: process.cwd(),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
})
this.sessions = api.sessions

View File

@@ -137,7 +137,6 @@ async function harness(
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
workspaceRoot: cwd,
...options.defaults,
})
return { api, ctx, cwd }

View File

@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
return { ctx, api }
}
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
await ctx.plugin(ApprovalService)
let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await()
const abort = new AbortController()

View File

@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
await ctx.plugin(AgentRegistry)
return {
ctx,
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},

View File

@@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => {
return undefined
},
})
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
@@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
@@ -150,7 +150,7 @@ describe('cold history recovery view', () => {
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
if (!history.result.ok) throw new Error('history failed')
@@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => {
})
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => {
const resume = vi.spyOn(ctx.agents, 'resume')
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -312,7 +312,7 @@ describe('subagent ownership fence', () => {
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
@@ -371,7 +371,7 @@ describe('subagent ownership fence', () => {
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const prompt = await api.sessions.prompt(request({
sessionId,
@@ -412,7 +412,7 @@ describe('subagent ownership fence', () => {
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false)
@@ -458,7 +458,7 @@ describe('subagent ownership fence', () => {
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.prompt(request({
sessionId: agent.id,
@@ -476,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
@@ -501,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
list: () => Promise.resolve([]),
inspect,
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false)
@@ -527,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => {
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
const response = await api.sessions.prompt(request({
@@ -571,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => {
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)

View File

@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }

View File

@@ -25,7 +25,7 @@ import { RpcId } from '../src/api/rpc.ts'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {

View File

@@ -84,7 +84,6 @@ function liveAgent(
const api = (ctx: Context) => createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
describe('sessions.fork', () => {

View File

@@ -156,7 +156,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const result = await api.sessions.prompt(request({
@@ -203,7 +202,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const image = {
type: 'image' as const,
@@ -246,7 +244,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
agent.session.append('agent/inbox/spliced', {
target: 'next-turn',
@@ -277,7 +274,7 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -312,7 +309,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -384,7 +381,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
@@ -409,7 +405,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
@@ -429,7 +424,6 @@ describe('Web session model selection', () => {
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
workspaceRoot: '/tmp',
})
expectValue(await api.sessions.selectModel(request({
@@ -460,7 +454,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
// The client disabling its input is an affordance; this method stays
@@ -493,7 +486,6 @@ describe('Web session model selection', () => {
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const catalog = expectValue(await api.sessions.models(request({ sessionId })))

View File

@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
}
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {

View File

@@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
return {
ctx,
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
}
}

View File

@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
return session
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {

View File

@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
})
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }

View File

@@ -95,7 +95,7 @@ function bench(options: {
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}

View File

@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 9, abort)
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
@@ -238,7 +238,7 @@ describe('mux live view computation', () => {
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)

View File

@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {
@@ -101,11 +101,17 @@ async function harness(
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: workspaceRoot,
workspaceRoot,
cwd: root,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
return { api, ctx, storageDomain, workspaceRoot }
return { api, ctx, storageDomain, root }
}
/** Stage one directory under the harness root for path adoption. */
function stageDir(root: string, name: string): string {
const path = join(root, name)
mkdirSync(path)
return path
}
describe('host.pickDirectory', () => {
@@ -243,31 +249,25 @@ describe('host.openPath', () => {
})
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()
it('serializes concurrent creates of one path into a single registration', async () => {
const { api, root } = await harness()
const target = stageDir(root, 'alpha')
const responses = await Promise.all([
api.workspace.create(request({ name: 'alpha' })),
api.workspace.create(request({ name: 'alpha' })),
api.workspace.create(request({ path: target })),
api.workspace.create(request({ path: target })),
])
const created = responses.find(response => response.result.ok)
const duplicate = responses.find(response => !response.result.ok)
const values = responses.map(response => expectOk(response))
const created = values.find(value => value.created)
const resolved = values.find(value => !value.created)
expect(created).toBeDefined()
expect(expectOk(created!)).toMatchObject({
created: true,
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
})
expect(duplicate?.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
})
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
})
it('adopts only existing directories and rejects unsafe names', async () => {
const { api, workspaceRoot } = await harness()
const existing = join(workspaceRoot, 'existing')
mkdirSync(existing)
it('adopts only existing directories', async () => {
const { api, root } = await harness()
const existing = stageDir(root, 'existing')
const first = expectOk(await api.workspace.create(request({ path: existing })))
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
@@ -280,21 +280,16 @@ describe('workspace.create', () => {
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
expect(reopened.workspace.title).toBe('renamed-existing')
const missing = join(workspaceRoot, 'missing')
const missing = join(root, 'missing')
const missingResult = await api.workspace.create(request({ path: missing }))
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
expect(existsSync(missing)).toBe(false)
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
const invalid = await api.workspace.create(request({ name }))
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
}
})
it('adopts different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness()
const first = join(workspaceRoot, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project')
const { api, root } = await harness()
const first = join(root, 'one', 'project')
const second = join(root, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
@@ -315,8 +310,8 @@ describe('workspace.create', () => {
describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const sessionId = SessionId('session-workspace-preallocated')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -342,8 +337,8 @@ describe('session creation and Workspace membership', () => {
})
it('retains a published session when attachment fails and repairs it on retry', async () => {
const { api, ctx } = await harness()
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const { api, ctx, root } = await harness()
const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const workspace = ctx.workspace.list()[0]
if (workspace === undefined) throw new Error('workspace missing from registry')
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
@@ -393,7 +388,7 @@ describe('Host Workspace increments', () => {
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
const { api, root } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
@@ -401,7 +396,7 @@ describe('Host Workspace increments', () => {
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const workspaceIncrement = nextHostFrame(stream)
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
expect(await workspaceIncrement).toMatchObject({
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
})
@@ -429,7 +424,7 @@ describe('Host Workspace increments', () => {
})
it('does not publish a Workspace whose registry-order commit fails', async () => {
const { api, storageDomain } = await harness()
const { api, storageDomain, root } = await harness()
const domain = storageDomain.get('workspace')
if (domain === undefined) throw new Error('workspace domain is not open')
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
@@ -438,7 +433,7 @@ describe('Host Workspace increments', () => {
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const next = stream.next()
const failed = await api.workspace.create(request({ name: 'ghost' }))
const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
expect(failed.result.ok).toBe(false)
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
abort.abort()
@@ -446,8 +441,8 @@ describe('Host Workspace increments', () => {
})
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
const sessionId = SessionId('session-kept-after-workspace-delete')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -479,8 +474,8 @@ describe('Host Workspace increments', () => {
})
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
const { api } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
const { api, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
const sessionId = SessionId('session-to-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])

View File

@@ -432,8 +432,8 @@ describe('workspace domain round trip', () => {
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
})
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
const response = await client(scriptedApi()).workspace.create({})
it('rejects a pathless create payload at the handler schema', async () => {
const response = await client(scriptedApi()).workspace.create({} as never)
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
})

View File

@@ -330,11 +330,11 @@ describe('workspace domain schemas', () => {
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
})
it('create requires exactly one of path/name (both refine arms)', () => {
it('create requires a path', () => {
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
expect(() => workspaceCreateRequestSchema.parse({})).toThrow()
// The retired create-by-name spelling stays a clean schema rejection.
expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow()
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
})

View File

@@ -35,23 +35,21 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-command-feedback": "^0.0.1",
"@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",
"@deepseek-ai/dsh-user-id": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-command-feedback": "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:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -25,7 +25,7 @@ import {
type TelemetrySeverity,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from './user-id.ts'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import {
BatchLogRecordProcessor,
LoggerProvider,

View File

@@ -13,7 +13,7 @@ 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 { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import Loader from '@cordisjs/plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'

View File

@@ -30,10 +30,7 @@
"path": "../session-telemetry"
},
{
"path": "../../util/brand"
},
{
"path": "../../util/paths"
"path": "../user-id"
},
{
"path": "../../support/invariants"

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/user-id/README.md
README.md: 31a72f5e7b58b90b165b16374c2301389cbe2ca0
README.zh.md: 013097b3038c43ff740660ef9159ca2b13f7b743

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-user-id
English | [中文](README.zh.md)
Shared anonymous identity for session telemetry and direct feedback acknowledgement. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement so an operator can correlate a submitted session and user with exported telemetry.
The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities, and the dsh-sdk launcher telemetry intentionally keeps its own unrelated store.
## Storage contract
Reads and writes are synchronous because both boot-time telemetry construction and direct command execution need one API. The result is memoized per resolved file path for the process lifetime. A first writer uses exclusive creation and a concurrent loser adopts the persisted winner; a corrupt file is replaced. Persistence is best-effort, so an unwritable home still receives a process-local UUID rather than blocking telemetry or feedback.
## Composition
This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect.
## Model Experience
None, as the identifier is used only in telemetry metadata and a direct human command response; it never enters a model request.
#### KV Cache effect
None; this package never contributes to a model request.
## Known Limitations and Deferred Work
- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity.
- **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value.
- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated, and this package does not unify the separate dsh-sdk launcher telemetry identity.

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-user-id
[English](README.md) | 中文
会话遥测与直接反馈确认共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值,以便运维人员将所报告的会话和用户与导出的遥测相关联。
该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份dsh-sdk launcher telemetry 则刻意使用与此无关的独立存储。
## 存储契约
读写采用同步方式,因为启动时构造遥测和直接执行命令都需要使用同一个 API。结果在进程生命周期内按解析后的文件路径缓存。首个写入方采用独占创建并发竞争中失败的一方会采用已持久化的胜出值。损坏的文件会被替换。持久化采用 best-effort因此即使 home 不可写,系统仍会返回进程本地 UUID而不会阻塞遥测或反馈。
## 组合
本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。
## 模型体验
无,因为该标识符只用于遥测元数据和面向用户的直接命令响应;它绝不会进入模型请求。
#### KV Cache 影响
无;本包绝不会向模型请求贡献任何内容。
## 已知限制与暂缓工作
- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。
- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID后续启动会收敛到已持久化的值。
- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联,本包也不会统一 dsh-sdk launcher telemetry 的独立身份。

View File

@@ -0,0 +1,39 @@
{
"name": "@deepseek-ai/dsh-user-id",
"description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,22 +1,20 @@
/**
* Per-harness-home anonymous user id for the OTel Resource.
* Per-harness-home anonymous user id shared by telemetry and feedback.
*
* 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.
* other identifying source. It is scoped to the harness home, not the
* machine: every process sharing one `$DSH_HOME` reports the same id, and
* deleting the file mints a fresh identity on the next launch. 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.
* Reads and writes are synchronous so boot-time and command consumers can
* use one API. 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
* @module @deepseek-ai/dsh-user-id
*/
import { randomUUID } from 'node:crypto'
@@ -64,8 +62,8 @@ function readPersistedId(file: string): AnonymousUserId | undefined {
* 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 hooks.
* for the current run so feedback and telemetry are 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 {

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-user-id`.
* @module @deepseek-ai/dsh-user-id/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-user-id'
/** Cordis companion plugin name. */
export const name = 'user-id-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the API owns one private memo and one best-effort
* file, with no independent event stream or public mutable relation for a
* companion to compare without creating the identity as a side effect.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant'
describe('invariant companion', () => {
it('registers the package ownership with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(UserIdInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
USER_ID_FILE_NAME,
getOrCreateAnonymousUserId,
} from '../src/user-id.ts'
} from '../src/index.ts'
const dirs: string[] = []

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../util/brand"
},
{
"path": "../../util/paths"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise<Bench> {
if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
return {
ctx,
session,

View File

@@ -139,6 +139,11 @@ export class WorkspaceRegistry extends Service {
* @param title - Display title used only when a new record is created.
* @returns the existing or newly durable workspace.
*/
// TODO: `title` lost its last production caller when the gateway's
// create-by-name branch was deleted
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md);
// drop the parameter with its @param clause and the `create(path, title?)`
// lines in this package's README pair.
async create(path: string, title?: string): Promise<Workspace> {
const canonical = await realpathNormalize(path)
if (!(await stat(canonical)).isDirectory()) {