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:
@@ -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 |
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ConversationRoot({
|
||||
disabled={last}
|
||||
onClick={() => { open(s.id) }}
|
||||
>
|
||||
{s.title}
|
||||
{s.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
}])),
|
||||
|
||||
@@ -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) } : {}),
|
||||
}])),
|
||||
|
||||
20
packages/client/ui-question/README.md
Normal file
20
packages/client/ui-question/README.md
Normal 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.
|
||||
67
packages/client/ui-question/package.json
Normal file
67
packages/client/ui-question/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
297
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal file
297
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
packages/client/ui-question/src/client/contract/slots.ts
Normal file
77
packages/client/ui-question/src/client/contract/slots.ts
Normal 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 }
|
||||
36
packages/client/ui-question/src/client/index.ts
Normal file
36
packages/client/ui-question/src/client/index.ts
Normal 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',
|
||||
)
|
||||
}
|
||||
4
packages/client/ui-question/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-question/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Readonly<Record<string, string>>
|
||||
export default classes
|
||||
}
|
||||
17
packages/client/ui-question/src/index.ts
Normal file
17
packages/client/ui-question/src/index.ts
Normal 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)
|
||||
}
|
||||
31
packages/client/ui-question/src/invariant.ts
Normal file
31
packages/client/ui-question/src/invariant.ts
Normal 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 */
|
||||
64
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal file
64
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
28
packages/client/ui-question/tests/node-plugin.spec.ts
Normal file
28
packages/client/ui-question/tests/node-plugin.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
265
packages/client/ui-question/tests/question-composer.spec.tsx
Normal file
265
packages/client/ui-question/tests/question-composer.spec.tsx
Normal 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('选择信号')
|
||||
})
|
||||
})
|
||||
36
packages/client/ui-question/tsconfig.json
Normal file
36
packages/client/ui-question/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-question/tsdown.config.ts
Normal file
3
packages/client/ui-question/tsdown.config.ts
Normal 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'])
|
||||
@@ -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.
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
22
packages/client/web/src/DocumentTitle.tsx
Normal file
22
packages/client/web/src/DocumentTitle.tsx
Normal 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
|
||||
}
|
||||
@@ -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', {})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
28
packages/client/web/tests/document-title.spec.tsx
Normal file
28
packages/client/web/tests/document-title.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query-sqlite"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query-sqlite"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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({}) }),
|
||||
|
||||
@@ -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': {}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:^"
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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({}))
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 } : {},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user