Merge remote-tracking branch 'origin/master' into feat/send-unify

# Conflicts:
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
Turtle
2026-07-23 22:41:45 +08:00
333 changed files with 10751 additions and 907 deletions

View File

@@ -32,7 +32,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |

View File

@@ -2,8 +2,8 @@
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
@@ -62,13 +62,19 @@ function buildAlphaLog(): SessionEvent[] {
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({
const userSeq = push({
type: 'user/message', surfaceOp: 'append',
data: {
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`),
source: { kind: 'user' },
},
})
if (turn === 0) {
push({
type: 'session/title',
data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } },
})
}
if (turn % 9 === 4) {
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
@@ -187,6 +193,20 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
return undefined
}
/** Fold the latest fixture title into the host's control-frame projection. */
function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract<MuxFrame, { type: 'session/title' }> | undefined {
const event = log.findLast(item => (item as { type: string }).type === 'session/title')
if (event === undefined) return undefined
const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } }
return {
type: 'session/title',
sessionId: id,
title: titleEvent.data.title,
eventSeq: titleEvent.seq,
updatedAt: titleEvent.time,
}
}
/**
* Message-boundary paging (mirrors the host's paging contract): count
* maxMessages messages
@@ -281,6 +301,41 @@ export function createFixtureApi(): ApiProxy {
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingQuestionRpcId = mint()
let questionPending = true
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
{
id: 'harness-profile',
header: '偏好',
question: '你现在更想招哪类 Agent/Harness 候选人?',
options: [
{ label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
{ label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
{ label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
],
},
{
id: 'work-mode',
header: '方式',
question: '你希望候选人优先展示哪种工作方式?',
options: [
{ label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
{ label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
],
},
{
id: 'signals',
header: '信号',
question: '哪些面试信号最重要?',
detail: '按当前招聘目标选择;跳过则视为不设偏好。',
multiSelect: true,
options: [
{ label: '系统设计' },
{ label: '代码质量' },
{ label: 'Agent 产品判断' },
],
},
]
const muxConns = new Set<StreamConn<MuxFrame>>()
const hostConns = new Set<StreamConn<HostFrame>>()
@@ -326,6 +381,10 @@ export function createFixtureApi(): ApiProxy {
emitMux(view === undefined
? { type: 'session/event', sessionId: id, event }
: { type: 'session/event', sessionId: id, event, view })
if ((event as { type: string }).type === 'session/title') {
// The raw title is already in this log, so the latest-title fold must find it.
emitMux(titleFrameOf(id, log) as Extract<MuxFrame, { type: 'session/title' }>)
}
}
/** At most one in-flight replay per session; cancel clears it. */
@@ -354,6 +413,12 @@ export function createFixtureApi(): ApiProxy {
appendUser(id: string, msg: string): void {
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
},
/** Append a later durable title revision through the normal raw-event + control-frame path. */
appendTitle(id: string, title: string): void {
const log = logOf(sid(id))
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
@@ -467,10 +532,12 @@ export function createFixtureApi(): ApiProxy {
muxConns.add(conn)
const breakNow = (): void => { conn.breakNow() }
streamBreakers.add(breakNow)
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
for (const s of sessions) {
if (!s.running) continue
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? [])
if (title !== undefined) conn.push({ rpcId: mint(), payload: title })
}
conn.push({
rpcId: pendingApprovalRpcId,
@@ -480,6 +547,14 @@ export function createFixtureApi(): ApiProxy {
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
payload: {
type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
},
})
}
try {
yield* conn.drain(signal)
} finally {
@@ -509,9 +584,16 @@ export function createFixtureApi(): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
void message
return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
questionPending = false
emitMux({
type: 'question/resolved', sessionId: sid('fx-alpha'),
questionRpcId: pendingQuestionRpcId,
outcome: message.result.ok ? 'answered' : 'cancelled',
})
return Promise.resolve({ accepted: true })
},
}
}

View File

@@ -18,6 +18,7 @@ interface TimingHooks {
setHistoryDelay(ms: number): void
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -148,14 +149,14 @@ describe('createFixtureApi', () => {
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
})
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
const api = createFixtureApi()
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
const abort = new AbortController()
const envelopes: RpcRequest<MuxFrame>[] = []
for await (const envelope of api.events.mux(req({}), abort.signal)) {
envelopes.push(envelope)
if (envelopes.length >= 2) abort.abort()
if (envelopes.length >= 4) abort.abort()
}
return envelopes
}
@@ -163,8 +164,11 @@ describe('createFixtureApi', () => {
const second = await openOnce()
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' })
expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics)
expect(first[3]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
expect(second[3]?.rpcId).toBe(first[3]?.rpcId)
})
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -217,9 +221,38 @@ describe('createFixtureApi', () => {
}
})
it('respond is a typed stub: always not-pending', async () => {
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
const api = createFixtureApi()
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
const abort = new AbortController()
let question: RpcRequest<MuxFrame> | undefined
for await (const envelope of api.events.mux(req({}), abort.signal)) {
if (envelope.payload.type !== 'question/requested') continue
question = envelope
abort.abort()
}
if (question === undefined) throw new Error('fixture question missing')
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
expect(await api.respond(response)).toEqual({ accepted: true })
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
const replayAbort = new AbortController()
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
const cancelledApi = createFixtureApi()
const cancelAbort = new AbortController()
let cancelQuestion: RpcRequest<MuxFrame> | undefined
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
if (envelope.payload.type !== 'question/requested') continue
cancelQuestion = envelope
cancelAbort.abort()
}
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
expect(await cancelledApi.respond({
type: 'client-response', rpcId: cancelQuestion.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
})).toEqual({ accepted: true })
})
it('describe answers the fixture identity', async () => {
@@ -248,10 +281,15 @@ describe('createFixtureApi', () => {
await new Promise(resolve => setTimeout(resolve, 10))
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')
const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')
expect(titleControlIndex).toBe(rawTitleIndex + 1)
// But history serves the silent event (the client's repull finds it).
const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
if (!repull.result.ok) throw new Error('repull failed')

View File

@@ -2,6 +2,10 @@
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
## Session title projection
`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.
## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.

View File

@@ -4,9 +4,15 @@
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
/** Host list summary enriched with the latest mux-projected durable title. */
export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
updatedAt: number
running: boolean
parentSessionId?: SessionId
@@ -21,12 +27,12 @@ export interface SessionListEntry {
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, SessionSummary>()
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
const children = new Map<SessionId, SessionSummary[]>()
const roots: SessionSummary[] = []
const children = new Map<SessionId, TitledSessionSummary[]>()
const roots: TitledSessionSummary[] = []
for (const s of summaries) {
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
const list = children.get(s.parentSessionId) ?? []
@@ -37,12 +43,12 @@ export function flattenLineage(summaries: readonly SessionSummary[]): SessionLis
}
}
const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: SessionSummary, depth: number): void => {
const walk = (s: TitledSessionSummary, depth: number): void => {
if (visited.has(s.sessionId)) {
console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
return

View File

@@ -4,7 +4,7 @@
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionListEntry } from './lineage.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
@@ -19,6 +19,13 @@ export interface SessionListSnapshot {
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Latest title control snapshot retained independently of list/instance arrival. */
interface SessionTitleSnapshot {
title: string
eventSeq: number
updatedAt: number
}
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
private readonly sessions = new Map<SessionId, Session>()
@@ -27,6 +34,7 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
private listError: RpcError | null = null
@@ -158,6 +166,24 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (frame.type === 'session/title') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
this.titleSnapshots.set(frame.sessionId, {
title: frame.title,
eventSeq: frame.eventSeq,
updatedAt: frame.updatedAt,
})
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq > frame.lastSeq) {
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
@@ -204,6 +230,7 @@ export class SessionManager {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
}
@@ -230,12 +257,19 @@ export class SessionManager {
}
private buildListSnapshot(): SessionListSnapshot {
const fresh = flattenLineage(this.summaries)
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
const title = this.titleSnapshots.get(summary.sessionId)
return title === undefined
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
})
const fresh = flattenLineage(merged)
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -25,7 +25,10 @@ import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
title: string
/** Latest durable log-backed title, absent until the host projects one. */
title?: string
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
parentId?: SessionId
running: boolean
@@ -62,10 +65,11 @@ export function scopeOf(ctx: Context): SessionId | undefined {
function sessionScope(): void {}
/**
* Display title projection. The wire summary carries no title yet (P-I
* ledger): the project directory's basename stands in, then the raw id.
* Display title projection: durable title, project directory basename, then
* the raw id.
*/
function titleOf(cwd: string | undefined, id: SessionId): string {
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
@@ -259,9 +263,10 @@ export class SessionsService {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
title: titleOf(entry.cwd, entry.sessionId),
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}

View File

@@ -89,6 +89,66 @@ describe('list lifecycle', () => {
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'title-new' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
})
manager.handleMuxEnvelope({
rpcId: 'title-stale' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
})
manager.handleMuxEnvelope({
rpcId: 'title-equal' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
})
api.onList = () => Promise.resolve(ok({
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
}))
await manager.refreshList()
const titled = manager.getListSnapshot()
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
expect(titled.items[1]?.title).toBeUndefined()
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 'title-unflushed' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
})
manager.handleMuxEnvelope({
rpcId: 'subscribed-recovered' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
manager.handleMuxEnvelope({
rpcId: 'title-durable' as never,
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
manager.handleMuxEnvelope({
rpcId: 'subscribed-current' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
})
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
})
})
describe('host frame routing', () => {

View File

@@ -41,16 +41,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
}
describe('list store projection', () => {
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.manager.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reflects live increments (host stream via manager) into the store', async () => {
@@ -273,12 +278,13 @@ describe('create', () => {
})
describe('coverage tails (branch duals)', () => {
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
const { byId } = b.svc.list.getSnapshot()
expect(byId[sid('no-base')]?.title).toBe('no-base')
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
expect(byId[sid('no-base')]?.title).toBeUndefined()
})
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {

View File

@@ -26,4 +26,4 @@ None; this package neither assembles nor sends a provider request.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **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.
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.

View File

@@ -18,8 +18,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -90,7 +90,7 @@ export function ConversationRoot({
disabled={last}
onClick={() => { open(s.id) }}
>
{s.title}
{s.displayTitle}
</button>
</span>
)

View File

@@ -51,7 +51,7 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
current: ROOT,
} as SessionListState)
const sessionFake = {

View File

@@ -26,8 +26,8 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 },
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
current: undefined,
} as SessionListState)

View File

@@ -122,8 +122,8 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', parentId: ROOT, running: false, updatedAt: 0 },
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
@@ -157,7 +157,7 @@ describe('bash sample row', () => {
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', running: false, updatedAt: 0 }
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()

View File

@@ -123,23 +123,25 @@ describe('selection survives on the store seat', () => {
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a title-upgrading list refresh keeps instance identity and the selection value', async () => {
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
const id = await b.sessions.create({})
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → formal title).
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
await b.sessions.manager.refreshList()
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)

View File

@@ -46,7 +46,7 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: r.title, running: false, updatedAt: 1,
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),

View File

@@ -53,7 +53,7 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: r.title, running: false, updatedAt: 1,
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-question
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
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. 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`.
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.
## Model Experience
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
#### KV Cache effect
No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and result.
## Known Limitations and Deferred Work
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.

View File

@@ -0,0 +1,67 @@
{
"name": "@deepseek-ai/dsh-client-ui-question",
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,348 @@
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
}
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: 720px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
max-height: min(60vh, 520px);
padding: 14px 16px 12px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 18px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv1-blur);
color: var(--dsw-alias-label-primary);
}
.card,
.card * {
box-sizing: border-box;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
margin-bottom: 8px;
}
.headingBlock {
min-width: 0;
padding: 1px 2px;
}
.eyebrow {
margin-bottom: 2px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 600;
}
.multiSelectHint {
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
font-weight: 400;
white-space: nowrap;
}
.detail {
margin: 2px 0 0;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.headerActions,
.footerActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.progress {
padding: 0 6px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 24px;
white-space: nowrap;
}
.iconButton {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.options {
display: flex;
flex-direction: column;
gap: 4px;
/* The scrollable region of the capped card (ChatView list pattern). */
min-height: 0;
overflow-y: auto;
}
.option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.option:hover:not(:disabled),
.optionSelected {
background: var(--dsw-alias-interactive-bg-hover);
}
.optionSelected {
border-color: var(--dsw-alias-border-l2);
}
.option:disabled,
.customTrigger:disabled {
cursor: default;
}
.number {
display: grid;
place-items: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.optionCopy {
min-width: 0;
flex: 1;
}
.optionLine {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
}
.optionLabel {
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.badge {
padding: 0 6px;
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 18px;
}
.description {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.choiceIcon {
display: grid;
place-items: center;
width: 20px;
color: var(--dsw-alias-label-tertiary);
}
.custom {
border: 1px solid transparent;
border-radius: 12px;
}
.customOpen {
border-color: var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-module-platform);
}
.customOptionless {
border: none;
background: transparent;
}
.customTrigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: none;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
text-align: left;
cursor: pointer;
}
.customTrigger:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
}
.customInput {
display: block;
width: calc(100% - 20px);
min-height: 54px;
max-height: 140px;
margin: 0 10px 10px;
padding: 7px 10px;
resize: vertical;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
outline: none;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 13px;
line-height: 20px;
}
.customInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.customOptionless .customInput {
width: 100%;
min-height: 58px;
margin: 0;
background: var(--dsw-alias-bg-module-platform);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-top: 8px;
padding: 0 2px;
}
.feedback {
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}
@media (max-width: 720px) {
.frame {
padding: 6px 10px 10px;
}
.card {
padding: 12px 10px 10px;
border-radius: 16px;
}
.header {
display: block;
}
.headerActions {
justify-content: flex-end;
margin-top: 8px;
}
.headingBlock {
padding: 0 2px;
}
.title {
font-size: 15px;
line-height: 21px;
}
.option,
.customTrigger {
align-items: flex-start;
gap: 8px;
padding: 6px;
}
.choiceIcon {
margin-top: 3px;
}
.footer {
align-items: flex-end;
}
.footerActions {
flex-shrink: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.option {
transition: none;
}
}

View File

@@ -0,0 +1,297 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
customOpen: boolean
skipped: boolean
}
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
* @returns Display label plus recommendation state.
*/
export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
const suffix = /\s*(?:\((?:recommended|推荐)\)|(?:recommended|推荐))\s*$/i
return suffix.test(label)
? { label: label.replace(suffix, ''), recommended: true }
: { label, recommended: false }
}
/**
* Remove a conventional multi-select suffix so the hint can be styled separately.
* @param title - Question title supplied by the interaction request.
* @returns Question title without a trailing multi-select marker.
*/
export function parseQuestionTitle(title: string): string {
return title.replace(/\s*[(]可多选[)]\s*$/, '')
}
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
* @param props - the selector-matched pending question carrier plus the framework standard kit.
* @returns The question flow 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} />
}
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
const question = questions[index]!
const draft = drafts[index]!
const hasOptions = (question.options?.length ?? 0) > 0
const cancelFlow = (): void => {
setBusy('cancel')
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
setError(null)
}
const choose = (label: string): void => {
updateDraft((current) => {
const selected = question.multiSelect === true
? current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', customOpen: false, skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}
const openCustom = (): void => {
updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
}
const answered = (item: DraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
const submitDrafts = (values: DraftAnswer[]): void => {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
return
}
const answer: QuestionAnswer = {
answers: questions.map((item, itemIndex) => {
const value = values[itemIndex] as DraftAnswer
if (value.skipped) return { id: item.id, selected: [] }
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
}
setBusy('answer')
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError('请选择一个选项或填写自定义答案。')
return
}
if (index < questions.length - 1) {
setIndex(current => current + 1)
setError(null)
return
}
submitDrafts(drafts)
}
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? {
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
: item)
setDrafts(nextDrafts)
setError(null)
if (index < questions.length - 1) {
setIndex(current => current + 1)
return
}
submitDrafts(nextDrafts)
}
return (
<div className={css.frame} data-question-key={pending.key}>
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
</h2>
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
</header>
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
<span className={css.number}>{optionIndex + 1}</span>
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}></span>}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
</button>
)}
{draft.customOpen && (
<textarea
autoFocus
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
event.preventDefault()
continueFlow()
}
}}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
</Button>
<Button
variant="primary" size="sm"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
</Button>
</div>
</footer>
</section>
</div>
)
}

View File

@@ -0,0 +1,77 @@
/**
* Question-composer slot contract: the registrant-side props composition for
* the conversation-owned `conversation.composer` slot, plus the question
* domain face over the runtime's carrier object. The carrier (PendingWait)
* owns envelope transport only; the question protocol — answer value shape,
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
// entry) into every program that sees this contract, so PropsRuntime resolves.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
/** The pending question carrier the owner dispatches into the composer slot. */
export type QuestionWait = PendingWait<'question'>
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = QuestionResponsePayload['answer']
/**
* Question domain face over the carrier: render identity and questions
* transparently forwarded; answer/cancel own the wire encoding (the ok value
* shape and the cancelled error) and turn a rejected carrier receipt into a
* thrown error. Components mint one per carrier via useMemo (never inside a
* select — a per-dispatch mint would churn identity and break memoization).
*/
export class PendingQuestion {
/**
* @param wait - the runtime carrier for one pending question request.
*/
constructor(private readonly wait: QuestionWait) {}
/** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
get key(): string {
return this.wait.key
}
/** The request's question list, forwarded from the carrier payload. */
get questions(): QuestionWait['payload']['questions'] {
return this.wait.payload.questions
}
/**
* Deliver the whole answer batch; a rejected carrier receipt throws.
* @param answer - complete structured answer batch.
*/
async answer(answer: QuestionAnswer): Promise<void> {
const receipt = await this.wait.respond({
ok: true, value: { sessionId: this.wait.sessionId, answer },
})
if (!receipt.accepted) {
throw new Error(`question response rejected: ${receipt.reason}`)
}
}
/** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
async cancel(): Promise<void> {
const receipt = await this.wait.respond({
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
})
if (!receipt.accepted) {
throw new Error(`question cancellation rejected: ${receipt.reason}`)
}
}
}
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the question carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface.
*/
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }

View File

@@ -0,0 +1,36 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain. Pure
* consumer — the selector narrows the owner's currency to the question
* carrier (matched prop), and the whole behavior surface rides the carrier
* (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
}
/**
* Client plugin body: register the question composer into the composer chain.
* Zero business face — data and verbs both live on the matched carrier.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const slots = ctx.slots
ctx.effect(
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
'ui-question: composer chain registration',
)
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}

View File

@@ -0,0 +1,17 @@
/**
* Web question plugin, node half: enabling this UI feature also exposes the
* model-facing ask_user_question tool on the host composition.
*/
import type { Context } from 'cordis'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
/** Host services required by the model-facing tool. */
export const inject = ['tools', 'userInteraction']
/**
* Mount ask_user_question for hosts that selected the Web question plugin.
* @param ctx - Host plugin context carrying tools and userInteraction.
*/
export function apply(ctx: Context): void {
toolAskUser.apply(ctx)
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-question`.
* @module @deepseek-ai/dsh-client-ui-question/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question'
/** Cordis companion plugin name. */
export const name = 'client-ui-question-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: tool and slot registrations are effects
* owned and observed by their respective registries; the host pending table is
* exercised through the public wire protocol.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,64 @@
/**
* apply wiring on a real cordis Context + SlotsService: QuestionComposer
* registered as the `question` entry of the conversation-declared composer
* slot with ZERO business face (data and verbs ride the dispatched carrier),
* load-order fail-loud, and fiber-teardown unregistration. Component and
* domain-face behavior is covered props-direct in question-composer.spec.tsx;
* no renderer machinery here.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
// Stand-in for ui-conversation's conversation entry: the composer slot only
// exists while a live entry declares it in children (declaration account:
// design §2.2).
slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null,
)
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
it('registers the question entry: routing selector, no inject face', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
expect(entry.component).toBe(QuestionComposer)
// The whole behavior surface rides the matched carrier: no business face.
expect(entry.inject).toBeUndefined()
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,28 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { apply, inject } from '../src/index.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe('ui-question node plugin', () => {
it('exposes ask_user_question only for the selected Web feature lifecycle', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const feature = ctx.plugin({ inject: [...inject], apply })
await feature.await()
expect(ctx.tools.get('ask_user_question')).toBeDefined()
await feature.dispose()
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
})
})

View File

@@ -0,0 +1,265 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ConversationSnapshot, SessionId, SessionListState } 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 { PendingQuestion } from '../src/client/contract/slots.ts'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Framework standard-kit stubs: the composer consumes none of them, the
* composed props type mandates their delivery (framework hooks are plain
* stubs per the client testing discipline). */
const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
}
const QUESTIONS = [
{
id: 'profile', header: '偏好', question: '选择候选人类型',
detail: '按当前空缺岗位的优先级选择。',
options: [
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
{ label: '研究潜力型', description: '优先研究能力。' },
],
},
{
id: 'detail', question: '补充你的要求',
},
{
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
},
]
/** Carrier fixture: a real PendingWait over a scripted respond carrier. */
function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
const carrier = new PendingWait(
'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond)
return { carrier, respond }
}
/** The client-response envelope respond must have received for an answer batch. */
function answeredEnvelope(rpcId: string, answers: object[]) {
return {
type: 'client-response', rpcId: RpcId(rpcId),
result: { ok: true, value: { sessionId: SID, answer: { answers } } },
}
}
describe('QuestionComposer', () => {
it('collects single, custom, and multi-select answers before one batch submit', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
expect(screen.getByText('按当前空缺岗位的优先级选择。')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
expect(respond).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// detail is per-question: the second question carries none.
expect(screen.queryByText('按当前空缺岗位的优先级选择。')).toBeNull()
expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
expect(screen.getByText('选择重要信号')).toBeTruthy()
expect(screen.getByText('可多选')).toBeTruthy()
expect(screen.queryByText('(可多选)')).toBeNull()
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
// The domain face encoded the whole batch into one carrier envelope.
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
]))
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
})
it('skips individual questions without discarding earlier answers', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(screen.getByText('3 / 3')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['研究潜力型'] },
{ id: 'detail', selected: [] },
{ id: 'signals', selected: [] },
]))
})
it('keeps IME Enter inside the custom input until composition finishes', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: '中文输入' } })
fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
expect(screen.getByText('2 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
})
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.keyDown(emptyCustom, { key: 'Enter' })
expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
fireEvent.click(screen.getByLabelText('下一题'))
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('上一题'))
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(respond).not.toHaveBeenCalled()
})
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
.mockRejectedValueOnce(new Error('第二次取消失败'))
const { carrier } = wait('question-1', respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
// Receipt rejection surfaces through the domain face's thrown message.
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy()
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
})
it('surfaces transport rejection and resets local drafts for a different request', async () => {
const respond = vi.fn()
.mockRejectedValueOnce(new Error('网络中断'))
.mockRejectedValueOnce('字符串错误')
const first = wait('first', respond)
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
const second = wait('second', respond)
view.rerender(<QuestionComposer matched={second.carrier} interactions={[second.carrier]} {...kit} />)
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
const custom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(custom, { target: { value: 'x' } })
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('字符串错误')).toBeTruthy()
})
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
const first = wait('same-id')
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
expect(screen.getByText('2 / 3')).toBeTruthy()
// Replay mints a NEW carrier for the same request; same key = no remount.
const replayed = wait('same-id')
view.rerender(<QuestionComposer matched={replayed.carrier} interactions={[replayed.carrier]} {...kit} />)
expect(screen.getByText('2 / 3')).toBeTruthy()
})
})
describe('PendingQuestion domain face', () => {
it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
const question = new PendingQuestion(wait('rq', respond).carrier)
const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
await expect(question.answer(batch)).resolves.toBeUndefined()
expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers))
await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/)
})
it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: true })
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
const question = new PendingQuestion(wait('rc', respond).carrier)
await expect(question.cancel()).resolves.toBeUndefined()
expect(respond).toHaveBeenCalledWith({
type: 'client-response', rpcId: RpcId('rc'),
result: {
ok: false,
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
},
})
await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/)
})
it('forwards key and questions from the carrier', () => {
const question = new PendingQuestion(wait('rk').carrier)
expect(question.key).toBe('q:rk')
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
})
})
describe('parseRecommendedLabel', () => {
it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})
describe('parseQuestionTitle', () => {
it('removes Chinese and ASCII multi-select suffixes', () => {
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-question', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -154,7 +154,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo
type: 'session',
id: s.id,
groupKey: g.key,
title: s.title,
title: s.displayTitle,
depth,
hasChildren,
expanded,
@@ -183,7 +183,7 @@ function flattenVisible(g: Group, expandedSessions: ReadonlySet<string>, rows: S
function searchVisible(g: Group, q: string): Set<SessionId> {
const visible = new Set<SessionId>()
for (const m of g.summaries.values()) {
if (!m.title.toLowerCase().includes(q)) continue
if (!m.displayTitle.toLowerCase().includes(q)) continue
let cur: SessionSummary | undefined = m
while (cur !== undefined && !visible.has(cur.id)) {
visible.add(cur.id)
@@ -213,9 +213,9 @@ function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarR
*
* Normal mode: every project row shows; sessions show under expanded
* projects, descending only into expanded sessions. Search mode (non-blank
* query, case-insensitive title substring): expansion state is ignored —
* query, case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a title or label hit are dropped, and a label-only hit keeps the
* without a display-title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* @param view - local expansion arrays and search query.

View File

@@ -23,7 +23,7 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }

View File

@@ -38,6 +38,7 @@ function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
displayTitle: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}

View File

@@ -11,6 +11,7 @@ const sid = (s: string) => s as SessionId
interface SummaryInit {
id: string
title?: string
displayTitle?: string
cwd?: string
parentId?: string
running?: boolean
@@ -20,10 +21,11 @@ interface SummaryInit {
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
displayTitle: init.displayTitle ?? init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.title !== undefined) s.title = init.title
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
@@ -211,6 +213,15 @@ describe('deriveRows search', () => {
const rows = deriveRows(list, view({ query: ' ' }))
expect(rows.every(r => r.type === 'project')).toBe(true)
})
it('matches the effective display title when no durable title is available', () => {
const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' }))
const rows = deriveRows(fallback, view({ query: 'fallback' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/elsewhere' }),
expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }),
])
})
})
describe('formatRelativeTime', () => {

View File

@@ -4,6 +4,8 @@ Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader
The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
## Model Experience
None, as the entry shell boots the browser plugin tree; nothing here reaches a model request.

View File

@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'react'
/** Props for the shell-owned browser title projection. */
export interface DocumentTitleProps {
/** Durable title of the selected session, or undefined for the product title. */
title?: string
}
/**
* Project the selected durable session title into the browser title and
* restore the shell's original product title when unmounted.
* @param props - selected session title projection.
* @returns no rendered content.
*/
export function DocumentTitle({ title }: DocumentTitleProps): null {
const original = useRef(document.title)
useEffect(() => {
document.title = title === undefined ? original.current : `${title}${original.current}`
return () => { document.title = original.current }
}, [title])
return null
}

View File

@@ -6,6 +6,9 @@
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { DocumentTitle } from './DocumentTitle.tsx'
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
import type {} from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,5 +27,20 @@ export interface AssemblyDeps {
*/
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
const { ctx } = deps
return () => ctx.slots.renderSlot('root', {})
const sessions = ctx.get('sessions') as SessionsService | undefined
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
const useSessions = bindSnapshotSelector(sessions.list)
const SessionDocumentTitle = (): ReactNode => {
const title = useSessions((state) => {
const id = state.current
return id === undefined ? undefined : state.byId[id]?.title
})
return <DocumentTitle {...title === undefined ? {} : { title }} />
}
return () => (
<>
<SessionDocumentTitle />
{ctx.slots.renderSlot('root', {})}
</>
)
}

View File

@@ -8,4 +8,5 @@
export { bootWebShell } from './boot.tsx'
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
export { seedModules } from './seed.ts'

View File

@@ -39,7 +39,7 @@ window.DSHClientProxy.loadPlugin({
return {
apply: (ctx) => {
ctx.plugin(SlotsService)
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
ctx.provide('sessions', {
list,
cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
@@ -134,6 +134,7 @@ afterEach(() => {
delete win.__TEST_RUNTIME_STORE__
document.body.innerHTML = ''
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
document.title = ''
})
/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
@@ -147,6 +148,7 @@ describe('bootWebShell (real loader + real script execution)', () => {
win.__DSH_BOOT__ = { plugins: bootPlugins() }
seedSlotsService()
const el = mountPoint()
document.title = 'DeepSeek Harness'
let unmount: (() => void) | undefined
act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
expect(el.textContent).toContain('HARNESS')
@@ -155,9 +157,11 @@ describe('bootWebShell (real loader + real script execution)', () => {
await flushLoader()
expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
expect(el.textContent).not.toContain('HARNESS')
expect(document.title).toBe('S1 — DeepSeek Harness')
act(() => { unmount!() })
expect(el.childElementCount).toBe(0)
expect(document.title).toBe('DeepSeek Harness')
})
it('store seat round-trips through the entry props (useStore + actions)', async () => {
@@ -218,6 +222,9 @@ describe('buildRenderApp — assembly contract', () => {
const ctx = new Context()
const fiber = ctx.plugin(SlotsService)
await fiber.await()
ctx.provide('sessions', {
list: createSnapshotStore({ ids: [], byId: {}, current: undefined }),
})
const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
expect(renderApp).toBeTypeOf('function')
// No renderer installed: the one-line shell must surface the boot-order error.

View File

@@ -0,0 +1,28 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { DocumentTitle } from '../src/DocumentTitle.tsx'
afterEach(() => {
cleanup()
document.title = ''
})
describe('DocumentTitle', () => {
it('preserves the product title without a durable title and restores it on unmount', () => {
document.title = 'DeepSeek Harness'
const mounted = render(<DocumentTitle />)
expect(document.title).toBe('DeepSeek Harness')
mounted.rerender(<DocumentTitle title="First title" />)
expect(document.title).toBe('First title — DeepSeek Harness')
mounted.rerender(<DocumentTitle title="Revised title" />)
expect(document.title).toBe('Revised title — DeepSeek Harness')
mounted.rerender(<DocumentTitle />)
expect(document.title).toBe('DeepSeek Harness')
mounted.unmount()
expect(document.title).toBe('DeepSeek Harness')
})
})

View File

@@ -15,10 +15,24 @@ import SessionReferenceService, {
} from '@deepseek-ai/dsh-session-reference'
import { stringifyTagSafeJson } from '../src/serialization.ts'
class TestSessionQueryService extends SessionQueryService {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
}
}
async function harness(config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService, config)
return ctx
}
@@ -524,19 +538,19 @@ describe('session reference discovery and preparation', () => {
it('rejects direct invalid configuration before service publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
expect(() => new SessionReferenceService(ctx, { maxReferences: 0 }))
.toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG'))
const oversizedCtx = new Context()
await oversizedCtx.plugin(SessionStore)
await oversizedCtx.plugin(SessionQueryService)
await oversizedCtx.plugin(TestSessionQueryService)
expect(() => new SessionReferenceService(oversizedCtx, { maxReferences: 4 }))
.toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG'))
const defaultCtx = new Context()
await defaultCtx.plugin(SessionStore)
await defaultCtx.plugin(SessionQueryService)
await defaultCtx.plugin(TestSessionQueryService)
expect(() => new SessionReferenceService(defaultCtx)).not.toThrow()
})
})

View File

@@ -402,6 +402,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult>',
jsDoc: '/**\n * Create and publish one owner-scoped session after backend setup succeeds.\n * @param owner - exact registered Agent that owns access and cleanup.\n * @param request - backend type plus optional owner-local name and cwd.\n * @param signal - cancellation of unpublished setup.\n * @returns published identity, metadata, status, and MOTD.\n */',
},
{
signature: 'hasOwnerActivity(owner: Agent): boolean',
jsDoc: '/**\n * Test whether an exact owner has a published session or unpublished spawn.\n * @param owner - exact live owner to inspect.\n * @returns true across the entire spawn-to-close interval, with no publication gap.\n */',
},
{
signature: 'startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation',
jsDoc: '/**\n * Start one exclusive interactive send.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - explicit text, submit behavior, and cancellation.\n * @returns live operation handle for foreground await or task registration.\n */',
@@ -464,20 +468,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */',
},
{
signature: 'abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values, so observers cannot mutate backend-owned state.\n * @param id - the persisted session to inspect.\n * @returns the header and valid stored event prefix exactly as observed.\n */',
},
{
signature: 'abstract list(): Promise<SessionHeader[]>',
jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @returns one header per materialized session.\n */',
},
{
signature: 'abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>',
jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */',
},
],
},
{
key: 'sessionQuery',
summary: 'Live-preferred logical-corpus exact-read and relationship-tracing service.',
summary: 'Unified live-preferred session query service.',
methods: [
{
signature: 'abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise<SessionSearchPage<SessionSearchHit>>',
jsDoc: '/**\n * Search the live-preferred logical corpus and group by session.\n * @param request - query text, metadata filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns session hits ranked by their strongest matching event.\n */',
},
{
signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise<SessionSearchPage<SessionEventSearchHit>>',
jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits in deterministic relevance order.\n */',
},
{
signature: 'listSessions(): Promise<SessionRecord[]>',
jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */',
},
{
signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]>',
jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */',
},
{
signature: 'async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */',
@@ -486,6 +510,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */',
},
{
signature: 'async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise<SessionEventSearchDocument[]>',
jsDoc: '/**\n * Scan first-party semantic event documents with provider-independent filters.\n * @param sessionId - live-preferred session id to scan.\n * @param filters - ANDed metadata and literal-text predicates.\n * @returns matching semantic documents in ascending seq order.\n */',
},
{
signature: 'async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot>',
jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */',
@@ -730,7 +758,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'register(definition: ToolDefinition): () => void',
jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */',
jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */',
},
{
signature: 'restrict(filter: ToolRestriction): () => void',
@@ -754,7 +782,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */',
jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */',
},
],
},
@@ -1453,7 +1481,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'GenerateOptions',
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\';\n}',
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
},
{
name: 'GenericCallView',
@@ -1735,6 +1763,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAvailability',
declaration: 'export type SessionAvailability = \'live\' | \'persisted\';',
},
{
name: 'SessionEvent',
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
@@ -1743,6 +1775,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
},
{
name: 'SessionEventMetadataFilter',
declaration: 'export type SessionEventMetadataFilter = Exclude<SessionEventResultFilter, {\n kind: \'text\';\n}>;',
},
{
name: 'SessionEventReadRequest',
declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}',
@@ -1751,6 +1787,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionEventRecord',
declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}',
},
{
name: 'SessionEventResultFilter',
declaration: 'export type SessionEventResultFilter = ({\n kind: \'seq\';\n} & SessionResultRange) | ({\n kind: \'time\';\n} & SessionResultRange) | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n} | {\n kind: \'text\';\n text: string;\n};',
},
{
name: 'SessionEventSearchDocument',
declaration: 'export interface SessionEventSearchDocument extends SessionEventRecord {\n text: string;\n}',
},
{
name: 'SessionEventSearchHit',
declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}',
},
{
name: 'SessionEventSearchRequest',
declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}',
},
{
name: 'SessionEventSurface',
declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';',
@@ -1795,6 +1847,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionLocation',
declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
},
{
name: 'SessionPersistenceRevision',
declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;',
},
{
name: 'SessionPersistenceSnapshot',
declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\n}',
},
{
name: 'SessionRecord',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
@@ -1807,6 +1867,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionReferenceInput',
declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}',
},
{
name: 'SessionResultFilter',
declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};',
},
{
name: 'SessionResultRange',
declaration: 'export interface SessionResultRange {\n from?: number;\n to?: number;\n}',
},
{
name: 'SessionSearchCursor',
declaration: 'export type SessionSearchCursor = Branded<\'SessionSearchCursor\'>;',
},
{
name: 'SessionSearchExecContext',
declaration: 'export interface SessionSearchExecContext {\n signal?: AbortSignal;\n}',
},
{
name: 'SessionSearchHit',
declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}',
},
{
name: 'SessionSearchPage',
declaration: 'export interface SessionSearchPage<T> {\n items: readonly T[];\n nextCursor?: SessionSearchCursor;\n}',
},
{
name: 'SessionSearchRequest',
declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}',
},
{
name: 'SessionSurface',
declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}',
@@ -1981,11 +2069,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TaskSnapshot',
declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}',
},
{
name: 'TaskStart',
declaration: 'export interface TaskStart {\n kind: TaskKind;\n label: string;\n owner?: Agent;\n run(): TaskHooks;\n}',
declaration: 'export interface TaskStart {\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n run(): TaskHooks;\n}',
},
{
name: 'TaskStatus',
@@ -2033,7 +2121,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolDefinition',
declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
declaration: 'export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}',
},
{
name: 'ToolErrorInfo',

View File

@@ -78,7 +78,7 @@ describe('cordis_inspect', () => {
expect(report).toContain('- tools — Tool registry and execution pipeline.')
expect(report).toContain('/**')
expect(report).toContain('Register globally or in the calling agent scope.')
expect(report).toContain('@param definition - the tool schema')
expect(report).toContain('@param definition - tool schema, execution, and optional finalization/presentation callbacks')
expect(report).toContain('@returns the exact disposer')
expect(report).toContain('register(definition: ToolDefinition)')
expect(report).toContain('type shapes (referenced by the signatures above')

View File

@@ -67,7 +67,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
### What belongs to plugins
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute``tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute` definition-owned `finalizeContent` `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error`
- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation

View File

@@ -1,6 +1,6 @@
# dsh-tools
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -15,7 +15,7 @@ tools:
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing/unsupported output declarations and a non-positive/non-finite `timeoutMs` fail at registration. Disposed with the calling fiber.
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
@@ -33,11 +33,11 @@ Cancellation is cooperative and quiescent. Every typed invocation supplies a cal
### Live events
The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
### Key types
- `ToolDefinition``ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema and cooperatively stops through `exec.signal`.
- `ToolDefinition``ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional final-content and presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema and cooperatively stops through `exec.signal`. `finalizeContent(exec, result)` runs exactly once for every normalized result, including failures that bypass post-policy, and can replace only `content`; it must be synchronous and total.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
@@ -53,7 +53,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. Canonical-result provenance belongs to one immutable dispatch token, so a cached result from another call or tool is revalidated under the active declaration.
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts. A definition's optional `finalizeContent` then owns its last content-only invariant across normal results and outer pipeline failures; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.

View File

@@ -169,6 +169,18 @@ export interface ToolDefinition extends ToolSchema {
* @returns the canonical value declared by `output.schema`.
*/
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
/**
* Synchronous last-mile transform for model-facing content. The registry
* snapshots this callback when execution starts and invokes it exactly once
* for every normalized outcome, including pipeline failures that bypass
* `tools/post-execute`, immediately before lossless materialization.
* Returning `undefined` preserves the content; every other result field
* remains registry-owned. The callback must be total and must not throw.
* @param exec - immutable execution identity and arguments.
* @param result - complete normalized outcome before materialization.
* @returns replacement content, or `undefined` to preserve it.
*/
finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -330,9 +342,9 @@ export interface ToolRegistryScheduler {
prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
/** Run only the around-dispatch/body stage. */
dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
/** Run ordered post-execute finalization, then materialize and notify the final outcome. */
/** Run post-execute and definition-owned content finalization, then materialize and notify. */
finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
/** Materialize and notify a final outcome that must bypass post-execute. */
/** Run definition-owned content finalization, then materialize and notify without post-execute. */
finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
}
@@ -638,6 +650,8 @@ export class ToolRegistry extends Service {
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
/** Definition-owned final content transform snapshotted before policy begins. */
private contentFinalizers = new WeakMap<ToolRunContext, ToolDefinition['finalizeContent']>()
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
@@ -715,7 +729,7 @@ export class ToolRegistry extends Service {
/**
* Register globally or in the calling agent scope. Scoped tools shadow
* globals; duplicates within one layer and the reserved `run_code` name fail.
* @param definition - the tool schema, execution, and optional presentation functions.
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
@@ -910,10 +924,11 @@ export class ToolRegistry extends Service {
}
/**
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
* notification. Tool and listener failures resolve as materialized error
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive. Cancellation
* Execute through pre-policy, guards, around-dispatch, post-policy,
* definition-owned content finalization, and final notification. Tool and
* listener failures resolve as materialized error results; an invisible tool
* reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
* snapshot final observers receive. Cancellation
* arriving after entry and before final result materialization skips a
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
* successful started outcome with `ABORTED`; already-started work is still
@@ -952,6 +967,8 @@ export class ToolRegistry extends Service {
const agent = exec.agent
const parent = exec.parent
const signal = exec.signal
const definition = this.get(name, agent)
const finalizeContent = definition?.finalizeContent?.bind(definition)
const base = {
token,
callId,
@@ -970,6 +987,7 @@ export class ToolRegistry extends Service {
}
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
this.contentFinalizers.set(execution, finalizeContent)
this.cancellationStates.set(execution, {
callerSignal: signal,
bodyInvoked: false,
@@ -977,6 +995,7 @@ export class ToolRegistry extends Service {
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: MutableToolRunContext = { ...base, arguments: undefined }
this.contentFinalizers.set(execution, finalizeContent)
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -1130,7 +1149,8 @@ export class ToolRegistry extends Service {
}
/**
* Run ordered post-execute, then materialize and notify the final outcome.
* Run ordered post-execute, then apply definition-owned content finalization,
* materialize, and notify the final outcome.
* @param exec - the prepared execution.
* @param result - dispatch/pre result that still needs post-execute.
* @returns the materialized final result.
@@ -1151,16 +1171,23 @@ export class ToolRegistry extends Service {
}
/**
* Materialize and notify a final result that must bypass post-execute.
* Materialize the candidate, apply definition-owned content finalization,
* then materialize and notify the authoritative result.
* @param exec - the prepared execution.
* @param result - final result.
* @returns the materialized final result.
* @internal
*/
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
let materializedResult: ToolExecutionResult
try {
materializedResult = this.materializeFinalResult(result)
} catch (error: unknown) {
materializedResult = this.materializeFinalResult(toolErrorResult(error))
}
let finalResult: ToolExecutionResult
try {
finalResult = this.materializeFinalResult(result)
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))
} catch (error: unknown) {
finalResult = this.materializeFinalResult(toolErrorResult(error))
}
@@ -1168,6 +1195,14 @@ export class ToolRegistry extends Service {
return finalResult
}
/** Apply the snapshotted tool-owned content transform without exposing other result fields. */
private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
const finalizeContent = this.contentFinalizers.get(exec)
if (finalizeContent === undefined) return result
const content = finalizeContent(exec, result)
return content === undefined ? result : { ...result, content }
}
/** Notify observers without exposing a mutation or error channel into the outcome. */
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
// Freeze the registry's live object before observers receive its readonly

View File

@@ -3,7 +3,7 @@
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts'
import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './index.ts'
import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -511,6 +511,15 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends Valu
* @returns The canonical value declared by `output.schema`.
*/
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
/**
* Optional last-mile content transform for every normalized outcome. Unlike
* `execute`, arguments remain `unknown` because invalid-input failures also
* reach this callback. See {@link ToolDefinition.finalizeContent}.
* @param exec - immutable execution identity and arguments.
* @param result - complete normalized outcome before materialization.
* @returns replacement content, or `undefined` to preserve it.
*/
finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* Pure pending-state presenter.
* @param args - typed validated arguments.
@@ -530,7 +539,7 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends Valu
* Define a first-party tool with inferred arguments and strict execution
* validation. Replay-only presenters validate softly and fall back to generic
* rendering for obsolete logged arguments.
* @param options - typed definition and optional presenters.
* @param options - typed definition and optional finalizer and presenters.
* @returns A registry-ready definition.
*/
export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
@@ -540,6 +549,8 @@ export function defineTool<const S extends ParameterSchemaSpec, const O extends
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
const userFinalizeContent = options.finalizeContent
// eslint-disable-next-line @typescript-eslint/unbound-method
const userRender = options.output.render
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentationMeta = options.output.presentationMeta
@@ -577,6 +588,13 @@ export function defineTool<const S extends ParameterSchemaSpec, const O extends
return userExecute(args as InferArgs<S>, exec) as Promise<JsonValue>
},
}
if (userFinalizeContent) {
tool.finalizeContent = (exec, result) => userFinalizeContent(exec, result)
}
// Presentation is display-only and may run on REPLAY of arbitrary logged args
// (possibly from an older schema), so it must never throw: validate softly and
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
// than the hard `ToolArgsError` the execute path raises.
if (userPresentCall) {
tool.presentCall = (args: unknown): ToolCallView | undefined => {
if (validate(args).length > 0) return undefined

View File

@@ -51,22 +51,22 @@ describe('ToolRegistry', () => {
expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
})
it('schemas() drops the UI presentation callbacks — they must never reach the model', async () => {
it('schemas() drops host callbacks — they must never reach the model', async () => {
const ctx = await setup()
// A tool that declares presentCall/presentResult (functions). schemas() feeds
// the system-prompt assembly → the model request, so those callbacks (and
// `execute`) must be stripped: a function in the JSON tool schema would
// corrupt the request. schemas() is an explicit allowlist, so it can't leak.
// Tool definitions contain output, finalization, execution, and presentation
// callbacks. schemas() is an explicit allowlist so none can reach the model.
ctx.tools.register(defineContentToolFixture({
name: 'present',
description: 'has presenters',
parameters: { x: { type: 'string', required: true } },
async execute() { return [] },
finalizeContent: (_exec, result) => result.content,
presentCall: args => ({ card: 'generic', title: args.x }),
presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }),
}))
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
expect(schema.finalizeContent).toBeUndefined()
expect(schema.presentCall).toBeUndefined()
expect(schema.presentResult).toBeUndefined()
expect(schema.execute).toBeUndefined()
@@ -155,6 +155,72 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('finalizes errors discovered while snapshotting non-content result fields', async () => {
const ctx = await setup()
let finalizeCalls = 0
ctx.tools.register({
...echoTool,
name: 'throwing-meta',
output: {
...echoTool.output,
presentationMeta() {
const meta = {}
Object.defineProperty(meta, 'value', {
enumerable: true,
get() { throw new Error('snapshot failed: '.repeat(100)) },
})
return meta
},
},
finalizeContent(_exec, result) {
finalizeCalls += 1
const block = result.content[0]
if (block?.type !== 'text') return undefined
return [{ type: 'text', text: block.text.slice(0, 32) }]
},
async execute() {
return 'body'
},
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('throwing-meta'), name: 'throwing-meta', arguments: {},
})
expect(result.isError).toBe(true)
const block = result.content[0]
expect(block?.type).toBe('text')
expect(block?.type === 'text' ? block.text : '').toMatch(/^Error: tool "throwing-meta"/)
expect(block?.type === 'text' ? block.text : '').toHaveLength(32)
expect(finalizeCalls).toBe(1)
})
it('normalizes a throwing final content callback without invoking it again', async () => {
const ctx = await setup()
let finalizeCalls = 0
ctx.tools.register({
...echoTool,
name: 'throwing-finalizer',
finalizeContent() {
finalizeCalls += 1
throw new Error('finalizer violated its total contract')
},
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('throwing-finalizer'), name: 'throwing-finalizer', arguments: {},
})
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: finalizer violated its total contract' }],
isError: true,
error: { message: 'finalizer violated its total contract' },
})
expect(finalizeCalls).toBe(1)
})
it('requires every raw registration to declare its canonical output', async () => {
const ctx = await setup()
const missingOutput = {
@@ -747,6 +813,36 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
})
it('runs the snapshotted final content transform after outer pipeline normalization', async () => {
const ctx = await setup()
const dispose = ctx.tools.register(defineContentToolFixture({
name: 'bounded',
description: 'bounded result',
parameters: {},
async execute() { return [{ type: 'text', text: 'body' }] },
finalizeContent(exec, result) {
expect(exec.name).toBe('bounded')
expect(result.isError).toBe(true)
return [{ type: 'text', text: 'bounded failure' }]
},
}))
ctx.on('tools/pre-execute', async () => {
dispose()
throw new HarnessError('policy failed', 'POLICY_FAILED')
})
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('bounded'), name: 'bounded', arguments: {} })
expect(result).toEqual({
content: [{ type: 'text', text: 'bounded failure' }],
isError: true,
error: {
message: 'policy failed',
info: { name: 'HarnessError', code: 'POLICY_FAILED' },
},
})
})
it('a block decision can ALSO attach additionalContexts', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)

View File

@@ -15,7 +15,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-session-query` + `@deepseek-ai/dsh-session-reference` | exact current-surface reads and bounded `dsh-session:` snapshots |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots |
| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
@@ -43,7 +43,7 @@ The app owns this cluster through one ordered Cordis effect. Teardown drains the
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory and the parent of the derived `session-query.db` index |
| `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `sessionReferences` | service defaults | cross-session candidate and snapshot limits routed to `dsh-session-reference` |
@@ -58,7 +58,7 @@ The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the
- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);
- in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit.
Run it under `node --expose-internals`, or Loader's optional `node-addon-require-builtin` fallback is required, so the cordis Loader can resolve the config's bare plugin specifiers through its internal module loader. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.)
The repository installs Loader's optional `node-addon-require-builtin` peer, so the built bin resolves bare plugin specifiers through the internal module loader under plain Node. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.)
All diagnostics go to **stderr** — stdout is the protocol.

View File

@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -66,6 +67,7 @@
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -12,6 +12,7 @@
*/
import type { Context } from 'cordis'
import { join } from 'node:path'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import CommandService from '@deepseek-ai/dsh-commands'
@@ -25,7 +26,7 @@ import SessionPersistenceJsonl, {
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference'
export const name = 'acp-demo'
@@ -57,7 +58,7 @@ export interface Config {
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
packChunks?: boolean
@@ -110,14 +111,16 @@ export const Config: z<Config> = z.object({
/**
* Compose the spine with the ACP front door. The agent-spine-demo bundle pre-creates
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
* `persona`; the JSONL backend persists under `persistenceRoot`; the ACP
* bridge owns stdout for JSON-RPC and creates one agent per `session/new`
* from the provider/model pair. The composite effect unloads in reverse order,
* keeping checkpoint and persistence listeners attached until ACP agents have
* flushed their closing events. No logger, no `hmr` — stdout stays pure.
* `persona`; the JSONL backend and derived query index persist under
* `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one
* agent per `session/new` from the provider/model pair. The composite effect
* unloads in reverse order, keeping checkpoint and persistence listeners
* attached until ACP agents have flushed their closing events. No logger, no
* `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
const goals = config.goals ?? {}
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
ctx.effect(function* () {
yield ctx.plugin(CommandService).dispose
if (goals !== false) yield ctx.plugin(commandGoal).dispose
@@ -127,13 +130,13 @@ export function apply(ctx: Context, config: Config): void {
// persistence passthroughs rather than sharing a facade with stdio-demo.
/* jscpd:ignore-start */
yield ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
root: persistenceRoot,
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
}).dispose
/* jscpd:ignore-end */
yield ctx.plugin(sessionCheckpointPolicy).dispose
yield ctx.plugin(SessionQueryService).dispose
yield ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }).dispose
yield ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}).dispose
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
}, 'acp-demo.composition')

View File

@@ -23,8 +23,7 @@ import { ACP_SESSION_REFERENCE_META_KEY } from '@deepseek-ai/dsh-acp'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
* complete a mock-backed turn. This catches built-only settle races, stdout protocol leaks, and
* published persistence behavior that the tsx source-path smoke cannot. It skips before build;
* `--expose-internals` enables Cordis bare-plugin loading.
* published persistence behavior that the tsx source-path smoke cannot. It skips before build.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
@@ -37,7 +36,8 @@ const dshPackages = [
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
'session-query/session-query', 'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths',
'session-query/session-query', 'session-query/session-query-sqlite',
'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
@@ -133,7 +133,7 @@ afterEach(async () => {
describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
it('boots the published bin, completes a turn, and writes default Zstandard persistence', async () => {
consumer = await makeConsumer()
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
child = spawn(process.execPath, [acpBin, '--config', './cordis.yml'], {
cwd: consumer,
env: {
...process.env,
@@ -226,7 +226,7 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ['--expose-internals', acpBin, '--config', configArg], {
const proc = spawn(process.execPath, [acpBin, '--config', configArg], {
cwd,
env: {
...process.env,

View File

@@ -26,6 +26,9 @@
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../../context/session-reference"
},

View File

@@ -38,7 +38,7 @@ The root headless-agent example supplies its leaf:
pnpm run demo:headless "inspect the failing test and fix it"
```
Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag.
Loader configs resolve bare package specifiers through the optional native helper installed by the repository, so the root command needs no special Node flags.
### Output formats

View File

@@ -116,7 +116,7 @@ interface BinResult {
function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, ['--expose-internals', cliBin, ...args], {
const child = spawn(process.execPath, [cliBin, ...args], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['ignore', 'pipe', 'pipe'],

View File

@@ -13,7 +13,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-session-query` + `@deepseek-ai/dsh-session-reference` | Exact current-surface reads and bounded `@session` snapshots consumed by the TUI |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
@@ -37,7 +37,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `workspaceContext` | required | Workspace-instruction config, or `false` |
| `persistenceRoot` | `./.sessions` | JSONL persistence root |
| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` |
| `welcome` | `ready.` | TUI subtitle |
@@ -48,7 +48,7 @@ Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI a
## The bin
`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. Bare package specifiers require `node --expose-internals` or the Loader's optional native fallback; the repository scripts use `--expose-internals`.
`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
## Example leaf

View File

@@ -48,6 +48,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tui": "^0.0.1",
@@ -72,6 +73,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -10,6 +10,7 @@
import type { Context } from 'cordis'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -23,7 +24,7 @@ import SessionPersistenceJsonl, {
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiTui from '@deepseek-ai/dsh-tui'
@@ -52,7 +53,7 @@ export interface Config {
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
@@ -119,14 +120,15 @@ export function composeTuiApp(ctx: Context, config: Config): void {
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
const goals = config.goals ?? {}
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
root: persistenceRoot,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
ctx.plugin(SessionQueryService)
ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
ctx.plugin(SessionReferenceService, config.sessionReferences ?? {})
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui, {

View File

@@ -54,9 +54,9 @@ async function makeConsumer(): Promise<string> {
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
// NO tsx — this is the published `node lib/bin.js` path (`--expose-internals`
// matches the demo command; the guard fires before the Loader needs it).
const child = spawn(process.execPath, ['--expose-internals', tuiBin, './cordis.yml'], {
// NO tsx — this is the published `node lib/bin.js` path; the guard fires
// before the Loader resolves the config tree.
const child = spawn(process.execPath, [tuiBin, './cordis.yml'], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['pipe', 'pipe', 'pipe'],

View File

@@ -51,7 +51,7 @@ describe('dsh-tui-demo app', () => {
'command-goal',
'SessionPersistenceJsonl',
'session-checkpoint-policy',
'SessionQueryService',
'SessionQuerySqlite',
'SessionReferenceService',
'UserInteractionService',
'ui-tui',
@@ -60,6 +60,7 @@ describe('dsh-tui-demo app', () => {
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
expect(calls[4]?.config).toEqual({ path: '/tmp/tui-sessions/session-query.db' })
expect(calls[5]?.config).toEqual({
maxReferences: 2,
candidateLimit: 7,

View File

@@ -29,6 +29,9 @@
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../../context/session-reference"
},

View File

@@ -8,6 +8,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.

View File

@@ -17,6 +17,7 @@ export const askUserQuestionItemSchema = z.object({
id: z.string(),
question: z.string(),
header: z.string().optional(),
detail: z.string().optional(),
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
multiSelect: z.boolean().optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
@@ -25,9 +26,13 @@ export const askUserQuestionItemSchema = z.object({
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),
// Non-empty by wire contract: the user-interaction service rejects empty
// batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
// and must fail loud here, not reach the composer.
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>

View File

@@ -33,8 +33,9 @@ export type ToolEventView =
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
* attached session and replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline).
* attached session followed by its optional latest title snapshot, then replays each
* session's still-pending approval/question requested frames (rpcId reused verbatim — the
* refresh-recovery baseline).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
@@ -54,6 +55,7 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }

View File

@@ -33,6 +33,7 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),

View File

@@ -30,6 +30,7 @@ export function RpcId(id: string): RpcId {
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'agent-busy': { reason: string }
'internal': {}

View File

@@ -29,6 +29,7 @@ describe('RpcId', () => {
describe('rpcErrorSchema', () => {
it('accepts every code branch with its required details', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
@@ -123,6 +124,7 @@ describe('events frame schemas', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{ type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 },
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
@@ -131,9 +133,20 @@ describe('events frame schemas', () => {
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
for (const invalid of [
{ type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
})
it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => {
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
})
it('accepts every host frame branch', () => {
const frames = [
{ type: 'host/session-added', sessionId: 's', parentSessionId: 'p' },

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
@@ -12,21 +12,24 @@ Which plugins mount and with what defaults is decided only here — shells must
| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. |
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. |
| `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. |
## ApiProxy implementation notes
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
## Model Experience
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. When `workspaceContext` is enabled, each agent-loop instance freezes the applicable workspace instructions into its logged request prefix; the owning package documents the exact [model-visible framing](../../context/workspace-context/README.md#prompt-shape).
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.
#### KV Cache effect
No direct invalidation; the mounted model-facing plugins own their request-prefix changes.
No main-request invalidation; when enabled, the auxiliary title request has its own cache behavior and leaves the conversation prefix unchanged.
## Known Limitations and Deferred Work
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
@@ -49,6 +50,8 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
@@ -69,6 +72,7 @@
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
},

View File

@@ -1,8 +1,6 @@
/**
* Host-side ApiProxy implementation (minimal-first —
* describe/list/create/history/prompt/cancel and both streams are real,
* respond is a stub). Signature discipline: unary takes the narrow
* RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
* Host-side ApiProxy implementation. Signature discipline: unary takes the
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { randomUUID } from 'node:crypto'
@@ -12,9 +10,17 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -104,6 +110,28 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
@@ -155,6 +183,35 @@ interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
sessionId: SessionId
questions: AskUserQuestionItem[]
resolve: (answer: AskUserQuestionAnswer) => void
reject: (error: UserInteractionError) => void
signal?: AbortSignal
onAbort?: () => void
}
/** Validate one answer batch against the exact question request it resolves. */
function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
if (payload.sessionId !== pending.sessionId) return false
const answers = payload.answer.answers
if (answers.length !== pending.questions.length) return false
return answers.every((answer, index) => {
const question = pending.questions[index] as AskUserQuestionItem
if (answer.id !== question.id) return false
if (new Set(answer.selected).size !== answer.selected.length) return false
const custom = answer.custom?.trim()
if (custom !== undefined && custom === '') return false
if (custom !== undefined && answer.selected.length > 0) return false
if (question.multiSelect !== true && answer.selected.length > 1) return false
const labels = new Set(question.options?.map(option => option.label) ?? [])
return answer.selected.every(label => labels.has(label))
})
}
/**
* Compute the render intent for a tool/call or tool/result event through the
* presenters registered at this moment; every other event type gets none. A
@@ -219,12 +276,70 @@ class SessionNotFound extends Error {}
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
* @param defaults - host-level default provider/model: injected as
* agentOptions on create/resume, reported by describe from the same source.
* @returns the ApiProxy implementation (minimal-first; stubs noted per method).
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
for (const queue of muxQueues) queue.push(envelope)
}
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
pendingQuestions.delete(pending.rpcId)
if (pending.signal !== undefined && pending.onAbort !== undefined) {
pending.signal.removeEventListener('abort', pending.onAbort)
}
broadcast({
type: 'question/resolved', sessionId: pending.sessionId,
questionRpcId: pending.rpcId, outcome,
})
}
const disposeProvider = ctx.userInteraction.registerProvider({
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const sessionId = request.agent?.id
if (sessionId === undefined) {
return Promise.reject(new UserInteractionError(
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const rpcId = RpcId(randomUUID())
const pending: PendingQuestion = {
rpcId, sessionId, questions: request.questions, resolve, reject,
...(request.signal === undefined ? {} : { signal: request.signal }),
}
const onAbort = (): void => {
claimQuestion(pending, 'cancelled')
reject(new UserInteractionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
}
pending.onAbort = onAbort
pendingQuestions.set(rpcId, pending)
request.signal?.addEventListener('abort', onAbort, { once: true })
const envelope: RpcRequest<MuxFrame> = {
rpcId,
payload: { type: 'question/requested', sessionId, questions: request.questions },
}
for (const queue of muxQueues) queue.push(envelope)
})
},
})
ctx.effect(() => () => {
disposeProvider()
for (const pending of [...pendingQuestions.values()]) {
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'web user-interaction provider was disposed', 'ASK_ABORTED'))
}
}, 'api-proxy: user-interaction provider')
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
@@ -361,8 +476,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
events: {
mux(_request, signal) {
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
muxQueues.add(queue)
for (const session of ctx.sessions.list()) {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
subscribeSession(queue, session)
}
for (const pending of pendingQuestions.values()) {
queue.push({
rpcId: pending.rpcId,
payload: {
type: 'question/requested', sessionId: pending.sessionId,
questions: pending.questions,
},
})
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
@@ -385,15 +510,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
subscribeSession(queue, session)
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
return queue.iterate(signal, () => {
muxQueues.delete(queue)
for (const dispose of disposers) dispose()
})
},
host(_request, signal) {
@@ -421,9 +553,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
// TODO(step2): approval/question pending registry (wire answerer + proxy provider).
respond(_message: ClientResponse): Promise<RpcReceipt> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
respond(message: ClientResponse): Promise<RpcReceipt> {
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {
if (message.result.error.code !== 'cancelled') {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
return Promise.resolve({ accepted: true })
}
const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
if (!parsed.success) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
const payload: QuestionResponsePayload = {
sessionId: parsed.data.sessionId,
answer: {
answers: parsed.data.answer.answers.map(answer => ({
id: answer.id,
selected: answer.selected,
...(answer.custom === undefined ? {} : { custom: answer.custom }),
})),
},
}
if (!matchesQuestions(payload, pending)) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'answered')
pending.resolve(payload.answer)
return Promise.resolve({ accepted: true })
},
}
}

View File

@@ -8,6 +8,9 @@ import { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
import * as SessionTitleFirstMessageLlm from '@deepseek-ai/dsh-session-title-first-message-llm'
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -38,6 +41,23 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import SpillLocal from '@deepseek-ai/dsh-spill-local'
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
/** Default deterministic title policy for sessions created through the host. */
const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
}
/** Default first-message model-title policy for sessions created through the host. */
const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 4_096,
maxOutputTokens: 64,
timeoutMs: 60_000,
}
/** Options for bootHost — the assembly-layer composition knobs. */
export interface BootHostOptions {
@@ -49,6 +69,10 @@ export interface BootHostOptions {
provider?: string
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
model?: string
/** Deterministic fallback-title limits. */
sessionTitle?: SessionTitleConfig
/** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */
sessionTitleLlm?: true | SessionTitleLlmConfig
/**
* Default project directory for sessions created without an explicit cwd
* (defaults to the host process working directory). A session's cwd is its
@@ -92,8 +116,16 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
await ctx.plugin(Timer)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
if (options.sessionTitleLlm !== undefined) {
await ctx.plugin(
SessionTitleFirstMessageLlm,
options.sessionTitleLlm === true ? DEFAULT_SESSION_TITLE_LLM_CONFIG : options.sessionTitleLlm,
)
}
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(AgentLoop, { agents: [] })

View File

@@ -1,16 +1,16 @@
/**
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
* entry tree listing the eight UI plugin packages (the P-I config-source bar —
* entry tree listing the nine UI plugin packages (the P-I config-source bar —
* a cordis.yml file form comes later; install/remove currently means editing
* this list and restarting). The web plugin registry discovers the entries by
* their package.json dshClient declarations; node halves are empty applies,
* so mounting them here costs nothing beyond Loader governance.
* their package.json dshClient declarations; feature packages may also mount
* their interface-specific host half through the same lifecycle.
*/
import { createRequire } from 'node:module'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/** The eight UI plugin packages served to the browser (order = manifest order). */
/** The nine UI plugin packages served to the browser (order = manifest order). */
export const WEB_UI_PLUGINS = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
@@ -19,6 +19,7 @@ export const WEB_UI_PLUGINS = [
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
@@ -41,7 +42,7 @@ export interface MountedWebPlugins {
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
// import silently fails and every entry stays fiber-less. This package
// depends on all eight UI plugins, so its own URL is the right anchor.
// depends on all nine UI plugins, so its own URL is the right anchor.
ctx.baseUrl ??= import.meta.url
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))

View File

@@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -32,6 +33,7 @@ describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
@@ -76,6 +78,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const listed = await api.sessions.list(request({}))

View File

@@ -18,6 +18,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -39,6 +40,7 @@ async function harness(): Promise<{ ctx: Context }> {
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),

View File

@@ -8,6 +8,8 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -22,6 +24,10 @@ class ScriptedAdapter extends LlmAdapter {
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if ((options.tools?.length ?? 0) === 0) {
yield * textResponse('Durable append-only session titles')
return
}
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
@@ -68,6 +74,21 @@ function expectOk<T>(response: RpcResponse<T>): T {
return response.result.value
}
async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
const next = await iterator.next()
if (next.done === true) throw new Error('mux ended before the expected frame')
return next.value
}
/** Durably append a title event without mounting title-generation policy. */
function appendTitle(ctx: Context, agent: Agent, title: string) {
return ctx.sessions.appendOutOfBand(agent.session, 'session/title', {
title,
messageSeqs: [1],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
}
let host: RunningHost | undefined
beforeEach(() => {
@@ -80,13 +101,19 @@ afterEach(async () => {
vi.unstubAllEnvs()
})
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
async function boot(
script: (StreamChunk[] | 'hang')[] = [],
sessionTitle?: SessionTitleConfig,
sessionTitleLlm?: true | SessionTitleLlmConfig,
): Promise<RunningHost> {
host = await startHost({
boot: {
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
workspaceContext: false,
provider: 'scripted',
model: 'test-model',
...(sessionTitle === undefined ? {} : { sessionTitle }),
...(sessionTitleLlm === undefined ? {} : { sessionTitleLlm }),
},
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
@@ -161,6 +188,23 @@ describe('bootHost / startHost', () => {
expect(requestText).toContain('Instructions from: AGENTS.md')
expect(requestText).toContain('host-workspace-context-probe')
})
it('keeps model title generation disabled when sessionTitleLlm is omitted', async () => {
const running = await boot([textResponse('pong')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'Explain durable session titles.' }],
})))
await idle
expect((await ctx.sessionTitle.refresh(agent.session))?.source).toEqual({ kind: 'fallback' })
expect(agent.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
})
})
describe('host.describe', () => {
@@ -190,6 +234,94 @@ describe('sessions.create / list', () => {
})
describe('sessions.prompt / cancel', () => {
it.each([
{ name: 'host default', config: true, target: '5 words', maxTokens: 64 },
{
name: 'configured policy',
config: {
targetWords: 3,
targetCjkCharacters: 8,
maxInputBytes: 2_048,
maxOutputTokens: 24,
timeoutMs: 2_000,
},
target: '3 words',
maxTokens: 24,
},
] satisfies {
name: string
config: true | SessionTitleLlmConfig
target: string
maxTokens: number
}[])('replaces the fallback with a model-backed first-message title using the $name', async ({ config, target, maxTokens }) => {
const modelTitle = 'Durable append-only session titles'
const running = await boot([textResponse('pong')], undefined, config)
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'Explain why append-only logs make session titles durable.' }],
})))
await idle
await vi.waitFor(() => {
expect(agent.session.events.filter(event => event.type === 'session/title').map(event => event.data))
.toEqual([
{
title: 'Explain why append-only logs make',
messageSeqs: [1],
source: { kind: 'fallback' },
},
{
title: modelTitle,
messageSeqs: [1],
source: {
kind: 'provider',
provider: 'session-title-first-message-llm',
model: { provider: 'scripted', model: 'test-model' },
},
},
])
})
const titleRequest = agent.session.events.find(event => event.type === 'session/title-llm-request')
expect(titleRequest?.data.system).toContain(target)
expect(titleRequest?.data.maxTokens).toBe(maxTokens)
})
it.each([
{ name: 'host default', config: undefined, expected: 'Show the Web UI durable' },
{
name: 'configured limit',
config: { fallbackMaxWords: 2, fallbackMaxBytes: 40, maxTitleBytes: 80 },
expected: 'Show the',
},
] satisfies { name: string; config: SessionTitleConfig | undefined; expected: string }[])(
'logs a durable fallback title with the $name',
async ({ config, expected }) => {
const running = await boot([textResponse('pong')], config)
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'Show the Web UI durable session title' }],
})))
await idle
const title = agent.session.events.find(event => event.type === 'session/title')
expect(title?.data).toEqual({
title: expected,
messageSeqs: [1],
source: { kind: 'fallback' },
})
},
)
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
const running = await boot([textResponse('pong')])
const { api, ctx } = running
@@ -261,6 +393,7 @@ describe('sessions.history', () => {
const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }])
await idle
const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
await first.dispose()
host = await startHost({
@@ -268,6 +401,8 @@ describe('sessions.history', () => {
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
const abort = new AbortController()
const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]()
const [a, b] = await Promise.all([
host.api.sessions.history(request({ sessionId })),
host.api.sessions.history(request({ sessionId })),
@@ -278,6 +413,11 @@ describe('sessions.history', () => {
}
expect(host.ctx.agents.get(sessionId)).toBeDefined()
expect(host.ctx.agents.list()).toHaveLength(1)
expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq,
}))
abort.abort()
})
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
@@ -385,6 +525,43 @@ describe('events streams', () => {
expect((await stream.next()).done).toBe(true)
})
it('mux: projects durable titles after open baselines and immediately after live raw events', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const initial = await appendTitle(ctx, agent, 'Initial title')
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time,
}))
const revised = await appendTitle(ctx, agent, 'Revised title')
let raw: RpcRequest<MuxFrame>
do raw = await nextMux(stream)
while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title'))
expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } })
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time,
}))
ac.abort()
})
it('mux: emits no title control for untitled subscriptions', async () => {
const { api } = await boot()
const first = expectOk(await api.sessions.create(request({}))).sessionId
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first })
const second = expectOk(await api.sessions.create(request({}))).sessionId
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second })
ac.abort()
})
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
const running = await boot([textResponse('x')])
const { api, ctx } = running
@@ -418,10 +595,175 @@ describe('events streams', () => {
})
})
describe('respond stub', () => {
it('always reports not-pending (step2 registry pending)', async () => {
const { api } = await boot()
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
describe('question request / response', () => {
const questions = [{
id: 'mode', question: 'Choose a mode',
options: [
{ label: 'Fast (Recommended)', description: 'Move quickly.' },
{ label: 'Careful', description: 'Review first.' },
],
}]
it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
await stream.next() // subscribed baseline starts the generator and installs the queue
const answerPromise = ctx.userInteraction.ask({ questions, agent })
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
const wrongSession = await api.respond({
type: 'client-response', rpcId: requested.rpcId,
result: {
ok: true,
value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
},
})
expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
const badChoice = await api.respond({
type: 'client-response', rpcId: requested.rpcId,
result: {
ok: true,
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
},
})
expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
const invalidResults = [
{ ok: true as const, value: null },
{ ok: true as const, value: { sessionId, answer: { answers: [] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: ' ' }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
{ ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
]
for (const result of invalidResults) {
expect(await api.respond({
type: 'client-response', rpcId: requested.rpcId, result,
})).toEqual({ accepted: false, reason: 'bad-response' })
}
const reconnectAbort = new AbortController()
const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
await replay.next()
const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
expect(replayed.rpcId).toBe(requested.rpcId)
expect(replayed.payload).toEqual(requested.payload)
const response = {
type: 'client-response' as const,
rpcId: requested.rpcId,
result: {
ok: true as const,
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
},
}
const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
expect([first, duplicate]).toContainEqual({ accepted: true })
expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
await expect(answerPromise).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
})
const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
expect(resolved.payload).toMatchObject({
type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
})
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
const customQuestions = [{ id: 'detail', question: 'What else?' }]
const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(await api.respond({
type: 'client-response', rpcId: customRequested.rpcId,
result: {
ok: true,
value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
},
})).toEqual({ accepted: true })
await expect(customAnswer).resolves.toEqual({
answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
})
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
})
const blankAnswer = ctx.userInteraction.ask({ questions, agent })
const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(await api.respond({
type: 'client-response', rpcId: blankRequested.rpcId,
result: {
ok: true,
value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
},
})).toEqual({ accepted: true })
await expect(blankAnswer).resolves.toEqual({
answers: [{ id: 'mode', selected: [] }],
})
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
})
ac.abort()
reconnectAbort.abort()
})
it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const streamAbort = new AbortController()
const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
await stream.next()
const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(await api.respond({
type: 'client-response', rpcId: requested.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
})).toEqual({ accepted: true })
await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', outcome: 'cancelled',
})
const ownerAbort = new AbortController()
const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
.catch((error: unknown) => error)
const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
ownerAbort.abort()
await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
})
expect(await api.respond({
type: 'client-response', rpcId: abortRequest.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
})).toEqual({ accepted: false, reason: 'not-pending' })
streamAbort.abort()
})
it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
const running = await boot()
const { ctx } = running
await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const alreadyAborted = new AbortController()
alreadyAborted.abort()
await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
const outstanding = ctx.userInteraction.ask({ questions, agent })
const disposed = running.dispose()
host = undefined
await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await disposed
})
})

View File

@@ -1,5 +1,5 @@
/**
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
* Web UI plugin assembly: the in-memory Loader tree mounts all nine UI
* packages (node halves), and the webserver registry built over it yields the
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
*
@@ -10,6 +10,9 @@
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { afterEach, describe, expect, it } from 'vitest'
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
@@ -31,8 +34,16 @@ afterEach(async () => {
})
describe.skipIf(!built)('mountWebPlugins + registry', () => {
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
async function rootWithHostServices(): Promise<Context> {
root = new Context()
await root.plugin(SystemPrompt)
await root.plugin(ToolRegistry)
await root.plugin(UserInteractionService)
return root
}
it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => {
root = await rootWithHostServices()
const mounted = await mountWebPlugins(root)
const registry = createHostWebPluginRegistry({
ctx: root,
@@ -59,7 +70,7 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
})
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
root = new Context()
root = await rootWithHostServices()
await mountWebPlugins(root)
const second = await mountWebPlugins(root)
// ctx.loader hands out a fresh traced proxy per access, so loader identity

View File

@@ -1,5 +1,5 @@
/**
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
* mountWebPlugins unit coverage (keyless; the real nine-package walk is the
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
* resolver seam — is exercised against a stubbed loader service so it runs
@@ -85,7 +85,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
root = new Context()
// Environment-dependent outcome: with built lib/ the eight imports load
// Environment-dependent outcome: with built lib/ the nine imports load
// and the mount resolves; without them every entry stays fiber-less and
// the sweep throws its loud list. Either way the branch under test is the
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
@@ -100,7 +100,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
}
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
expect(root.get('loader') !== undefined).toBe(true)
}, 30_000) // built-env run imports eight real plugin packages through the Loader
}, 30_000) // built-env run imports nine real plugin packages through the Loader
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
const entriesList: FakeEntry[] = []

View File

@@ -23,6 +23,12 @@
{
"path": "../../core/session"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-title/session-title-first-message-llm"
},
{
"path": "../../core/system-prompt"
},
@@ -140,6 +146,9 @@
{
"path": "../../client/ui-conversation"
},
{
"path": "../../client/ui-question"
},
{
"path": "../../client/ui-trajectory"
}

View File

@@ -32,7 +32,7 @@ The plugin registers the single provider route `deepseek`. A request selects it
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults.
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.

View File

@@ -118,14 +118,18 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
parameters: tool.parameters,
},
}))
// A short title budget must produce visible text; conversation and
// compaction calls continue to inherit the adapter's thinking defaults.
const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking
const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort
return {
model: options.model,
messages,
stream: true,
stream_options: { include_usage: true },
...defaults.thinking !== undefined ? { thinking: { type: defaults.thinking } } : {},
...defaults.reasoningEffort !== undefined ? { reasoning_effort: defaults.reasoningEffort } : {},
...thinking !== undefined ? { thinking: { type: thinking } } : {},
...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {},
...tools !== undefined && tools.length > 0 ? { tools } : {},
...options.temperature !== undefined ? { temperature: options.temperature } : {},
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},

View File

@@ -180,6 +180,15 @@ describe('serializeRequest', () => {
expect(wire.reasoning_effort).toBe('max')
})
it('disables thinking for session-title requests without changing adapter defaults', () => {
const wire = serializeRequest(
request({ messages: history, purpose: 'session-title' }),
{ thinking: 'enabled', reasoningEffort: 'max' },
)
expect(wire.thinking).toEqual({ type: 'disabled' })
expect(wire.reasoning_effort).toBeUndefined()
})
it('omits thinking fields when unset (provider default applies)', () => {
const wire = serializeRequest(request({ messages: history }))
expect(wire.thinking).toBeUndefined()

View File

@@ -39,7 +39,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
### Call configuration (`call-config.ts`)
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated.
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
### App attribution (`attribution.ts`)

View File

@@ -228,8 +228,8 @@ export interface GenerateOptions {
sessionId?: Branded<'SessionId'>
/**
* Provider-neutral classification for an auxiliary model call. Adapters may
* map the purpose to model-hidden transport metadata. Ordinary conversation
* requests leave it unset.
* map the purpose to model-hidden transport metadata or purpose-specific
* generation policy. Ordinary conversation requests leave it unset.
*/
purpose?: 'compaction'
purpose?: 'compaction' | 'session-title'
}

View File

@@ -4,6 +4,6 @@ Plan mode is one logged, per-agent collaboration state. It is a single **product
| Package | Role | ctx key |
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]`, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. ACP maps this capability onto its generic `default` / `plan` picker; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-plan-mode
Logged, per-agent plan collaboration state with deployment-owned guidance, a direct `/plan [message]` entry command, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
## Durable state
@@ -12,7 +12,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, a dir
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`.
When `ctx.commands` is composed, the package registers `/plan [message]`. The command selects plan mode first. A non-empty argument is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance; bare `/plan` only changes state.
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.
ACP is an adapter, not the owner of this vocabulary: it advertises the fixed wire ids `default` and `plan`, maps `session/set_mode` to the boolean service, and translates committed `plan/mode` events back to `current_mode_update`.
@@ -53,19 +53,19 @@ Inactive mode adds no tokens; active mode adds the configured section to every r
The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward.
### Optional command message
### Human command
#### What the model sees
`/plan` and its terminal result stay outside model history; a non-empty suffix becomes one trimmed user text block through `agent.steer()` after plan mode is selected.
`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one trimmed user text block through `agent.steer()` after plan mode is selected. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it.
#### Token effect
The suffix costs the same history tokens as submitting that text separately; a bare command adds none.
The optional message costs the same history tokens as submitting that text separately; bare `/plan` and `/plan off` add none. A narrated active exit adds the small retained switch notice.
#### KV Cache effect
The user block is append-only conversation growth, while entering plan mode also changes the earlier policy section.
The user block is append-only conversation growth. Entering or leaving plan mode changes the earlier policy section; a narrated exit notice is appended after the reusable request prefix.
### Exit tool schema and review exchange

View File

@@ -1,9 +1,10 @@
/**
* Plan mode is logged per-agent collaboration state: while active, a
* deployment-owned guidance section shapes each model request, and
* `exit_plan_mode` presents the completed plan for user review. It is
* independent of sandbox mode and approval policy; those enforcement axes do
* not read or write plan state.
* `exit_plan_mode` presents the completed plan for user review, while the
* `/plan off` command lets a user leave directly. Plan mode is independent of
* sandbox mode and approval policy; those enforcement axes do not read or
* write plan state.
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
@@ -210,13 +211,27 @@ export class PlanModeService extends Service {
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'plan',
description: 'Enter plan mode',
input: { hint: '[message]' },
description: 'Enter or leave plan mode',
input: { hint: '[off|message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
if (message === 'off') {
const state = this.get(agent)
this.set(agent, false)
if (state.active) {
return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
}
if (state.pending === true) {
return { kind: 'success', text: 'Plan mode entry cancelled.' }
}
return { kind: 'success', text: 'Plan mode is already inactive.' }
}
this.set(agent, true)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: 'Entering plan mode (applies from the next step).' }
return {
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
}
},
})
})

View File

@@ -546,14 +546,17 @@ describe('/plan', () => {
const plainSteer = vi.fn()
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
expect(ctx.commands.list(plainAgent)).toEqual([
{ name: 'plan', description: 'Enter plan mode', input: { hint: '[message]' } },
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
])
const signal = new AbortController().signal
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
expect(plain).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(plain).toEqual({
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
})
expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
expect(plainSteer).not.toHaveBeenCalled()
@@ -561,11 +564,50 @@ describe('/plan', () => {
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
expect(plan).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(plan).toEqual({
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
})
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
})
it('leaves active plan mode, cancels a pending entry, and treats inactive exit as idempotent', async () => {
const ctx = await setup()
await ctx.plugin(CommandService)
await new Promise(resolve => setImmediate(resolve))
const signal = new AbortController().signal
const inactive = await agentWithSession(ctx, 'inactive-plan-command')
expect(await ctx.commands.execute(inactive, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Plan mode is already inactive.' })
expect(ctx.planMode.get(inactive)).toEqual({ active: false })
const entering = await agentWithSession(ctx, 'entering-plan-command')
const enteringSteer = vi.fn()
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
await ctx.commands.execute(entering, '/plan', signal)
expect(await ctx.commands.execute(entering, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
expect(enteringSteer).not.toHaveBeenCalled()
await boundary(ctx, entering, 'turn/start')
expect(ctx.planMode.get(entering)).toEqual({ active: false })
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
const activeSteer = vi.fn()
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
expect(await ctx.commands.execute(active, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(ctx.planMode.get(active)).toEqual({ active: true, pending: false })
expect(await ctx.commands.execute(active, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(activeSteer).not.toHaveBeenCalled()
await boundary(ctx, active, 'turn/start')
expect(ctx.planMode.get(active)).toEqual({ active: false })
})
it('removes the contributed command when the plan-mode plugin is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -4,9 +4,11 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
## Plugin (`pty-local`)
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
## Model Experience

View File

@@ -30,10 +30,12 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {

View File

@@ -7,6 +7,9 @@
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -20,10 +23,37 @@ export type { Config as PtyLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'pty-local'
/** Required services: registry plus the one shared confinement policy. */
/** Required services: PTY registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
interface SandboxModeFenceState {
pty: Context['pty']
sandboxPolicy: Context['sandboxPolicy']
}
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
const existing = sandboxModeFences.get(owner)
if (existing !== undefined) {
existing.pty = ctx.pty
existing.sandboxPolicy = ctx.sandboxPolicy
return
}
const state: SandboxModeFenceState = { pty: ctx.pty, sandboxPolicy: ctx.sandboxPolicy }
sandboxModeFences.set(owner, state)
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (session !== owner.session || event.type !== 'sandbox/mode') return
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
throw new Error(
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
)
}, { global: true })
}
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
@@ -73,7 +103,8 @@ export class LocalPtyBackend implements PtyBackend {
}
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
spec.signal?.throwIfAborted()
ensureSandboxModeFence(this.ctx, spec.owner)
const argv = spawnArgv(this.ctx, this.config, spec)
const file = argv[0]
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
@@ -93,7 +124,7 @@ export class LocalPtyBackend implements PtyBackend {
try {
await session.close('PTY startup failed')
} catch (closeError: unknown) {
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
throw new PtyBackendCleanupError(error, closeError)
}
throw error
}

View File

@@ -16,6 +16,7 @@ export interface ProcessInspector {
isStdinWaiting(pgid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
/** Return whether the exact identity remains a non-quiescent process. */
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: PtySignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
@@ -49,6 +50,7 @@ interface ProcStat {
parentPid: number
pgrp: number
session: number
state: string
tpgid: number
started: string
}
@@ -64,13 +66,15 @@ export function parseProcStat(text: string): ProcStat | undefined {
if (open <= 0 || close <= open) return undefined
const pid = Number(text.slice(0, open).trim())
const rest = text.slice(close + 2).trim().split(/\s+/)
const state = rest[0] || ''
const parentPid = Number(rest[1])
const pgrp = Number(rest[2])
const session = Number(rest[3])
const tpgid = Number(rest[5])
const started = rest[19]
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger) || started === undefined) return undefined
return { pid, parentPid, pgrp, session, tpgid, started }
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|| state.length !== 1 || started === undefined) return undefined
return { pid, parentPid, pgrp, session, state, tpgid, started }
}
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
@@ -269,7 +273,8 @@ class LinuxProcessInspector extends PosixProcessInspector {
}
isAlive(identity: ProcessIdentity): boolean {
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
const stat = readLinuxStat(this.internals, identity.pid)
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
}
}

View File

@@ -9,6 +9,8 @@ export const PROMPT_MARKER_PREFIX = '133;D;'
export interface SanitizedChunk {
text: string
prompt: boolean
/** Present when printable text followed the latest owned prompt marker. */
promptText?: true
}
/**
@@ -20,6 +22,8 @@ export class TerminalSanitizer {
private pending = ''
private discardMode: 'osc' | 'csi' | undefined
private discardOscEscape = false
private trailingCarriageReturn = false
private awaitingPromptText = false
constructor(private readonly maxPendingBytes: number) {}
@@ -32,15 +36,24 @@ export class TerminalSanitizer {
this.pending += this.discardPrefix(chunk)
let text = ''
let prompt = false
let promptText = false
let index = 0
const appendText = (value: string): boolean => {
text += value
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
this.awaitingPromptText = false
return true
}
return false
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
text += this.pending.slice(index)
promptText = appendText(this.pending.slice(index)) || promptText
index = this.pending.length
break
}
text += this.pending.slice(index, escape)
promptText = appendText(this.pending.slice(index, escape)) || promptText
if (escape + 1 >= this.pending.length) {
index = escape
break
@@ -59,7 +72,11 @@ export class TerminalSanitizer {
}
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
const content = this.pending.slice(escape + 2, end - terminatorBytes)
if (content.startsWith(PROMPT_MARKER_PREFIX)) prompt = true
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
prompt = true
promptText = false
this.awaitingPromptText = true
}
index = end
continue
}
@@ -82,7 +99,7 @@ export class TerminalSanitizer {
}
this.pending = this.pending.slice(index)
this.enforcePendingBound()
return { text: normalizeTerminalText(text), prompt }
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
}
/**
@@ -94,7 +111,21 @@ export class TerminalSanitizer {
this.pending = ''
this.discardMode = undefined
this.discardOscEscape = false
return normalizeTerminalText(text)
this.awaitingPromptText = false
const normalized = this.normalizeText(text)
if (!this.trailingCarriageReturn) return normalized
this.trailingCarriageReturn = false
return `${normalized}\n`
}
private normalizeText(text: string): string {
let complete = this.trailingCarriageReturn ? `\r${text}` : text
this.trailingCarriageReturn = false
if (complete.endsWith('\r')) {
complete = complete.slice(0, -1)
this.trailingCarriageReturn = true
}
return normalizeTerminalText(complete)
}
private enforcePendingBound(): void {

View File

@@ -17,7 +17,7 @@ import type {
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import type { ProcessInspector } from './process-inspector.ts'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
import { TerminalSanitizer } from './sanitize.ts'
function delay(ms: number): Promise<void> {
@@ -148,9 +148,11 @@ export class LocalPtySession implements PtyBackendSession {
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private promptTextSeen = false
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
constructor(
@@ -184,27 +186,29 @@ export class LocalPtySession implements PtyBackendSession {
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
this.motd = result.viewport
} catch (error: unknown) {
signal?.throwIfAborted()
throw error
} finally {
this.initializing = false
}
}
startSend(request: PtySendRequest): PtySendOperation {
if (this.closePromise !== undefined) throw new Error('PTY session is closing')
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) throw new Error('PTY session already has an active send')
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => {
try {
this.terminal.write('\x03')
} catch (error: unknown) {
operation.fail(error)
}
})
const operation = new LocalSendOperation(
this.config.maxReadBytes,
Date.now(),
() => { this.interrupt(operation) },
)
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
if (request.signal !== undefined) {
const onAbort = (): void => { operation.cancel() }
@@ -267,8 +271,15 @@ export class LocalPtySession implements PtyBackendSession {
}
close(reason: string): Promise<void> {
this.closePromise ??= this.closeOnce(reason)
return this.closePromise
this.closing = true
if (this.closePromise !== undefined) return this.closePromise
const closing = this.closeOnce(reason).catch((error: unknown) => {
this.closePromise = undefined
this.failActive(error)
throw error
})
this.closePromise = closing
return closing
}
private onData(data: string): void {
@@ -279,8 +290,11 @@ export class LocalPtySession implements PtyBackendSession {
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
}
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
}
@@ -297,7 +311,7 @@ export class LocalPtySession implements PtyBackendSession {
this.settleActive('session_exit')
return
}
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
}
@@ -337,58 +351,113 @@ export class LocalPtySession implements PtyBackendSession {
this.active = undefined
}
private failActive(error: unknown): void {
const operation = this.active
if (operation === undefined) return
this.clearActive()
operation.fail(error)
}
private interrupt(operation: LocalSendOperation): void {
if (this.active !== operation) return
try {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
this.inspector.signalGroup(pgid, 'SIGINT')
} catch (error: unknown) {
this.failActive(error)
}
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
}
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = this.survivors(members)
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
survivors = this.survivors(members)
}
return survivors
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()
for (const group of groups) {
for (const member of group) {
const key = JSON.stringify([member.pid, member.started])
if (seen.has(key)) continue
seen.add(key)
members.push(member)
}
}
return members
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
const captured = this.descendants()
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForExit(captured)
// A TERM-handling descendant may have forked while winding down. Rescan
// while the shell can still reap every member, then kill both the fresh
// tree and captured survivors that were reparented out of that tree.
const members = this.unionMembers(capturedSurvivors, this.descendants())
this.signalMembers(members, 'SIGKILL')
const survivors = await this.waitForExit(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
}
private async stopShell(): Promise<void> {
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExitedDuringTerm) {
// The exit notification remains authoritative.
}
if (this.statusValue.kind === 'running') {
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyExitedDuringKill) {
// The exit notification remains authoritative.
}
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
}
}
private async closeOnce(reason: string): Promise<void> {
this.dataDisposable.dispose()
// Stop readiness polling but retain the active operation: teardown settles
// it as session_exit below, so an in-flight send is never mis-settled as
// stdin_read/inferred_idle/timeout during the grace period.
this.stopPolling()
const members = this.inspector.processTree(this.pid)
for (const member of members) {
try {
this.inspector.signalProcess(member, 'SIGTERM')
} catch (_alreadyExitedDuringTerm) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExited) {
// onExit or identity checks below remain authoritative.
}
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = members.filter(member => this.inspector.isAlive(member))
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, this.config.disposeGraceMs))
survivors = members.filter(member => this.inspector.isAlive(member))
}
for (const survivor of survivors) {
try {
this.inspector.signalProcess(survivor, 'SIGKILL')
} catch (_alreadyExitedDuringKill) {
// Final identity check below decides success.
}
}
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyKilled) {
// The root may already have delivered onExit.
}
const killDeadline = Date.now() + this.config.disposeGraceMs
survivors = members.filter(member => this.inspector.isAlive(member))
while (survivors.length > 0 && Date.now() < killDeadline) {
await delay(Math.min(25, this.config.disposeGraceMs))
survivors = members.filter(member => this.inspector.isAlive(member))
}
const exitWaitMs = Math.max(0, killDeadline - Date.now())
await Promise.race([this.exitPromise.promise, delay(exitWaitMs)])
survivors = members.filter(member => this.inspector.isAlive(member))
this.settleActive('session_exit')
this.exitDisposable.dispose()
const survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
await this.stopShell()
this.settleActive('session_exit')
this.exitDisposable.dispose()
}
}

View File

@@ -2,12 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
import type { IPty, IPtyForkOptions } from 'node-pty'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
@@ -62,6 +62,30 @@ function spec(owner: Agent, signal?: AbortSignal) {
}
}
function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolve()): LocalPtySession {
return {
motd: '',
initialize,
startSend: () => { throw new Error('unused') },
read: () => { throw new Error('unused') },
signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }),
status: () => ({ kind: 'running' as const }),
close: () => Promise.resolve(),
} as unknown as LocalPtySession
}
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy'], (providerCtx) => {
providerCtx.pty.registerBackend(new LocalPtyBackend(
providerCtx,
{ ...config(), backendType: 'stub' },
inspector,
(() => ({})) as never,
createSession,
))
})
}
describe('LocalPtyBackend startup rollback', () => {
it('rejects pre-aborted setup and empty sandbox argv', async () => {
const ctx = new Context()
@@ -69,8 +93,9 @@ describe('LocalPtyBackend startup rollback', () => {
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
const backend = new LocalPtyBackend(ctx, config(), inspector)
const controller = new AbortController()
controller.abort()
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toThrow('spawn aborted')
const abortReason = new Error('spawn aborted')
controller.abort(abortReason)
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
})
@@ -86,12 +111,18 @@ describe('LocalPtyBackend startup rollback', () => {
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
const startupFailure = new Error('startup failed')
const cleanupFailure = new Error('cleanup failed')
const doublyFailed = {
initialize: () => Promise.reject(new Error('startup failed')),
close: () => Promise.reject(new Error('cleanup failed')),
initialize: () => Promise.reject(startupFailure),
close: () => Promise.reject(cleanupFailure),
} as unknown as LocalPtySession
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toThrow('startup and cleanup both failed')
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
name: 'PtyBackendCleanupError',
spawnError: startupFailure,
cleanupError: cleanupFailure,
} satisfies Partial<PtyBackendCleanupError>))
})
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
@@ -175,6 +206,7 @@ describe('pty-local plugin shape', () => {
it('validates config and registers the configured backend', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
@@ -183,4 +215,90 @@ describe('pty-local plugin shape', () => {
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
})
it('ignores unrelated session events and mode changes without a live owner', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('unowned-mode'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
})
it('keeps the owner-lifetime sandbox fence after the local provider unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const session = ctx.sessions.create(SessionId('mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
const created = await ctx.pty.spawn(owner, { type: 'stub' })
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
}).not.toThrow()
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
await providerFiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
expect(() => { setSandboxMode(session, 'read-only') }).toThrow(
'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first',
)
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1)
const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
const second = await ctx.pty.spawn(owner, { type: 'stub' })
await replacementFiber.dispose()
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
await ctx.pty.kill(owner, created.sessionId)
await ctx.pty.kill(owner, second.sessionId)
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2)
})
it('also fences sandbox-mode changes across unpublished PTY creation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()
await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise))
const spawning = ctx.pty.spawn(owner, { type: 'stub' })
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
gate.resolve(undefined)
const created = await spawning
await ctx.pty.kill(owner, created.sessionId)
expect(ctx.pty.hasOwnerActivity(owner)).toBe(false)
})
})

View File

@@ -7,6 +7,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService from '@deepseek-ai/dsh-pty'
import type { PtySendOperation } from '@deepseek-ai/dsh-pty'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
@@ -62,6 +63,16 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
const deadline = Date.now() + 2_000
let output = ''
while (!output.includes(expected) && Date.now() < deadline) {
output += operation.readOutput().delta
if (!output.includes(expected)) await new Promise(resolve => setTimeout(resolve, 10))
}
expect(output).toContain(expected)
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
@@ -119,4 +130,26 @@ describe('pty-local real shell', () => {
await ctx.pty.kill(agent, created.sessionId)
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const foreground = ctx.pty.startSend(agent, created.sessionId, {
text: 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_READY", flush=True); time.sleep(60)\'',
submit: true,
signal: controller.signal,
})
await waitForOutput(foreground, 'RAW_READY')
controller.abort()
const result = await foreground.done
expect(result.waitReason).toBe('stdin_read')
const after = await ctx.pty.startSend(agent, created.sessionId, {
text: 'echo AFTER_SIGINT',
submit: true,
}).done
expect(after.viewport).toContain('AFTER_SIGINT')
expect(after.waitReason).toBe('stdin_read')
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
})

View File

@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1): string {
const rest = ['S', String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
while (rest.length < 19) rest.push('0')
rest.push(started)
return `${pid} (command with space) ${rest.join(' ')}`
@@ -65,8 +65,10 @@ function fakeInternals() {
describe('Linux process inspector', () => {
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
expect(parseProcStat('bad')).toBeUndefined()
expect(parseProcStat('1 () ')).toBeUndefined()
expect(parseProcStat('1 () S')).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' })
const fake = fakeInternals()
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
@@ -90,6 +92,10 @@ describe('Linux process inspector', () => {
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
it('detects read, select, poll, and epoll waits across non-leader threads', () => {

View File

@@ -7,7 +7,7 @@ describe('TerminalSanitizer', () => {
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptText: true })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
@@ -25,6 +25,20 @@ describe('TerminalSanitizer', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
it('carries a trailing carriage return across data chunks and flushes standalone CR', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false })
expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false })
expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false })
expect(sanitizer.flush()).toBe('\n')
})
it('reports printable prompt text that follows a marker in a later chunk', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptText: true })
})
it('bounds and discards unterminated control sequences through their terminators', () => {
const oscBel = new TerminalSanitizer(8)
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })

View File

@@ -15,6 +15,7 @@ class FakeTerminal {
kills: string[] = []
throwWrite = false
throwKill = false
autoExitOnKill = true
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
@@ -44,7 +45,7 @@ class FakeTerminal {
kill(signal?: string): void {
if (this.throwKill) throw new Error('kill failed')
this.kills.push(signal ?? 'SIGHUP')
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
resize() {}
@@ -148,7 +149,7 @@ describe('LocalPtySession readiness and output', () => {
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
@@ -159,7 +160,8 @@ describe('LocalPtySession readiness and output', () => {
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
controller.abort()
expect(terminal.writes.at(-1)).toBe('\x03')
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
expect(terminal.writes).not.toContain('\x03')
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
@@ -196,11 +198,13 @@ describe('LocalPtySession readiness and output', () => {
operationInternal.append('')
const sessionInternal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
interrupt(operation: PtySendOperation): void
statusValue: PtySessionStatus
appendOutput(text: string): void
}
sessionInternal.appendOutput('')
sessionInternal.pollReadiness({} as PtySendOperation)
sessionInternal.interrupt({} as PtySendOperation)
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
sessionInternal.pollReadiness(operation)
await operation.done
@@ -212,12 +216,23 @@ describe('LocalPtySession readiness and output', () => {
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const cancelTerminal = new FakeTerminal()
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
const cancelInspector = new FakeInspector()
const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config())
await initialize(cancel, cancelTerminal)
const cancellable = cancel.startSend({ text: '', submit: false })
cancelTerminal.throwWrite = true
cancelInspector.throwGroup = true
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('write failed')
await expect(cancellable.done).rejects.toThrow('group failed')
expect(cancellable.cancel()).toBe(false)
const missingGroupTerminal = new FakeTerminal()
const missingGroupInspector = new FakeInspector()
const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config())
await initialize(missingGroup, missingGroupTerminal)
missingGroupInspector.pgid = undefined
const unresolved = missingGroup.startSend({ text: '', submit: false })
expect(unresolved.cancel()).toBe(true)
await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
})
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
@@ -239,6 +254,38 @@ describe('LocalPtySession readiness and output', () => {
await timedOut
})
it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.pgid = undefined
const session = new LocalPtySession(terminal.asPty(), inspector, config())
const controller = new AbortController()
const reason = new Error('startup cancelled')
const initializing = session.initialize(controller.signal)
const rejected = expect(initializing).rejects.toBe(reason)
controller.abort(reason)
await rejected
})
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
let settled = false
const initializing = session.initialize().then(() => { settled = true })
terminal.emitData('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(20)
expect(settled).toBe(false)
terminal.emitData('dsh> ')
await vi.advanceTimersByTimeAsync(10)
await initializing
expect(session.motd).toBe('dsh> ')
})
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
@@ -328,14 +375,15 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
// readiness poll would otherwise mis-settle this as stdin_read once close
// begins, so teardown must stop polling before its grace period.
terminal.emitData('\x1b]133;D;0\x07dsh> ')
terminal.throwKill = true
terminal.autoExitOnKill = false
const closing = session.close('mid-send')
await vi.advanceTimersByTimeAsync(60)
await vi.advanceTimersByTimeAsync(20)
terminal.emitExit(0, 15)
expect((await operation.done).waitReason).toBe('session_exit')
await closing
})
it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => {
it('keeps the shell alive until SIGKILL recipients leave the process table', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
@@ -348,11 +396,81 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
const closing = session.close('test').then(() => { settled = true })
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(terminal.kills).toEqual([])
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
await closing
expect(terminal.kills).toEqual(['SIGTERM'])
expect(settled).toBe(true)
})
it('rescans for descendants forked during TERM before stopping the shell', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
let reads = 0
inspector.processTree = () => {
reads += 1
if (reads === 1) {
inspector.alive.add(124)
return [{ pid: 124, started: 'first' }]
}
if (reads === 2) {
inspector.alive.add(125)
return [{ pid: 125, started: 'late' }]
}
return []
}
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await session.close('test')
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
expect(terminal.kills).toEqual(['SIGTERM'])
})
it('retains captured survivors that are reparented out of the teardown rescan', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const captured = { pid: 124, started: 'captured' }
let reads = 0
inspector.alive.add(captured.pid)
inspector.processTree = () => reads++ === 0 ? [captured] : []
inspector.signalProcess = (identity, signal) => {
inspector.processes.push([identity.pid, signal])
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
}
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
const closing = session.close('test')
await vi.advanceTimersByTimeAsync(25)
await closing
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
expect(terminal.kills).toEqual(['SIGTERM'])
})
it('allows teardown to retry after a descendant-survivor failure', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.removeOnSignal = false
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 }))
const first = session.close('first')
const rejected = expect(first).rejects.toThrow('surviving pids: 124')
await vi.advanceTimersByTimeAsync(25)
await rejected
expect(terminal.kills).toEqual([])
inspector.alive.delete(124)
const second = session.close('retry')
expect(second).not.toBe(first)
await second
expect(terminal.kills).toEqual(['SIGTERM'])
})
})

Some files were not shown because too many files have changed in this diff Show More