Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api/sessions.ts
This commit is contained in:
creatixchu
2026-07-31 00:09:49 +08:00
138 changed files with 3057 additions and 505 deletions

View File

@@ -1094,6 +1094,56 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const appended = logOf(sessionId).at(-1) as SessionEvent
return ok(request, { title: normalized, seq: appended.seq })
},
fork: (request) => {
const { sessionId, atSeq } = request.payload
const source = summaryOf(sessionId)
if (source === undefined) {
return err(request, {
code: 'session-not-found',
message: `no session ${sessionId}`,
details: { sessionId },
})
}
const log = logs.get(sessionId) ?? []
const lastSeq = log.at(-1)?.seq ?? -1
const anchoredBoundary = atSeq === undefined
? undefined
: log.find(e => e.type === 'turn/end' && e.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? log.findLast(e => e.type === 'turn/end')
: undefined)
if (boundary === undefined) {
return err(request, {
code: 'fork-unavailable',
message: atSeq !== undefined && atSeq <= lastSeq
? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}`
: `session ${sessionId} has no completed turn`,
details: { sessionId },
})
}
let cut = boundary.seq + 1
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
const child: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd },
}
logs.set(child.sessionId, log.slice(0, cut))
sessions.push(child)
emitHost({
type: 'host/session-added', sessionId: child.sessionId, blank: false,
parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd },
})
const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
if (workspace !== undefined) {
workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { sessionId: child.sessionId })
},
history: async (request) => {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1690,6 +1740,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.rename': return this.api.sessions.rename(request)
case 'session.fork': return this.api.sessions.fork(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.attachment': return this.api.sessions.attachment(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)

View File

@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
@@ -101,6 +102,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 10329668d9a7a3e7230ab6f3ef9cdb127aec584c
README.zh.md: d6555bb4e71aee7ae195e4f9e98c21eb349acf3c
README.md: 6917951e1a2b97dc6cab27a1bb046022c726304f
README.zh.md: 88019725c08355181c97eed4eb917fd96f7ce4e2

View File

@@ -28,6 +28,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `N` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.

View File

@@ -28,6 +28,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值直到打开或恢复会话促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd`blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)``N` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
## 会话模型选择
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。

View File

@@ -29,6 +29,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).

View File

@@ -289,6 +289,40 @@ export class SessionManager {
}
}
/**
* Contract session.fork; on success merge the child into summaries
* immediately (same synchronous-addressability guarantee as create). The
* child carries the source's history, so it is never blank; lineage rides
* parentSessionId so the list nests it under its source. A child published
* before Workspace attachment fails is also reconciled into the list.
* @param opts - source session and the optional seq anchoring the cut.
* @returns the fork result (the child session id).
*/
async fork(
opts: { sessionId: SessionId; atSeq?: number },
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const { result } = await this.api.sessions.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
})
const childId = result.ok
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
}
return result
} catch (error) {
return transportError(error)
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the

View File

@@ -83,6 +83,22 @@ export class SessionCreateError extends Error {
}
}
/** Structured session-fork failure. */
export class SessionForkError extends Error {
override readonly name = 'SessionForkError'
/**
* @param rpcError - Host business or folded transport error.
* @param sourceSessionId - the session the fork was cut from.
*/
constructor(
readonly rpcError: RpcError,
readonly sourceSessionId: SessionId,
) {
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
@@ -123,6 +139,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
return id
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* @param title - source session's durable title.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
}
const fullWidth = /^(.*?)(\d+)$/u.exec(title)
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
return `${fullWidth[1]}${BigInt(fullWidth[2]) + 1n}`
}
return `${title} (1)`
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
@@ -319,6 +353,42 @@ export class SessionsService implements ISessions {
return result.value.sessionId
}
/**
* Fork a session from a completed-turn prefix of the source (same
* synchronous-addressability guarantee as {@link SessionsService.create}:
* on resolution the child is in the list store and open() can target it).
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
*/
async fork(opts: {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
}): Promise<SessionId> {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
if (sourceTitle !== undefined) {
const child = this.binding(childId)?.session
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
const renamed = await child.rename(increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
}
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).

View File

@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -120,6 +121,7 @@ export class FakeApiClient implements IApiClient {
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),

View File

@@ -277,6 +277,23 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a fork child published before workspace attachment fails', async () => {
const api = new FakeApiClient()
api.onFork = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
sessionId: S2,
parentSessionId: S1,
blank: false,
})])
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))

View File

@@ -10,7 +10,7 @@ import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -399,6 +399,69 @@ describe('create', () => {
})
})
describe('fork', () => {
it.each([
['Roadmap', 'Roadmap (1)'],
['Roadmap (1)', 'Roadmap (2)'],
['计划1', '计划2'],
['计划 9', '计划 10'],
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
})
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = (payload) => {
const { title } = payload as { title: string }
return Promise.resolve(ok({ title, seq: 3 }))
}
await expect(b.svc.fork({
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
})).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
title: childTitle,
displayTitle: childTitle,
parentId: 'source',
})
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
expect(b.api.callsOf('session.rename')).toEqual([])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
expect(b.api.callsOf('session.rename')).toEqual([])
})
it('rejects when child rename fails while keeping the published child addressable', async () => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'source-title' as never,
payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
})
await feedList(b, [{ id: 'source' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = () => Promise.resolve(err({
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
}))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
expect(b.svc.binding(sid('child'))).toBeDefined()
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
const b = bench()

View File

@@ -179,7 +179,7 @@ export class TestSessions implements ISessions {
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -402,6 +402,17 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).
* @param opts - source session id, optional cut anchor, and client title policy.
* @returns the source id (no child record is created).
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId> {
this.calls.push({ method: 'fork', args: [opts] })
return Promise.resolve(opts.sessionId)
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).

View File

@@ -201,7 +201,7 @@ describe('sessions', () => {
await runtime.dispose()
})
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
it('records service-face calls; open() moves selection, clear() empties it, and fork() echoes the source', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
@@ -211,9 +211,13 @@ describe('sessions', () => {
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
await expect(runtime.sessions.fork({
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
})).resolves.toBe('s1')
expect(runtime.sessions.calls).toEqual([
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
])
await runtime.dispose()
})

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: dd9292e1586828f107850a7f3676fc8bb115adcb
README.zh.md: 3b02f8f5b3f2200379e4d652ae9bc471388ee83f
README.md: 3447c3389aaaff37b1f9bf6cbe55f03f7b746a86
README.zh.md: de097d44e030fc0e1afb46d9d770b0de855e3cf7

View File

@@ -42,7 +42,7 @@ None; this package neither assembles nor sends a provider request.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -40,9 +40,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **统计行的耗时只覆盖窗口内消息流**LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -306,6 +306,13 @@ export function apply(ctx: Context): void {
},
loadOlder: () => { void scoped.loadOlder() },
loadImage: attachment => conversation.resolveImage(sessionId, attachment),
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the source view untouched.
})
},
}
},
}, ChatView)

View File

@@ -25,6 +25,10 @@ export interface AssistantMarkdownProps {
loadImage?: ImageLoader
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
onFork?: ((seq: number) => void) | undefined
}
function firstLine(text: string): string {
@@ -62,7 +66,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, loadImage = unavailableImage, time,
blocks, streaming, interrupted, loadImage = unavailableImage, time, seq, onFork,
}: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
@@ -94,6 +98,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
/>
)}

View File

@@ -233,7 +233,7 @@ function StreamingTail({ useSession, onGrow, loadImage }: {
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage,
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, forkAt,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
@@ -382,6 +382,8 @@ export function ChatView({
interrupted={node.interrupted}
loadImage={loadImage}
time={node.time}
seq={node.seq}
onFork={forkAt}
/>
)
}
@@ -390,7 +392,7 @@ export function ChatView({
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} loadImage={loadImage} />
return <MessageItem key={item.key} node={node} loadImage={loadImage} onFork={forkAt} />
}
return (

View File

@@ -1,5 +1,6 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock,
// optional edit stub.
import { useCallback } from 'react'
import {
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Fork the session at this message. */
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
text, time, clock, edit, onBranch, className,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -48,7 +51,7 @@ export function MessageIconActions({
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>

View File

@@ -18,6 +18,8 @@ import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
loadImage?: ImageLoader
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
}
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
@@ -73,7 +75,9 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node, loadImage = unavailableImage }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({
node, loadImage = unavailableImage, onFork,
}: MessageItemProps) {
switch (node.kind) {
case 'user': {
const { text, images, rest } = contentParts(node.content)
@@ -91,6 +95,7 @@ export const MessageItem = memo(function MessageItem({ node, loadImage = unavail
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
/>
</div>

View File

@@ -438,6 +438,8 @@ export interface ChatViewInjected {
loadOlder: () => void
/** Resolve a session-authorized historical image for inline display. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
/** Fork the session through the turn containing the message at `seq`, then open the child. */
forkAt: (seq: number) => void
}
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */

View File

@@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => {
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
chatView.injected.forkAt(17)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
})
expect(b.runtime.sessions.calls).toContainEqual({
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
})
await b.runtime.dispose()
})

View File

@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
@@ -127,9 +128,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openFile,
loadOlder,
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
forkAt,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
}
describe('chat-flow derivation', () => {
@@ -201,6 +203,16 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
const view = render(<h.ChatView {...h.props} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })

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-question/README.md
README.md: 0700375758774610fcd897b9a3e16484206a871d
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3

View File

@@ -6,6 +6,8 @@ Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user`
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.

View File

@@ -6,6 +6,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权HTTP 交付成功不会在本地移除待处理状态。
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale``question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。

View File

@@ -0,0 +1,113 @@
/* Plan-review takeover: the waiting-approval card language (amber strip on a
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
its own module rather than shared with ui-conversation's ApprovalPanel: the
two takeovers agree on tokens and geometry, not on content — this one's body
is scrollable markdown, that one's is a headline plus a command line. Warn
semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors the question card's frame so the takeover is a content swap. */
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
}
.card {
display: flex;
overflow: hidden;
flex-direction: column;
width: 100%;
max-width: 776px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the plan, so the
strip and the decision row stay reachable on a long plan. */
max-height: min(60vh, 520px);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark: the plan body inside scrolls once the card hits
the cap above, so the thumb takes the l2 pair (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.card,
.card * {
box-sizing: border-box;
}
/* Tinted full-width header band, as on the approval takeover. */
.strip {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
}
/* The plan is the panel's message: it takes the whole body and the scroll. */
.body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 16px 4px;
font-size: 14px;
line-height: 22px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 12px;
padding: 8px 16px 12px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
border-radius: 16px;
}
.body {
padding: 10px 12px 4px;
}
.footer {
align-items: flex-end;
padding: 8px 12px 10px;
}
}

View File

@@ -0,0 +1,100 @@
// PlanReviewPanel: the composer takeover for a question carrying the
// `plan-review` presentation intent. A plan under review is one decision over
// one body of markdown, so it takes the waiting-approval card shape — tinted
// strip, content, right-aligned action row — instead of the generic question
// flow's pager, numbered options, skip and custom-answer affordances, which
// read as a quiz the user is being graded on.
//
// The three actions are the whole decision surface: approve and decline answer
// the question with the option labels the asker offered (localised copy on the
// buttons, the asker's descriptions as their tooltips), while "discuss"
// dismisses the request so the composer returns and the user can simply say
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
// a labelled button because in a two-outcome decision it is the third real
// answer, not an escape hatch.
import { useState } from 'react'
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
import css from './PlanReviewPanel.module.css'
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
export type PlanReviewPanelProps =
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
/**
* Optional-prop spread for a decision button's tooltip: `title` is optional on
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
*
* @param description - the asker's option description, when it carries one.
* @returns The `title` prop to spread, or nothing.
*/
function tooltip(description: string | undefined): { title?: string } {
return description === undefined ? {} : { title: description }
}
/**
* Render a plan review as a decision card.
*
* @param props - the question domain face, the narrowed plan review, and `t`.
* @returns The plan-review takeover for this request.
*/
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
// One-shot latch shaped like the approval takeover's: the panel leaves only
// when the host's resolved frame lands, so until then a second click must
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
// shows why, since nothing else would tell the user the click was lost.
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const settle = (send: () => Promise<void>): void => {
setBusy(true)
setError(null)
void send().catch((cause: unknown) => {
setBusy(false)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const decide = (label: string): void => {
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
}
const decline = review.decline
return (
<div className={css.frame} data-plan-review-key={pending.key}>
<section className={css.card} aria-label={review.question}>
<div className={css.strip}>
<span className={css.dot} />
{t('plan.header')}
</div>
<div className={css.body} data-plan-review-scroll>
<MarkdownText text={review.plan} />
</div>
<div className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.actions}>
<Button
size="sm" variant="ghost" icon={<IconEditOutline16 />}
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
>
{t('plan.discuss')}
</Button>
{decline !== undefined && (
<Button
size="sm" variant="outline" {...tooltip(decline.description)}
disabled={busy} onClick={() => { decide(decline.label) }}
>
{t('plan.decline')}
</Button>
)}
<Button
size="sm" variant="primary" {...tooltip(review.approve.description)}
disabled={busy} onClick={() => { decide(review.approve.label) }}
>
{t('plan.approve')}
</Button>
</div>
</div>
</section>
</div>
)
}

View File

@@ -5,9 +5,10 @@ import {
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
PendingQuestion,
PendingQuestion, planReviewOf,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
@@ -46,14 +47,24 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
*
* One takeover, two shapes: a request that declares a presentation intent this
* package renders takes that shape (a plan review is one decision over one
* plan, not a question set), and every other request takes the generic flow.
* The routing lives here, at the one entry that owns the composer seat, so
* neither shape can claim a request the other is already rendering.
*
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow for this request.
* @returns The question flow, or the intent's own surface, for this request.
*/
export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
return <QuestionFlow key={question.key} pending={question} t={props.t} />
const review = useMemo(() => planReviewOf(question.questions), [question])
return review === undefined
? <QuestionFlow key={question.key} pending={question} t={props.t} />
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
}
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {

View File

@@ -19,6 +19,71 @@ export type QuestionWait = PendingWait<'question'>
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = QuestionResponsePayload['answer']
/** One question of the request, as the carrier payload carries it. */
type QuestionItem = QuestionWait['payload']['questions'][number]
/** One option the asker offered on a question. */
type QuestionOption = NonNullable<QuestionItem['options']>[number]
/**
* A request narrowed to the `plan-review` presentation intent: everything the
* decision card renders and answers with, so the panel never re-reads the
* request shape. `approve` and `decline` are the asker's own options — an
* answer must carry one of those labels verbatim — and `plan` is the markdown
* body under review.
*/
export interface PlanReview {
/** The reviewed question's id, echoed in the answer. */
id: string
/** The question text, kept as the card's accessible name. */
question: string
/** The plan markdown under review. */
plan: string
/** The option that approves the plan. */
approve: QuestionOption
/** The option that declines it; absent when the asker offered no other option. */
decline?: QuestionOption
}
/**
* Narrow a request to a renderable plan review, or return undefined to leave it
* to the generic question flow.
*
* The card is one decision over one plan, and it claims a request only when it
* can send every answer that request allows — an intent changes the layout,
* never which answers are reachable. So the batch must be a single question
* that declares the intent, carries the plan as its detail, offers the approve
* label the intent names, and is a binary single choice: at most one option
* besides approve, and not multi-select. A third option or a multi-select batch
* has answers two buttons cannot express, so the generic flow keeps it — as it
* keeps any request whose intent the asker's own service would have rejected,
* because the client sits downstream of a wire boundary and every request must
* stay answerable.
*
* @param questions - the request's whole question batch.
* @returns The narrowed review, or undefined when the generic flow owns it.
*/
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
if (questions.length !== 1) return undefined
// Length-checked above; the index read is the narrowing tax, not a guess.
const question = questions[0] as QuestionItem
const intent = question.intent
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
if (question.multiSelect === true) return undefined
const options = question.options ?? []
if (options.length > 2) return undefined
const approve = options.find(option => option.label === intent.approve)
if (approve === undefined) return undefined
const decline = options.find(option => option.label !== intent.approve)
return {
id: question.id,
question: question.question,
plan: question.detail,
approve,
...(decline === undefined ? {} : { decline }),
}
}
/**
* Question domain face over the carrier: render identity and questions
* transparently forwarded; answer/cancel own the wire encoding (the ok value

View File

@@ -5,6 +5,12 @@
* question carrier (matched prop), and the whole behavior surface rides the
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
*
* One entry, two shapes: the composer renders a request that declares a
* presentation intent as that intent's own surface (`plan-review` → the plan
* decision card) and every other request as the generic question flow. A
* separate chain entry per shape would race the same carrier, so the shape
* choice lives inside this entry — see QuestionComposer.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -15,7 +21,9 @@ import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type {
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
} from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {

View File

@@ -11,6 +11,10 @@ export const zh = {
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
'plan.header': '计划待审',
'plan.approve': '确认执行',
'plan.decline': '拒绝',
'plan.discuss': '去聊天里说',
} satisfies Record<string, string>
/** The question namespace key union. */
@@ -27,4 +31,8 @@ export const en = {
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',
'plan.header': 'Plan review',
'plan.approve': 'Approve',
'plan.decline': 'Refuse',
'plan.discuss': 'Chat about it',
} satisfies Record<QuestionKey, string>

View File

@@ -0,0 +1,228 @@
// @vitest-environment jsdom
// The plan-review takeover, driven through the composer entry that routes to
// it: a request carrying the intent must reach the decision card and answer
// with the asker's own option labels, and a request that does not (or cannot)
// must keep the generic question flow.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
t: seatOver(zh, commonZh),
}
const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
/** The plan-mode request shape: one question, the plan as detail, approve named. */
const questions = (): QuestionWait['payload']['questions'] => [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: PLAN,
options: [
{ label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
],
intent: { kind: 'plan-review', approve: 'Approve' },
}]
/** Carrier fixture over a scripted respond carrier. */
function wait(
payload: QuestionWait['payload'] = { questions: questions() },
respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
) {
return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
}
/** The client-response envelope respond must have received for a decision. */
function decidedEnvelope(label: string) {
return {
type: 'client-response', rpcId: RpcId('q-1'),
result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
}
}
describe('planReviewOf', () => {
it('narrows a plan-review request to its decision, options included', () => {
expect(planReviewOf(questions())).toEqual({
id: 'plan-review',
question: 'Approve this plan and leave plan mode?',
plan: PLAN,
approve: { label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
decline: { label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
})
})
it('leaves the decline absent when the asker offered approve alone', () => {
const [question] = questions()
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
expect(review?.approve).toEqual({ label: 'Approve' })
expect(review === undefined ? true : 'decline' in review).toBe(false)
})
it.each([
['a batch of more than one question', () => [...questions(), ...questions()]],
['no intent at all', () => [{ ...questions()[0] as object, intent: undefined }]],
['an intent without the plan as detail', () => [{ ...questions()[0] as object, detail: undefined }]],
['an intent whose approve names no option', () => [{
...questions()[0] as object, intent: { kind: 'plan-review', approve: 'Ship it' },
}]],
['an intent with no options at all', () => [{ ...questions()[0] as object, options: undefined }]],
// Two buttons cannot send a third label or a combination, and the generic
// flow can: an intent never costs the user a reachable answer.
['a third option the card could not offer', () => [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }, { label: 'Start over' }],
}]],
['a multi-select decision', () => [{ ...questions()[0] as object, multiSelect: true }]],
])('declines %s, leaving the request to the generic flow', (_case, build) => {
expect(planReviewOf(build() as never)).toBeUndefined()
})
it('declines an empty batch, which the generic flow reports as such', () => {
expect(planReviewOf([])).toBeUndefined()
})
})
describe('PlanReviewPanel', () => {
it('renders the plan under a review strip, with none of the quiz affordances', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
// The plan renders as markdown, so its heading is a heading.
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
expect(screen.getByText('render the rows')).toBeTruthy()
// The question text stays as the card's accessible name rather than a title
// that reads like a test item.
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
// No pager, no numbered options, no skip, no custom answer.
expect(screen.queryByText('1 / 1')).toBeNull()
expect(screen.queryByRole('radio')).toBeNull()
expect(screen.queryByText(zh['action.skip'])).toBeNull()
expect(screen.queryByRole('textbox')).toBeNull()
})
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
fireEvent.click(approve)
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
// One-shot: every action locks until the host's resolved frame lands.
expect(approve.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
fireEvent.click(approve)
expect(respond).toHaveBeenCalledTimes(1)
})
it('answers with the asker\'s decline label', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
})
it('dismisses the request so the composer returns for a plain message', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('q-1'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
})
it('omits the tooltip for an option carrying no description', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object,
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
})
it('hides the decline action when the asker offered approve alone', () => {
const { carrier } = wait({ questions: [{
...questions()[0] as object, options: [{ label: 'Approve' }],
}] as never })
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
})
it('re-arms the actions and says why when the decision does not land', async () => {
const { carrier, respond } = wait(
{ questions: questions() },
vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
const failure = await screen.findByText('question response rejected: not-pending')
expect(failure.getAttribute('role')).toBe('status')
// Re-armed for the retry: a lost click must not leave a dead card.
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
expect(respond).toHaveBeenCalledTimes(2)
})
it('reports a non-Error transport failure as its stringified value', async () => {
// A non-Error rejection is the case under test: a carrier can reject with
// anything, and the panel must still show the user something.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(await screen.findByText('socket gone')).toBeTruthy()
})
it('carries the same decision surface in English', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5
README.md: 860c24b8a25a1e9968261f586c16163579131a1c
README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6

View File

@@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration.
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
## Model Experience
@@ -18,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions.
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -6,6 +6,8 @@
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
## 模型体验
@@ -18,5 +20,5 @@
## 已知限制与暂缓事项
- **没有 Session 删除与 fork 控件**Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果Rename 已接线);删除 Workspace 注册记录不会删除 Session。
- **没有 Session 删除控件**Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -83,7 +83,7 @@ interface DragState {
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
@@ -98,13 +98,12 @@ type SessionTreeProps = Pick<
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, workspaces, query,
useSessions, startSession, open, forkSession, workspaces, query,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null)
const currentGroup = current === undefined
@@ -116,8 +115,8 @@ function SessionTree({
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
() => deriveGroups(list, workspaces, { expandedProjects, query }),
[list, workspaces, expandedProjects, query],
)
const now = Date.now()
@@ -128,7 +127,7 @@ function SessionTree({
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// Group section: header row + expanded top-level session rows. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (WorkspaceBrowser.module.css).
<div key={group.key} className={css.groupSection}>
@@ -152,7 +151,7 @@ function SessionTree({
}}
/>
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots outside search. The drag
// Draggable: real-workspace session rows outside search. The drag
// never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section).
const draggable = group.workspaceId !== undefined && query === ''
@@ -170,15 +169,15 @@ function SessionTree({
drop: (half: 'before' | 'after') => {
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
if (drag === null) return
const roots = group.sessions
const sessions = group.sessions
// Anchor = the row the insert line points at ('after' means
// the next root; end-of-list omits the anchor → append).
const anchor = half === 'before' ? node.id : roots[index + 1]?.id
const anchor = half === 'before' ? node.id : sessions[index + 1]?.id
setDrag(null)
if (anchor === drag.sessionId) return
// No-op when the drop lands back on the source position.
const sourceIndex = roots.findIndex(r => r.id === drag.sessionId)
const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId)
const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
console.warn('session reorder rejected:', reason)
@@ -190,12 +189,11 @@ function SessionTree({
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={open}
onRename={onSessionRename}
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
onFork={forkSession}
drag={dragProps}
/>
)
@@ -209,7 +207,7 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
@@ -223,14 +221,11 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
flat
onFork={forkSession}
/>
))}
</div>
@@ -254,6 +249,7 @@ export function WorkspaceBrowser({
startSession,
open,
renameSession,
forkSession,
renameWorkspace,
deleteWorkspace,
insertSessionBefore,
@@ -462,11 +458,12 @@ export function WorkspaceBrowser({
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
forkSession={forkSession}
workspaces={workspaces}
startSession={startSession}
open={open}

View File

@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
open: (sessionId: SessionId) => void
/** Rename a Session (explicit user title; resolves on host acceptance). */
renameSession: (sessionId: SessionId, title: string) => Promise<void>
/** Fork a Session at its last completed turn and open the child. */
forkSession: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */

View File

@@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void {
const result = await session.rename(title)
if (!result.ok) throw new Error(result.error.message)
},
forkSession: (sessionId) => {
ctx.sessions.fork({ sessionId, increaseTitle: true })
.then((childId) => { ctx.sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the current selection.
})
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

View File

@@ -39,9 +39,7 @@
height: 20px;
}
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
gap to the title — the slots butt together, so the row gap is zeroed and
the title carries its own margins. */
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
.sessionRow {
height: 34px;
gap: 0;
@@ -168,7 +166,7 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Drag reorder insert line (workspace-group roots): 2px accent above or
/* Drag reorder insert line (workspace-group session rows): 2px accent above or
below the hovered row, drawn with box-shadow so no layout shift. */
.sessionRow.dropBefore {
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
@@ -233,33 +231,9 @@
color: var(--dsw-alias-label-primary);
}
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
so titles align across sibling rows. Duplicates the .iconButton reset instead
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
left the raw UA button box showing. */
.twist {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 20px;
border: none;
border-radius: 4px;
padding: 0;
background: transparent;
cursor: pointer;
}
.twist:hover {
color: var(--dsw-alias-label-primary);
}
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
stays one step darker (tertiary, #81858C) per the cell spec. Declared last
to win over the composed .iconButton color. */
.chevron,
.twist {
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
darker (tertiary, #81858C) per the cell spec. */
.chevron {
color: var(--dsw-alias-label-caption);
}

View File

@@ -2,8 +2,8 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename/Delete and session Rename; the session and workspace
* hover cards are suppressed while a menu is open.
* except workspace Rename/Delete and session Rename/Fork; the session and
* workspace hover cards are suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -16,9 +16,6 @@ import type { GroupNode, SessionNode } from '../tree.ts'
import { formatRelativeTime } from '../tree.ts'
import css from './Rows.module.css'
/** Indent step per tree level: one 16px slot (figma session cell). */
const INDENT_STEP = 16
const SESSION_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
@@ -135,16 +132,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, running dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* One top-level 34px session row with running dot and relative time.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
* @returns the session row.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
@@ -161,7 +154,7 @@ function SessionHoverContent({ node, now }: { node: SessionNode; now: number })
}
/**
* Root-row drag wiring supplied by the group owner (workspace groups only).
* Session-row drag wiring supplied by the group owner (workspace groups only).
* `drop` reports the half of the row the pointer released on: 'before'
* inserts above this row, 'after' below it (the owner resolves the anchor).
*/
@@ -184,26 +177,22 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
/** Open the browser-owned session rename dialog (row menu action). */
onRename: (id: SessionNode['id'], currentTitle: string) => void
onToggle: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group roots outside search). */
/** Fork a session at its last completed turn (row menu action). */
onFork: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
flat?: boolean
}) {
const row = node
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
<div
className={clsx(
@@ -212,8 +201,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
draggable={drag !== undefined}
onDragStart={drag === undefined
@@ -239,18 +226,6 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: null}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
@@ -261,7 +236,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
items={SESSION_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
if (id === 'rename') onRename(node.id, row.title)
if (id === 'fork') onFork(node.id) // delete stays visual-only.
}}
portal
closeOnPointerLeave
@@ -280,24 +256,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
</div>
)
return (
<>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onRename={onRename}
onToggle={onToggle}
/>
))}
</>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
)
}

View File

@@ -11,20 +11,15 @@ export const UNGROUPED_KEY = ''
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** One session node of a group's visible tree (34px row; children render indented one step). */
/** One top-level session row in a group or the flat list. */
export interface SessionNode {
id: SessionId
title: string
/** Visible children, already expansion/search-filtered (empty when folded). */
children: readonly SessionNode[]
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
running: boolean
updatedAt: number
}
/** One workspace group section: header row facts + the visible session tree. */
/** One workspace group section: header row facts + visible top-level session rows. */
export interface GroupNode {
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
key: string
@@ -39,14 +34,13 @@ export interface GroupNode {
expanded: boolean
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
containsCurrent: boolean
/** Visible roots (empty while the group is folded). */
/** Visible session rows (empty while the group is folded). */
sessions: readonly SessionNode[]
}
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
@@ -56,9 +50,7 @@ interface Group {
cwd: string | undefined
createdAt: number | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
children: Map<SessionId, SessionId[]>
sessions: SessionSummary[]
}
/**
@@ -89,7 +81,7 @@ function sessionTitle(session: SessionSummary): string {
return session.blank ? 'New Session' : session.displayTitle
}
/** Build one group's parent/child tree from an ordered member list. */
/** Build one group without projecting session lineage into presentation. */
function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
@@ -99,54 +91,11 @@ function buildGroup(
members: readonly SessionSummary[],
order: 'account' | 'recency',
): Group {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
}
// Workspace order is the member iteration order (workspace.sessionIds), so
// attached groups keep insertion order; Ungrouped sorts by recency.
if (order === 'recency') {
roots.sort(byRecency)
for (const kids of children.values()) {
kids.sort((a, b) => {
const sa = summaries.get(a)
const sb = summaries.get(b)
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
if (sa === undefined || sb === undefined) return 0
return byRecency(sa, sb)
})
}
}
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
const sessions = [...members]
// Workspace order is workspace.sessionIds; only Ungrouped lacks an account
// order and therefore falls back to recency.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
/**
@@ -181,72 +130,24 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
return groups
}
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
function sessionNode(s: SessionSummary): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
children,
hasChildren,
expanded,
running: s.running,
updatedAt: s.updatedAt,
}
}
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return null
const kids = g.children.get(id) ?? []
const expanded = expandedSessions.has(id)
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
return sessionNode(s, children, kids.length > 0, expanded)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/** Matched sessions plus their ancestor chains (forced visible under search). */
function searchVisible(g: Group, q: string): Set<SessionId> {
const visible = new Set<SessionId>()
for (const m of g.summaries.values()) {
if (!sessionTitle(m).toLowerCase().includes(q)) continue
let cur: SessionSummary | undefined = m
while (cur !== undefined && !visible.has(cur.id)) {
visible.add(cur.id)
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
}
}
return visible
}
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id) || !visible.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return null
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
return sessionNode(s, children, kids.length > 0, kids.length > 0)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/**
* Derive the nested workspace browser group structure.
* Derive the workspace browser groups with every session as a top-level row.
*
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. Search mode (non-blank query,
* preserving Host account order. Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a display-title or label hit are dropped, and a label-only hit
* keeps the bare group header. Blank sessions are excluded everywhere.
* matching sessions are forced visible, groups without a display-title or
* label hit are dropped, and a label-only hit
* keeps the bare group header. Non-current blank sessions are excluded.
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
@@ -259,7 +160,6 @@ export function deriveGroups(
): GroupNode[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
@@ -274,24 +174,24 @@ export function deriveGroups(
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
sessionCount: g.sessions.length,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? buildVisible(g, expandedSessions) : [],
sessions: expanded ? g.sessions.map(sessionNode) : [],
})
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
const matches = g.sessions.filter(session => sessionTitle(session).toLowerCase().includes(q))
if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded: visible.size > 0,
sessionCount: g.sessions.length,
expanded: matches.length > 0,
containsCurrent: g.key === currentGroup,
sessions: buildSearch(g, visible),
sessions: matches.map(sessionNode),
})
}
}
@@ -301,9 +201,8 @@ export function deriveGroups(
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency; rows reuse SessionNode with children always
* empty so the renderer stays branch-free. Search mode filters by
* case-insensitive display-title substring.
* no parent/child adjacency. Search mode filters by case-insensitive
* display-title substring.
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @returns flat rows in render order.
@@ -318,7 +217,7 @@ export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>
rows.push(s)
}
rows.sort(byRecency)
return rows.map(s => sessionNode(s, [], false, false))
return rows.map(sessionNode)
}
/**

View File

@@ -19,11 +19,17 @@ async function bench() {
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
const fork = vi.fn(async () => 'forked' as never)
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
ctx.provide('sessions', { open, clear, binding, fork } as never)
return {
ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename,
insertSessionBefore, open, clear, renameSession, binding, fork,
}
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -66,6 +72,14 @@ describe('ui-workspace apply', () => {
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
browser.open('session' as never)
expect(b.open).toHaveBeenCalledWith('session')
await browser.renameSession('session' as never, 'renamed session')
expect(b.binding).toHaveBeenCalledWith('session')
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
browser.forkSession('session' as never)
await vi.waitFor(() => {
expect(b.open).toHaveBeenCalledWith('forked')
})
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
await browser.renameWorkspace('ws' as never, 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)

View File

@@ -56,46 +56,22 @@ describe('workspace browser rows', () => {
expect(onToggle).toHaveBeenCalledOnce()
})
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
expanded: true, running: true, updatedAt: 0,
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', running: true, updatedAt: 0,
}
const onOpen = vi.fn()
const onToggle = vi.fn()
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle} />,
render(
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
expect(parentRow.getAttribute('aria-selected')).toBe('true')
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
expect(childRow.getAttribute('aria-selected')).toBe('false')
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(onToggle).toHaveBeenCalledWith(parent.id)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(parentRow)
fireEvent.click(childRow)
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
const row = screen.getByRole('treeitem')
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.hasAttribute('aria-expanded')).toBe(false)
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
fireEvent.click(row)
expect(onOpen).toHaveBeenCalledWith(node.id)
})
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
@@ -156,15 +132,15 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
})
it('session row menu opens without opening the session and dispatches rename', () => {
it('session row menu opens without opening the session and dispatches rename and fork', () => {
const onOpen = vi.fn()
const onRename = vi.fn()
const onFork = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
@@ -173,9 +149,10 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
expect(onOpen).not.toHaveBeenCalled()
// Fork and Delete stay visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
expect(onFork).toHaveBeenCalledWith(node.id)
// Delete stays visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
expect(onRename).toHaveBeenCalledOnce()
@@ -185,25 +162,14 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('flat variant renders no twist even for a parent and ignores toggling', () => {
const node: SessionNode = {
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} flat />)
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
expanded: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
@@ -226,11 +192,10 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
@@ -242,13 +207,12 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -265,8 +229,8 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -279,8 +243,8 @@ describe('workspace browser rows', () => {
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})

View File

@@ -21,7 +21,7 @@ const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = [], query = '') => ({
expandedProjects, expandedSessions: [] as string[], query,
expandedProjects, query,
})
describe('deriveGroups', () => {
@@ -74,7 +74,7 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessionCount).toBe(1)
})
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
const parent = summary('parent', 1)
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
const newChild = { ...summary('new-child', 20), parentId: parent.id }
@@ -87,15 +87,13 @@ describe('deriveGroups', () => {
const groups = deriveGroups(
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
{ expandedProjects: [UNGROUPED_KEY], query: '' },
)
expect(groups).toHaveLength(1)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
])
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
newChild.id, tieA.id, tieB.id, oldChild.id,
cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
])
// Equal timestamps use ids as a deterministic tiebreak in either input order.
@@ -113,7 +111,7 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
it('searches rows independently of lineage and keeps label-only hits', () => {
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
@@ -124,8 +122,8 @@ describe('deriveGroups', () => {
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
match.id, self.id, orphan.id, cycleA.id, cycleB.id,
])
const labelOnly = deriveGroups(
@@ -157,8 +155,6 @@ describe('deriveFlat', () => {
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
// Rows are branch-free: no children, no expansion.
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
})
it('search filters by case-insensitive display-title substring', () => {

View File

@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
startSession: vi.fn(),
open: vi.fn(),
renameSession: vi.fn(async () => {}),
forkSession: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
@@ -124,7 +125,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('alpha-s')).toBeNull()
})
it('unfolds a session subtree through the row twist', () => {
it('renders a fork child as a top-level row without a session twist', () => {
const parent = summary('parent-s', 2)
const child = { ...summary('child-s', 1), parentId: parent.id }
mount({
@@ -132,11 +133,9 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('child-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('child-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('child-s')).toBeNull()
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
})
it('auto-expands the selected session group and starts a session from the group ', () => {

View File

@@ -1554,9 +1554,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AskUserQuestionAnswerItem',
declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}',
},
{
name: 'AskUserQuestionIntent',
declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n};',
},
{
name: 'AskUserQuestionItem',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n intent?: AskUserQuestionIntent;\n}',
},
{
name: 'AskUserQuestionOption',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: 88a80fb51d7161e6940a3460b7f506575592f9cb
README.zh.md: 9767c545ddc87d323c9fefa43a3145d1a2df5914
README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4
README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60

View File

@@ -5,9 +5,9 @@ English | [中文](README.zh.md)
The **model-facing filesystem discovery tools**`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
// A deployment chooses how over-cap glob pages are selected.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
@@ -20,11 +20,12 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin
## Config
All keys are optional; the defaults are the shipped search caps.
`sampleOverCapGlobResults` is required and has no fallback; deployments choose the over-cap ordering contract explicitly. The remaining keys are optional search caps with the defaults below.
| Key | Default | Meaning |
|---|---|---|
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
| `sampleOverCapGlobResults` | none (required) | `true` samples an over-cap `glob` page across top-level entries; `false` keeps the modification-time-ordered head. When formatted spill succeeds, both modes preserve the complete sorted list in that artifact. |
| `globMaxResults` | `100` | Max paths one `glob` call shows inline (matches Claude Code's `GlobTool` limit). A result within the cap remains complete and modification-time ordered. |
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
@@ -34,14 +35,14 @@ All keys are optional; the defaults are the shipped search caps.
| Tool | Arguments | Behavior |
|---|---|---|
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
## Errors
@@ -55,10 +56,16 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
##### Glob guidance
##### Glob guidance with `sampleOverCapGlobResults: true`
```markdown
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
```
##### Glob guidance with `sampleOverCapGlobResults: false`
```markdown
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
```
##### Grep guidance
@@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### Token effect
Fixed guidance cost per request while the tools are registered.
Fixed guidance cost per request while the tools are registered; the required sampling choice selects one glob variant.
#### KV Cache effect
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
Prefix-stable while the plugin scope, sampling choice, and guidance text are unchanged. Activation, disposal, or changing the choice may invalidate reuse from this prompt section.
### Tool schemas
#### What the model sees
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds.
#### Token effect
@@ -93,7 +100,7 @@ Prefix-stable while tool visibility and definitions are unchanged. Registration
#### What the model sees
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. With `sampleOverCapGlobResults: true`, an over-cap `glob` page takes paths round-robin across entries immediately beneath the actual search root, and the footer states the sampled basis and how many top-level entries it reached; when it cannot reach them all, the footer tells the model to narrow `path`. With `false`, the page is the modification-time-ordered head and keeps the plain capped-result footer. A result that fits inline is untouched, and a flat sampled result also keeps the plain footer because its sample equals the modification-time head. The spill artifact always holds the complete list in modification-time order.
#### Token effect
@@ -122,3 +129,4 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
- **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.

View File

@@ -5,9 +5,9 @@
**面向模型的文件系统发现工具**`glob``grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时本包package探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep就记录警告并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)``ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
```ts ignore-check
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
// A deployment chooses how over-cap glob pages are selected.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
@@ -20,11 +20,12 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
## 配置
所有键均为可选;默认值是随产品交付的搜索上限
`sampleOverCapGlobResults` 是必填项且没有回退值;部署必须显式选择超过上限时的排序契约。其余键是可选的搜索上限,默认值如下
| 键 | 默认值 | 含义 |
|---|---|---|
| `globMaxResults` | `100` | 一次 `glob` 调用内联保留的最大路径数(与 Claude Code 的 `GlobTool` 上限相同);后续路径写入格式化 spill 产物。 |
| `sampleOverCapGlobResults` | 无(必填) | `true` 会在顶层条目之间对超过上限的 `glob` 页面采样;`false` 保留按修改时间排序的前部。格式化 spill 成功时,两种模式都会在该产物中保留完整排序列表。 |
| `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过上限的结果保持完整,并按修改时间排序。 |
| `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 |
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
@@ -34,14 +35,14 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| 工具 | 参数 | 行为 |
|---|---|---|
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个路径,按修改时间排序。 |
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
常规预算不进入面向模型的 schema没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
## 两类预算、两类产物
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ paths }` 中保留所有已取得路径;`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面 locator。嵌套 Code 分派会跳过 spill因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
## 错误
@@ -55,10 +56,16 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema而不移除其提示词段。
##### Glob 指导
##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导
```markdown
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
```
##### 启用 `sampleOverCapGlobResults: false` 时的 Glob 指导
```markdown
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
```
##### Grep 指导
@@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### Token 影响
工具注册期间,每个请求支付固定指导成本。
工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体
#### KV Cache 影响
只要插件作用域和指导文本不变,前缀就保持稳定。启用dispose资源释放可能从该提示词段开始使复用失效。
只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用dispose资源释放或更改该选项,可能从该提示词段开始使复用失效。
### 工具 schema
#### 模型看到的内容
当前接口可见时,公开已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search);前提是加载时 `rg` 探测成功
glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见
#### Token 影响
@@ -93,7 +100,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### 模型看到的内容
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径footer 会说明采样依据和触达的顶层条目数若无法触达全部条目footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动扁平的采样结果也沿用普通 footer因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。
#### Token 影响
@@ -122,3 +129,4 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。
- **schema 只公开一个有界页面**offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。
- **启用采样时,只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。

View File

@@ -3,17 +3,15 @@
* pattern, sorted by modification time. Execution goes through the bash seam
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
* model-facing schema, argument validation, shell-safe command construction,
* result parsing, retention, and formatting; process concerns (defaulting,
* result parsing, inline sampling, and formatting; process concerns (defaulting,
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
*
* @module @deepseek-ai/dsh-tool-fs-search/glob
*/
import type { Context } from 'cordis'
import { sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -41,6 +39,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
export interface GlobToolCaps {
/** Whether over-cap pages are sampled across top-level entries instead of taking the modification-time head. */
sampleOverCapGlobResults: boolean
/** Max paths retained inline; later paths go to the formatted spill file. */
maxResults: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
@@ -99,30 +99,136 @@ export function buildGlobCommand(input: GlobInput): string {
}
/**
* Format the model-facing `glob` result: the retained paths, then — when the
* result was capped — a footer carrying either the formatted-spill recovery
* locator or the could-not-save explanation. The omitted count is a budget fact:
* the search itself completed.
* The inline page of a capped `glob` result, plus how much of the complete
* result's top level it reaches.
*/
export interface GlobSample {
/** Paths to show inline: grouped by top-level entry, modification-time ordered within each group. */
items: string[]
/** Distinct top-level entries the shown paths reach. */
shown: number
/** Distinct top-level entries across the complete result. */
total: number
}
/** Remove the displayed search-root prefix before choosing a top-level group. */
function relativeToSearchRoot(path: string, root: string): string {
if (root === '.') return path.startsWith(`.${sep}`) ? path.slice(2) : path
let rootEnd = root.length
while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1
const trimmedRoot = root.slice(0, rootEnd)
if (trimmedRoot.length === 0) return stripLeadingSeparators(path)
if (path === trimmedRoot) return ''
if (path.startsWith(`${trimmedRoot}${sep}`)) {
return path.slice(trimmedRoot.length + 1)
}
return path
}
/** Strip only separators recognized by the execution platform. */
function stripLeadingSeparators(path: string): string {
let start = 0
while (path[start] === sep) start += 1
return path.slice(start)
}
/**
* The leading path segment of one display path — the top-level entry, relative
* to the search root, that the path sits under. A path with no separator is its
* own top-level entry. Leading separators are stripped first so an absolute path
* (one outside the workdir, which {@link toWorkdirRelative} leaves untouched)
* groups by its first real name instead of collapsing every such path into one
* empty group.
*/
function topLevelSegment(path: string): string {
const trimmed = stripLeadingSeparators(path)
const cut = trimmed.indexOf(sep)
return cut === -1 ? trimmed : trimmed.slice(0, cut)
}
/**
* Choose the inline page of an over-cap result by round-robin across the
* complete result's top-level entries, instead of taking its head.
*
* @param retained - the retention outcome over every discovered path.
* Every top-level entry receives a slot before any receives a second; exhausted
* groups drop out. Group order and order within each group follow `paths`, so a
* flat result reproduces the modification-time head.
*
* @param paths - the complete result, in ripgrep's modification-time order.
* @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`.
* @param root - the search root in the same display-path space as `paths`.
* @returns the page grouped by top-level entry, with the shown/total top-level spread.
*/
export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, root = '.'): GlobSample {
type ActiveGroup = { key: string; items: string[]; index: number; current: string }
const groups = new Map<string, string[]>()
let active: ActiveGroup[] = []
for (const path of paths) {
const key = topLevelSegment(relativeToSearchRoot(path, root))
const group = groups.get(key)
if (group === undefined) {
const items = [path]
groups.set(key, items)
active.push({ key, items, index: 0, current: path })
} else {
group.push(path)
}
}
const taken = new Map<string, string[]>()
let count = 0
while (active.length > 0 && count < maxItems) {
const nextActive: ActiveGroup[] = []
for (const { key, items, index, current } of active) {
if (count >= maxItems) break
count += 1
const bucket = taken.get(key)
if (bucket === undefined) taken.set(key, [current])
else bucket.push(current)
const nextIndex = index + 1
const nextPath = items[nextIndex]
if (nextPath !== undefined) nextActive.push({ key, items, index: nextIndex, current: nextPath })
}
active = nextActive
}
return { items: [...taken.values()].flat(), shown: taken.size, total: groups.size }
}
/**
* Format a capped sampled page and its complete-result recovery path. A flat
* result keeps the plain footer because its sample is the modification-time head.
*
* @param sample - the inline page and its top-level spread.
* @param seen - how many paths the complete result holds; always more than the page.
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
* @returns the model-facing text.
*/
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
const body = retained.items.join('\n')
if (!retained.truncated) return body
export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: SpillRef | undefined): string {
const basis = sample.total === seen
? '.'
: `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.`
+ (sample.shown < sample.total ? ' Narrow path to inspect a specific subtree.' : '')
return formatGlobPage(sample.items, seen, spillRef, basis)
}
/** Format one bounded page and the recovery path for its complete sorted result. */
function formatGlobPage(items: readonly string[], seen: number, spillRef: SpillRef | undefined, basis: string): string {
const body = items.join('\n')
const recovery = spillRef !== undefined
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
: 'The complete result could not be saved; narrow pattern or path to see more.'
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
return `${body}\n\n(Showing ${items.length} of ${seen} paths${basis} ${recovery})`
}
/** Retain and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
/** Bound and format one canonical path list for the Native surface relative to its search root. */
function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return formatGlobOutput(retainer.finish(), spillRef)
// A result that fits is shown whole, untouched: modification-time order is the
// tool's contract, and over a complete result it is what answers age questions.
if (paths.length <= caps.maxResults) return paths.join('\n')
if (!caps.sampleOverCapGlobResults) {
return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef, '.')
}
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
}
/**
@@ -144,19 +250,32 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
*/
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
const overCapGuidance = caps.sampleOverCapGlobResults
? 'while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.'
: 'while a larger one keeps the modification-time-ordered head.'
ctx.systemPrompt.section({
name: 'tool:glob',
order: 103,
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. '
+ `Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`,
})
const overCapDescription = caps.sampleOverCapGlobResults
? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries`
: `a larger result returns the first ${caps.maxResults} paths in modification-time order`
const tool = defineTool({
name: 'glob',
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — '
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
+ `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`,
+ `Up to ${caps.maxResults} paths come back in modification-time order; ${overCapDescription}, `
+ 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.',
parameters: {
pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' },
pattern: {
type: 'string',
required: true,
description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). '
+ 'A pattern with no "/" matches the basename at any depth, so "*" and "*.ts" both search the whole tree; include a separator to anchor the depth.',
},
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
},
timeoutMs: caps.timeoutMs,
@@ -165,15 +284,17 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
type: 'object',
additionalProperties: false,
properties: {
root: { type: 'string', required: true },
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
if (run.noMatches) return { paths: [] }
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
if (run.noMatches) return { root, paths: [] }
const all: string[] = []
for (const line of run.stdout.split('\n')) {
@@ -181,7 +302,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
const displayPath = toWorkdirRelative(line, run.workdir)
all.push(displayPath)
}
return { paths: all }
return { root, paths: all }
},
presentCall: presentGlobCall,
})
@@ -189,14 +310,14 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { root: string; paths: string[] } | undefined
if (value === undefined) return decision
const paths = value.paths
if (paths.length <= caps.maxResults) return decision
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
content: [{ type: 'text', text: renderGlobPaths(paths, caps, value.root, spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})

View File

@@ -33,8 +33,8 @@ import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
export type { GlobInput, GlobToolCaps } from './glob.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts'
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
export {
GREP_MAX_LINE_BYTES,
GREP_MAX_MATCHES,
@@ -58,8 +58,10 @@ export const name = 'tool-fs-search'
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
export const inject = ['tools', 'systemPrompt', 'bash']
/** Plugin config (all optional — `Config` supplies the defaults). */
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
export interface Config {
/** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */
sampleOverCapGlobResults: boolean
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
globMaxResults?: number
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
@@ -73,6 +75,7 @@ export interface Config {
}
export const Config: z<Config> = z.object({
sampleOverCapGlobResults: z.boolean().required(),
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
@@ -137,6 +140,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
return
}
applyGlobTool(ctx, {
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
maxResults: resolved.globMaxResults,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,

View File

@@ -64,7 +64,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch)
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
})
afterEach(async () => {

View File

@@ -82,7 +82,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here.
const fiber = await ctx.plugin(unwrapped)
const fiber = await ctx.plugin(unwrapped, { sampleOverCapGlobResults: true })
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep']))
await fiber.dispose()
})

View File

@@ -12,7 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { join, sep } from 'node:path'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
@@ -29,6 +29,7 @@ import {
presentGlobCall,
presentGrepCall,
previewLine,
sampleAcrossTopLevel,
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
@@ -110,12 +111,14 @@ class FakeSpill extends SpillStore {
}
interface SetupOptions {
config?: ToolFsSearch.Config
config?: Partial<ToolFsSearch.Config>
spill?: boolean
probeError?: Error
probeResult?: BashRunResult
}
const DEFAULT_CONFIG = { sampleOverCapGlobResults: true } satisfies ToolFsSearch.Config
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
const warnings: string[] = []
@@ -127,7 +130,7 @@ async function setup(options: SetupOptions = {}) {
if (options.probeResult) bash.probeResult = options.probeResult
if (options.probeError) bash.probeError = options.probeError
if (options.spill === true) await ctx.plugin(FakeSpill)
const fiber = await ctx.plugin(ToolFsSearch, options.config)
const fiber = await ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...options.config })
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
return { ctx, bash, spill, fiber, warnings }
}
@@ -184,6 +187,10 @@ describe('registration', () => {
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the glob tool')
expect(prompt).toContain('Use the grep tool')
expect(prompt).toContain('sampled across top-level entries')
expect(prompt).not.toContain('sampled across top-level directories')
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
expect(glob?.description).toContain('sampled across top-level entries')
})
it('does not register glob or grep when the bash executor cannot find rg', async () => {
@@ -211,7 +218,7 @@ describe('registration', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolFsSearch) // no bash executor
await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no bash executor
expect(ctx.tools.schemas()).toHaveLength(0)
})
@@ -236,9 +243,27 @@ describe('registration', () => {
expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000)
expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000)
})
it('describes the modification-time head when over-cap sampling is disabled', async () => {
const { ctx } = await setup({ config: { sampleOverCapGlobResults: false } })
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('a larger one keeps the modification-time-ordered head')
expect(prompt).not.toContain('sampled across top-level entries')
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
expect(glob?.description).toContain('a larger result returns the first 100 paths in modification-time order')
expect(glob?.description).not.toContain('sampled across top-level entries')
})
})
describe('config validation', () => {
it('requires an explicit over-cap glob sampling choice', () => {
expect(() => new ToolFsSearch.Config()).toThrow(/sampleOverCapGlobResults/)
expect(new ToolFsSearch.Config({ sampleOverCapGlobResults: false })).toMatchObject({
sampleOverCapGlobResults: false,
globMaxResults: 100,
})
})
it.each([
['globMaxResults', { globMaxResults: 0 }],
['grepMaxMatches', { grepMaxMatches: -1 }],
@@ -250,7 +275,7 @@ describe('config validation', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeBash)
await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
})
})
@@ -498,13 +523,100 @@ describe('raw output acquisition', () => {
})
})
describe('cross-directory sampling', () => {
it('gives every top-level entry a slot before any entry gets a second', () => {
const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f']
// The head of 3 would be all `v/`; the sample reaches all three entries.
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 })
// Extra slots go round again — to the only entry with paths left — and the
// page stays grouped by entry rather than interleaved.
expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 })
})
it('hands an exhausted entry the remaining slots go to entries that still have paths', () => {
const paths = ['solo/a', 'many/b', 'many/c', 'many/d']
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 })
})
it('does not rescan exhausted entries while filling a skewed page', () => {
const singletonCount = 12_500
const paths = [
...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`),
...Array.from({ length: singletonCount }, (_, index) => `late/${index}`),
]
expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({
shown: singletonCount + 1,
total: singletonCount + 1,
items: { length: paths.length - 1 },
})
}, 500)
it('reports the entries it could not reach when the page is smaller than the top level', () => {
const paths = ['a/1', 'b/1', 'c/1', 'd/1']
expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 })
})
it('groups an absolute path by its first real name, not by its empty root segment', () => {
// Paths outside the workdir stay absolute; without stripping the leading
// separator every one of them would collapse into a single empty group.
expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2))
.toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 })
})
it('reproduces the modification-time-ordered head for a flat result', () => {
expect(sampleAcrossTopLevel(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ items: ['a.ts', 'b.ts'], shown: 2, total: 3 })
})
it('groups paths relative to an explicit search root', () => {
expect(sampleAcrossTopLevel([
'workspace/vendor/a.ts',
'workspace/vendor/b.ts',
'workspace/source/c.ts',
'workspace/guides/d.md',
], 3, 'workspace')).toEqual({
items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'],
shown: 3,
total: 3,
})
expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.'))
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/'))
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 })
const rooted = [
['root', 'a', 'one'].join(sep),
['root', 'a', 'two'].join(sep),
['root', 'b', 'three'].join(sep),
]
expect(sampleAcrossTopLevel(rooted, 2, 'root'))
.toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 })
expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src'))
.toEqual({ items: ['other/a.ts'], shown: 1, total: 1 })
expect(sampleAcrossTopLevel(['src'], 1, 'src'))
.toEqual({ items: ['src'], shown: 1, total: 1 })
})
it.skipIf(process.platform === 'win32')('treats POSIX backslashes as filename characters', () => {
const paths = ['old\\one', 'old\\two', 'src/a']
expect(sampleAcrossTopLevel(paths, 2)).toEqual({
items: ['old\\one', 'old\\two'],
shown: 2,
total: 3,
})
})
it('handles more top-level groups than the JavaScript argument limit', () => {
const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`)
expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 })
})
})
describe('glob results', () => {
it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
@@ -534,7 +646,7 @@ describe('glob results', () => {
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
@@ -547,6 +659,79 @@ describe('glob results', () => {
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }])
})
it('samples an over-cap result across top-level entries instead of taking its head', async () => {
// The shipped failure: `*` matches the whole tree, mtime order puts one
// freshly-unpacked subtree first, and a head-of-3 reads like the entire
// workspace. The sample reaches every top-level entry instead.
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n'
+ '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched '
+ 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. '
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
})
it('keeps the modification-time head when over-cap sampling is disabled', async () => {
const { ctx, bash } = await setup({
config: { globMaxResults: 3, sampleOverCapGlobResults: false },
})
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n'))
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('vendor/a.ts\nvendor/b.ts\nvendor/c.ts\n\n'
+ '(Showing 3 of 5 paths. The complete result could not be saved; narrow pattern or path to see more.)')
})
it('samples relative to the explicit search root instead of its workdir prefix', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult([
'workspace/vendor/a.ts',
'workspace/vendor/b.ts',
'workspace/source/c.ts',
'workspace/guides/d.md',
].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') })
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
})
it('samples relative to an absolute search root after workdir display conversion', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult([
'/w/workspace/vendor/a.ts',
'/w/workspace/vendor/b.ts',
'/w/workspace/source/c.ts',
'/w/workspace/guides/d.md',
].join('\n'))
const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') })
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
})
it('drops the narrowing hint when the sample reaches every top-level entry', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n'))
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n'
+ '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched '
+ 'instead of taken in modification-time order. '
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
})
it('keeps modification-time order untouched when the whole result fits', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 4 } })
bash.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n')
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts')
})
it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => {
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
.toBe('a.ts\nb.ts\n\n(Showing 2 of 3 paths. The complete result could not be saved; narrow pattern or path to see more.)')
})
it('does not create a spill file when the result fits inline', async () => {
const { ctx, bash, spill } = await setup({ spill: true })
bash.handler = () => runResult('a.ts\nb.ts\n')
@@ -559,14 +744,14 @@ describe('glob results', () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
value: { paths: ['replacement-a.ts', 'replacement-b.ts'] },
value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] },
}))
bash.handler = () => runResult('old-a.ts\nold-b.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob replacement success')
expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] })
expect(text(result)).toContain('replacement-a.ts')
expect(text(result)).not.toContain('old-a.ts')
expect(spill?.saves).toHaveLength(0)
@@ -580,7 +765,7 @@ describe('glob results', () => {
parent: Symbol('run_code') as ToolExecutionToken,
})
if (result.isError) throw new Error('expected glob success')
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)')
expect(spill?.saves).toHaveLength(0)
})

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: 48929853ad02aaff510aa7c68f38bb698e127452
README.zh.md: e63b687d88acc108fc8e40182e48542f07274f64
README.md: 9cf00382cbe643daf8bfeea67f2104333498557a
README.zh.md: 737682a06305a57b51cb4cb0da4a2f9108b4effa

View File

@@ -16,6 +16,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
@@ -44,7 +46,7 @@ None; this package neither assembles nor sends a provider request.
- **`attachments` is a hard inject** — the proxy will not mount until an attachment backend provides `ctx.attachments`; a composition missing one stalls silently as a cordis inject gap rather than failing loud (same gap as the connection route; a capability-degraded mount is deferred work).
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).

View File

@@ -16,7 +16,9 @@
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方模型推理reasoning目标以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行当图片正等待发布或仍存在于当前派生历史中时会拒绝选择纯文本目标被压缩compaction移除的图片不再阻止选择。目录成员关系不构成校验适配器可以解析未列出的模型而不可用路由或不受支持的推理强度会返回 `model-unavailable`
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方模型推理reasoning目标及谱系再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行当图片正等待发布或仍存在于当前派生历史中时会拒绝选择纯文本目标被压缩compaction移除的图片不再阻止选择。目录成员关系不构成校验适配器可以解析未列出的模型而不可用路由或不受支持的推理强度会返回 `model-unavailable`
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering中途引导不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent绝不恢复冷会话因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
@@ -44,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **`attachments` 是硬性注入依赖**:代理在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败(与 connection 路由是同一缺口;降级挂载属于延期工作)。
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**协议形状POST `/api/respond``RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **预留 seam 不进入 `RpcMethodMap`**`prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Noteagent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -1324,6 +1324,75 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async fork(request) {
const { sessionId, atSeq } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const source = found.agent.session
const events = source.events
// An in-log anchor belongs to the turn containing it and must never
// clip backward to an earlier completed turn. Omitted and past-end
// anchors retain the last-completed-turn shortcut.
const lastSeq = events.at(-1)?.seq ?? -1
const anchoredBoundary = atSeq === undefined
? undefined
: events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? events.findLast(e => e.type === 'turn/end')
: undefined)
if (boundary === undefined) {
return err(request, {
code: 'fork-unavailable',
message: atSeq !== undefined && atSeq <= lastSeq
? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
: `session "${sessionId}" has no completed turn to fork from`,
details: { sessionId },
})
}
// Extend the cut through trailing out-of-band appends (session/title,
// injections) up to the next turn/start: they are standalone events, so
// the seed stays balanced, and the child inherits a title generated
// right after the boundary turn.
let cut = boundary.seq + 1
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
const childId = `session-${randomUUID()}` as SessionId
try {
await ctx.agents.create({
sessionId: childId,
seed: events.slice(0, cut),
meta: {
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
parentSession: source.id,
seedLength: cut,
},
agentOptions,
setup: installTarget,
})
} catch (error: unknown) {
return err(request, {
code: 'internal',
message: `failed to fork session "${sessionId}": ${String(error)}`,
details: {},
})
}
// Keep the child in the source's Workspace so the list nests it under
// its parent; the child is already published if the attach fails.
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
if (workspace !== undefined) {
try {
await workspace.attachSession(childId)
} catch (error: unknown) {
return err(request, {
code: 'workspace-attach-failed',
message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
details: { sessionId: childId, workspaceId: workspace.id },
})
}
}
return ok(request, { sessionId: childId })
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)

View File

@@ -23,6 +23,11 @@ export const askUserQuestionItemSchema = z.object({
detail: z.string().optional(),
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
multiSelect: z.boolean().optional(),
// Presentation intent: a tagged union on the wire, so an unknown tag is a
// rejected frame rather than a silently generic render.
intent: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('plan-review'), approve: z.string() }),
]).optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
/** Unified message envelope carried by transient queue frames. */

View File

@@ -24,6 +24,7 @@ export interface RpcMethodMap {
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.rename': SessionsApi['rename']
'session.fork': SessionsApi['fork']
'session.prompt': SessionsApi['prompt']
'session.attachment': SessionsApi['attachment']
'session.updateQueue': SessionsApi['updateQueue']

View File

@@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -52,6 +52,7 @@ export interface RpcErrorDetailsMap {
/** A leading-/ prompt named no registered command; the message names the token. */
'unknown-command': {}
'title-invalid': { sessionId: SessionId }
'fork-unavailable': { sessionId: SessionId }
'internal': {}
}

View File

@@ -90,6 +90,17 @@ export const sessionRenameValueSchema = z.object({
seq: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
/** session.fork request payload (atSeq anchors the completed-turn cut). */
export const sessionForkRequestSchema = z.object({
sessionId: sessionIdSchema,
atSeq: z.number().int().nonnegative().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
/** session.fork response value (the child session id). */
export const sessionForkValueSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -236,6 +236,20 @@ export interface SessionsApi {
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
Promise<RpcResponse<{ title: string; seq: number }>>
/**
* Forks a new session from a completed-turn prefix of the source. `atSeq`
* anchors the cut: the boundary is the first `turn/end` at or after it
* (a message's fork button passes the message seq, so the fork includes
* that whole turn); a boundary past the log end, or an omitted `atSeq`,
* falls back to the source's last completed turn. An in-log anchor whose
* turn is still open fails with `fork-unavailable` instead of clipping to
* an earlier turn. The child inherits the source cwd, latest logged model
* target, workspace attachment, and `parentSessionId` lineage; the seed
* prefix carries the source title.
*/
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Sends text plus temporary base64 image uploads; the host persists images before calling the agent.
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host
@@ -248,7 +262,7 @@ export interface SessionsApi {
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/** Reads one durable image after proving that this session's log references its id. */
attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }> ):
attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }>):
Promise<RpcResponse<{ attachment: ImageAttachmentRef; data: string }>>
/**

View File

@@ -21,6 +21,7 @@ import {
sessionCancelValueSchema,
sessionAttachmentValueSchema,
sessionCreateValueSchema,
sessionForkValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
@@ -70,6 +71,7 @@ export interface IApiClient {
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
@@ -123,6 +125,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.fork': sessionForkValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.attachment': sessionAttachmentValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
@@ -337,6 +340,7 @@ export abstract class AbstractApiClient implements IApiClient {
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal),
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),

View File

@@ -18,6 +18,7 @@ import {
sessionCancelRequestSchema,
sessionAttachmentRequestSchema,
sessionCreateRequestSchema,
sessionForkRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
@@ -72,6 +73,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },

View File

@@ -0,0 +1,163 @@
/** Session-fork boundaries, lineage, and inherited model routing. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
}
async function composed(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('workspace', { list: () => [] } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
...options.seed === undefined ? {} : { seed: [...options.seed] },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = {} as Agent
const agentCtx = ownerCtx.extend({ agent })
Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
await options.setup?.(agentCtx)
ctx.agents.register(agent)
return { agent, dispose: () => Promise.resolve() }
},
resume: () => Promise.reject(new Error('fork test sources are live')),
})
return ctx
}
function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (openTail) {
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
}
const api = (ctx: Context) => createApiProxy(ctx, {
provider: 'default-provider',
model: 'default-model',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
describe('sessions.fork', () => {
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-source', 2)
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
const child = ctx.sessions.get(response.result.value.sessionId)
expect(child?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
])
expect(child?.header.parentSession).toBe(source.id)
expect(child?.header.cwd).toBe('/proj')
await ctx.fiber.dispose()
})
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, true)
const proxy = api(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
]
const omitted = await proxy.sessions.fork(request({ sessionId: source.id }))
expect(omitted.result.ok).toBe(true)
if (omitted.result.ok) {
expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 }))
expect(pastEnd.result.ok).toBe(true)
if (pastEnd.result.ok) {
expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
await ctx.fiber.dispose()
})
it('rejects an in-log anchor whose turn is still open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-open', 1, true)
const anchor = source.events.at(-1)?.seq ?? 0
const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.result).toMatchObject({
ok: false,
error: { code: 'fork-unavailable', details: { sessionId: source.id } },
})
if (!response.result.ok) expect(response.result.error.message).toMatch(/has not completed/)
await ctx.fiber.dispose()
})
it('installs the latest logged model target before the child can run', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-routed', 1)
source.append('request/header', {
header: {
config: {
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: ReasoningEffortId('high'),
},
},
reason: 'initial',
})
const response = await api(ctx).sessions.fork(request({ sessionId: source.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
const child = ctx.agents.get(response.result.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
const assembly = await child.ctx.systemPrompt.assemble()
expect(assembly.variables).toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
})
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
await expect(agentEvents(child.ctx, child).waterfall(
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback),
)).resolves.toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: 'high',
})
await ctx.fiber.dispose()
})
})

View File

@@ -47,6 +47,7 @@ function scriptedApi(overrides: {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
rename: r => ok(r, { title: 'renamed', seq: 0 }),
fork: r => ok(r, { sessionId: sid('s-fork') }),
prompt: r => ok(r, { accepted: true as const }),
attachment: r => ok(r, {
attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
@@ -114,6 +115,21 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
})
it('routes session fork with its optional cut anchor through the wire', async () => {
let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
const api = scriptedApi({
sessions: {
fork: (request) => {
seen = request
return ok(request, { sessionId: sid('s-child') })
},
},
})
const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 })
expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 })
expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
})
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
const api = scriptedApi()
const c = client(api)

View File

@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async rename(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
},
async fork(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},

View File

@@ -421,6 +421,17 @@ describe('events frame schemas', () => {
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
})
it('carries a question presentation intent through, and rejects an unknown one', () => {
const intent = { kind: 'plan-review', approve: 'Approve' }
expect(askUserQuestionItemSchema.parse({
id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent,
}).intent).toEqual(intent)
// An unrecognised tag is a rejected frame, not a silently generic render.
for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) {
expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow()
}
})
it('rejects a queue snapshot with malformed items', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()

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/plan/plan-mode/README.md
README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf
README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba
README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569
README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd

View File

@@ -14,6 +14,8 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`.
The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message.
When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request.
The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
@@ -77,7 +79,7 @@ The user block is append-only conversation growth. Entering or leaving plan mode
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback.
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover.
#### Token effect
@@ -92,4 +94,5 @@ Mode transitions do not change the tool catalog; plan arguments and review resul
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
- The `exit_plan_mode` review arc (submit → human review → approved flip or rejected feedback) is covered by package tests only; its assembled-application snapshot left with the retired ACP UI scenarios ([automation-only ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)) and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.
- The `exit_plan_mode` review arc has one assembled-application snapshot, the Web `plan-review` e2e lane (submit → decision card → approved flip). The rejected-feedback and dismissed branches are covered by package tests only, and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.
- Only the Web UI renders the `plan-review` intent; the TUI presents the review through its generic question flow, which is answerable but does not read as a plan gate.

View File

@@ -14,6 +14,8 @@
激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。
评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。
组合 `ctx.commands`该包package会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode任何其他非空参数都会先选择 plan mode再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。
TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
@@ -77,7 +79,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr
#### 模型所见内容
[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。
[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用
#### Token 影响
@@ -92,4 +94,5 @@ Mode 转换不改变工具目录plan 参数与评审结果按常规方式扩
- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。
- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。
- Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。
- `exit_plan_mode` 评审弧(提交 → 人类评审 → 已批准切换已拒绝反馈)仅由包测试覆盖;其组装应用快照随已退役 ACP UI 场景一起离开([仅面向自动化的 ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。
- `exit_plan_mode` 评审弧有一个组装应用快照,即 Web `plan-review` e2e 通道(提交 → 决定卡片 → 已批准切换)。已拒绝反馈与放弃审阅两个分支仅由包测试覆盖TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。
- 只有 Web UI 渲染 `plan-review` 意图TUI 通过其通用问题流程呈现该评审,可以回答,但读起来不像一个计划关口。

View File

@@ -29,7 +29,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the optional command child.
import type {} from '@deepseek-ai/dsh-commands'
// Type-only: resolves ctx.sessionProjections for the optional unit child.
@@ -70,6 +70,9 @@ export interface PlanModeConfig {
section: string
}
/** The review question's id, echoed in the answer this tool reads. */
const REVIEW_ID = 'plan-review'
/** The review question's approve option label. */
const APPROVE_LABEL = 'Approve'
@@ -317,7 +320,7 @@ export class PlanModeService extends Service {
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
id: REVIEW_ID,
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: args.plan,
@@ -325,16 +328,31 @@ export class PlanModeService extends Service {
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
// Presentation only: a capable UI renders the plan as a review
// decision instead of a generic question, and answers with one of
// the labels above either way.
intent: { kind: 'plan-review', approve: APPROVE_LABEL },
}],
agent,
signal: exec.signal,
}).catch((cause: unknown) => {
// A dismissed review is not a failed one: the user took the turn back
// to say something the two options do not cover. Say so, because the
// generic channel message names ask_user_question, which the model
// never called. An abort (turn cancel, provider teardown) keeps its
// own message — there is no user to wait for.
if (cause instanceof UserInteractionError && cause.code === 'ASK_CANCELLED') {
throw new Error('The user dismissed the plan review to speak instead; '
+ 'stay in plan mode, stop here, and wait for their message.')
}
throw cause
})
// A review may outlive this plugin fiber. Without boundary listeners,
// an approved result could never land, so fail and keep planning.
if (disposed) {
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID)
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
const feedback = item?.custom ?? ''

View File

@@ -6,7 +6,9 @@ import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import UserInteractionService, {
UserInteractionError, type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
@@ -894,6 +896,40 @@ describe('exit_plan_mode', () => {
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
})
it('declares the plan-review presentation intent naming its approve option', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
await callExit(ctx, agent)
const question = asked[0]?.questions[0]
expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' })
// The named label is one this same question offers, so a UI honouring the
// intent answers a choice this tool accepts.
expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)
})
it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({
ask: () => Promise.reject(new UserInteractionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED')),
})
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('leaves every other review failure its own message', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({
ask: () => Promise.reject(new UserInteractionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED')),
})
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('forwards the execution abort signal to the review question', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const controller = new AbortController()

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/ui/user-interaction/README.md
README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32
README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816

View File

@@ -13,14 +13,19 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`; the tagged presentation intent below.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
### Presentation intent
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.

View File

@@ -13,14 +13,19 @@
### 关键类型
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionOption``{ label, description? }`
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空自定义文本是所选选项的替代而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
### 呈现意图
`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上 —— 而 `detail` 正是它自称在审阅的东西。
## 职责
这是接口包package`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise工具结果随后恢复正常的 agent loop智能体循环

View File

@@ -20,7 +20,8 @@ declare module 'cordis' {
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
export type {
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
AskUserQuestionOption,
} from './types.ts'
/** Request for a human answer. */
@@ -86,6 +87,28 @@ export class UserInteractionService extends Service {
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
// A presentation intent asserts two things the types cannot: that the
// named approve label is one of this question's own options, and that a
// plan-review carries the plan it is a review of. A UI honouring the
// intent answers with that label, and shows that detail as the plan, so
// either gap would put a choice the asker never offered — or an approval of
// something invisible — in front of the user. Caught at the asker, where
// the mistake is, rather than in each UI.
for (const question of request.questions) {
const intent = question.intent
if (intent === undefined) continue
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} whose approve label `
+ `${JSON.stringify(intent.approve)} names none of its options`,
'BAD_INTENT')
}
if (question.detail === undefined) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}

View File

@@ -13,6 +13,24 @@ export interface AskUserQuestionOption {
description?: string
}
/**
* A caller-declared presentation intent: the question IS a decision of this
* shape, so a UI that recognises the tag may present it as such instead of as a
* generic option list. Tagged so further intents can be added; a UI that does
* not know a tag renders the generic flow, and the answer encoding is identical
* either way — an intent shapes presentation only, never the protocol.
*/
export type AskUserQuestionIntent = {
/** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
kind: 'plan-review'
/**
* The option label that approves the plan; every other option declines it.
* Named rather than positional so no UI infers the verdict from option order.
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
}
/** One question in a user-interaction request. */
export interface AskUserQuestionItem {
/** Stable caller-provided question id, echoed in the answer. */
@@ -27,6 +45,8 @@ export interface AskUserQuestionItem {
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
/** Optional presentation intent for capable UIs; absent asks for the generic option list. */
intent?: AskUserQuestionIntent
}
/** Answer to one question. */

View File

@@ -83,4 +83,63 @@ describe('UserInteractionService', () => {
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects an intent whose approve label names none of its own options', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
// A wrong label among offered options, and no options offered at all.
for (const options of [[{ label: 'Approve' }], undefined]) {
await expect(ctx.userInteraction.ask({
questions: [{
...question,
...(options === undefined ? {} : { options }),
intent: { kind: 'plan-review', approve: 'Ship it' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
}
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a plan-review intent on a question carrying no plan to review', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
// Detail IS the plan for this intent, so a UI honouring it would ask the
// user to approve something they cannot see.
await expect(ctx.userInteraction.ask({
questions: [{
id: 'plan-review', question: 'Approve?',
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
intent: { kind: 'plan-review', approve: 'Approve' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
expect(p.ask).not.toHaveBeenCalled()
})
it('passes an intent through once its approve label names an offered option', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider('Approve')
ctx.userInteraction.registerProvider(p)
const intent = { kind: 'plan-review', approve: 'Approve' } as const
const result = await ctx.userInteraction.ask({
questions: [
{ id: 'plain', question: 'Proceed?' },
{
id: 'plan-review', question: 'Approve?', detail: '# Plan',
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
},
],
})
expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
})
})