Merge origin/master into feat/close-todo

Keep master's session-projection carrier for todos, and fold turn/start
clearance into the tool-todo projection unit (plus TUI/fixture mirrors).
This commit is contained in:
07akioni
2026-07-28 19:43:56 +08:00
227 changed files with 5475 additions and 789 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 8fd49723c4b0534eebd2e590c647caadd63136a7
README.zh.md: e2ad8ad80d002d769cf6a2c9f4f09c37ce960935
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391
README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd

View File

@@ -8,7 +8,7 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
@@ -37,5 +37,4 @@ Registry metadata, command input, and direct output never enter a model request
## Known Limitations and Deferred Work
- **Only unstructured text input** — forms, completion schemas, and typed arguments remain command-owned parsing concerns.
- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.

View File

@@ -8,7 +8,7 @@
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer注册或移除命令时系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令;语法无效或名称未知时返回 `undefined`
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
@@ -37,5 +37,4 @@
## 已知限制与延期工作
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
- **不持久化命令输出**:适配器会实时显示结果,但通用注册表不会将结果加入会话日志,也不会在重新连接后重建结果。
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -28,12 +33,15 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -0,0 +1,29 @@
/**
* dsh-commands' owned branded id: command lifecycle pairing across the
* session log, the wire admission response, and client-side flow pairing.
*
* The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; this module
* is a pure type/constructor outlet (no cordis imports, no module
* augmentation) so wire and client programs can name the brand without
* loading the host plugin's Context merges — the `dsh-llm/brand` shape.
*
* @module @deepseek-ai/dsh-commands/brand
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Pairs one command execution's `command/run`/`command/done` lifecycle
* records with each other and with the `command.execute` admission response.
* Minted by the executor, monotonic per service instance.
*/
export type CommandId = Branded<'CommandId'>
/**
* Brand a string as a {@link CommandId}.
* @param id - the executor-minted pairing id.
* @returns the same string, branded; no validation is performed.
*/
export function CommandId(id: string): CommandId {
return id as CommandId
}

View File

@@ -7,11 +7,28 @@ import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
import { CommandId } from './brand.ts'
export { CommandId } from './brand.ts'
export const name = 'commands'
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
/**
* Producer record for one command invocation (the `command/run` event's
* provenance slot). Merge-extensible sum type mirroring `MessageSourceMap`'s
* shape; minimal today because every executor caller is a human-facing UI
* surface dispatching a human-typed line, so the sole variant is `user`.
*/
export interface CommandSourceMap {
user: { kind: 'user' }
}
/** The union over {@link CommandSourceMap} — who issued a command line. */
export type CommandSource = CommandSourceMap[keyof CommandSourceMap]
/** Immutable metadata for a command's optional unstructured input. */
export interface CommandInputDescriptor {
/** Placeholder shown before the user supplies free-form input. */
@@ -33,6 +50,19 @@ export type CommandResult =
| { readonly kind: 'success'; readonly text?: string }
| { readonly kind: 'error'; readonly text: string }
/**
* One settled command execution: the handler's normalized result plus the
* lifecycle pairing id minted for its `command/run`/`command/done` records,
* so a dispatching surface can correlate the RPC-level acknowledgment with
* the flow node those events produce.
*/
export interface CommandExecution {
/** Pairing id carried by this execution's lifecycle events. */
readonly commandId: CommandId
/** The handler's normalized outcome. */
readonly result: CommandResult
}
/** Plugin-owned command registration. */
export interface CommandDefinition {
/** Lowercase command name without the leading slash. */
@@ -88,6 +118,27 @@ class CommandLayer implements ScopeLayer {
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* A resolved slash command entered its handler. Log-only (never model
* surface); paired with `command/done` by `commandId`, mirroring the
* `tool/call`↔`tool/result` pairing. The payload is structured — `name`
* and `args` are `parseCommand`'s own split (name and verbatim rawInput,
* separator whitespace included), so a consumer (a projection unit
* folding its own command records, a rich command card) never re-parses
* a line.
*/
'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource }
/**
* The paired command settled. `kind`/`text` carry the handler's verbatim
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
* rendered failure); presentation stays client-computed at render time.
*/
'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string }
}
}
declare module 'cordis' {
interface Context {
commands: CommandService
@@ -230,6 +281,11 @@ export class CommandService extends Service {
() => { this.notifyChange() },
)
/** Monotonic per-instance counter behind {@link mintCommandId}. */
private commandSeq = 0
/** Instance token keeping minted ids unique across process restarts over one resumed log. */
private readonly instanceToken = crypto.randomUUID().slice(0, 8)
constructor(ctx: Context) {
super(ctx, 'commands')
}
@@ -272,24 +328,82 @@ export class CommandService extends Service {
/**
* Parse and execute a known command without sending it to the model.
*
* A resolved command's lifecycle is logged: `command/run` is appended
* before the handler is invoked and `command/done` after settlement (a
* thrown or aborted handler settles as `kind: 'error'`). Both are direct
* log-only appends — no turn wraps them, and persistence drains them at
* ordinary checkpoints. Admission misses (syntax or unknown name) log
* nothing — they never entered a handler. A `command/run` append failure
* fails the execution loud; a `command/done` append failure on the
* handler-failure path is contained so the handler's own error stays the
* reported failure.
*
* @param agent - exact receiving agent.
* @param line - complete slash-command line.
* @param signal - cancellation signal owned by the UI request.
* @returns a detached result, or `undefined` when syntax or name does not resolve.
* @returns the settled execution (result + lifecycle pairing id), or
* `undefined` when syntax or name does not resolve.
*/
async execute(
agent: Agent,
line: string,
signal: AbortSignal,
): Promise<CommandResult | undefined> {
): Promise<CommandExecution | undefined> {
const parsed = parseCommand(line)
if (parsed === undefined) return undefined
const command = this.view(agent).get(parsed.name)
if (command === undefined) return undefined
if (signal.aborted) throw abortError(signal)
const commandId = this.mintCommandId()
this.appendLifecycle(agent.session, 'command/run', {
commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' },
})
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
const output = command.definition.handler(invocation)
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
let result: CommandResult
try {
const output = command.definition.handler(invocation)
result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
} catch (error: unknown) {
try {
this.appendLifecycle(agent.session, 'command/done', {
commandId, kind: 'error',
text: error instanceof Error ? error.message : renderThrown(error),
})
} catch (appendError: unknown) {
this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`)
}
throw error
}
this.appendLifecycle(agent.session, 'command/done', {
commandId, kind: result.kind,
...result.text === undefined ? {} : { text: result.text },
})
return Object.freeze({ commandId, result })
}
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
private mintCommandId(): CommandId {
this.commandSeq += 1
return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`)
}
/**
* Append one log-only lifecycle event directly: no turn is opened for it and
* no flush is forced — persistence observes the eager `session/event` path
* and drains at ordinary checkpoints and teardown, like every other
* standalone plugin event.
*/
private appendLifecycle<T extends 'command/run' | 'command/done'>(
session: Session,
type: T,
data: SessionEventMap[T],
): SessionEvent<T> {
// Both admitted types are log-only (non-surface), but TypeScript does not
// reduce Session.append's conditional rest parameter through a generic
// type parameter. Preserve the proven two-argument call shape.
const appendLogOnly = session.append.bind(session) as (eventType: T, eventData: SessionEventMap[T]) => SessionEvent<T>
return appendLogOnly(type, data)
}
/** Resolve global definitions followed by exact scoped shadows. */

View File

@@ -1,11 +1,12 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`.
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`:
* command lifecycle events pair by commandId within one session log.
* @module @deepseek-ai/dsh-commands/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
@@ -14,11 +15,36 @@ export const name = 'commands-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: registry notifications intentionally hide mutation details and contain
* observers, so list/find self-comparisons would duplicate implementation rather than detect drift.
*/
const install: InvariantInstaller = () => {}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install pairing validation over loaded logs and newly appended lifecycle events. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
// Install-scoped so a dispose/re-register cycle re-sweeps from a clean slate.
const runIds = new WeakMap<Session, Set<string>>()
const validateEvent = (session: Session, event: SessionEvent): void => {
if (event.type === 'command/run') {
const ids = runIds.get(session) ?? new Set<string>()
if (ids.has(event.data.commandId)) {
fail(`command/run repeats commandId ${JSON.stringify(event.data.commandId)}`)
}
ids.add(event.data.commandId)
runIds.set(session, ids)
return
}
if (event.type !== 'command/done') return
if (runIds.get(session)?.has(event.data.commandId) !== true) {
fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
}
}
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(session, event)
}
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
validateEvent(session, event)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register this package's invariant companion.
@@ -27,4 +53,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
function command(name: string, text = `ran:${name}`): CommandDefinition {
@@ -16,18 +16,27 @@ function command(name: string, text = `ran:${name}`): CommandDefinition {
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
return ctx
}
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
/** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
const agent = { id: name as SessionId } as Agent
const session = ctx.sessions.create(SessionId(name))
const agent = { id: session.id, session } as Agent
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
return { scope, agent }
}
/** The lifecycle slice of one agent's log (boundary markers stripped). */
function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
return agent.session.events
.filter(event => event.type === 'command/run' || event.type === 'command/done')
.map(event => ({ type: event.type, data: event.data }))
}
describe('parseCommand()', () => {
it.each([
['/goal', { name: 'goal', rawInput: '' }],
@@ -87,11 +96,11 @@ describe('CommandService', () => {
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result)
.toEqual({ kind: 'success', text: 'scoped' })
await scope.dispose()
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
})
it('removes a registration when its contributing plugin fiber is disposed', async () => {
@@ -167,10 +176,12 @@ describe('CommandService', () => {
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
const controller = new AbortController()
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
expect(result).toEqual({ kind: 'success', text: 'ok' })
expect(Object.isFrozen(result)).toBe(true)
expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
expect(execution?.commandId).toBeTruthy()
expect(Object.isFrozen(execution)).toBe(true)
expect(Object.isFrozen(execution?.result)).toBe(true)
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
agent,
rawInput: ' untouched ',
@@ -262,9 +273,9 @@ describe('CommandService', () => {
description: 'Denied',
handler: () => ({ kind: 'error', text: 'not now' }),
})
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
expect(result).toEqual({ kind: 'error', text: 'not now' })
expect(Object.isFrozen(result)).toBe(true)
const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
expect(Object.isFrozen(execution?.result)).toBe(true)
ctx.commands.register({
name: 'silent',
@@ -272,8 +283,8 @@ describe('CommandService', () => {
handler: () => ({ kind: 'success' }),
})
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
expect(silent).toEqual({ kind: 'success' })
expect(Object.isFrozen(silent)).toBe(true)
expect(silent?.result).toEqual({ kind: 'success' })
expect(Object.isFrozen(silent?.result)).toBe(true)
})
it.each([
@@ -286,6 +297,112 @@ describe('CommandService', () => {
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
})
it('logs a paired command/run + command/done around a successful handler', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('deploy', 'deployed'))
const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
const lifecycle = lifecycleOf(agent)
expect(lifecycle).toMatchObject([
{ type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
])
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
expect(ids[0]).toBeTruthy()
expect(ids[0]).toBe(ids[1])
// The execution's pairing id is the logged one (RPC-level correlation).
expect(execution?.commandId).toBe(ids[0])
// Direct log-only appends: no turn is opened for the pair on an idle log.
expect(agent.session.events.map(event => event.type)).toEqual([
'command/run', 'command/done',
])
})
it('mints distinct monotonic commandIds across executions', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('first'))
ctx.commands.register(command('second'))
await ctx.commands.execute(agent, '/first', new AbortController().signal)
await ctx.commands.execute(agent, '/second', new AbortController().signal)
const ids = lifecycleOf(agent)
.filter(event => event.type === 'command/run')
.map(event => (event.data as { commandId: string }).commandId)
expect(new Set(ids).size).toBe(2)
})
it('logs command/done kind error for an expected error result', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
await ctx.commands.execute(agent, '/denied', new AbortController().signal)
expect(lifecycleOf(agent)).toMatchObject([
{ type: 'command/run', data: { name: 'denied' } },
{ type: 'command/done', data: { kind: 'error', text: 'not now' } },
])
})
it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register({
name: 'boom',
description: 'Throw',
handler: () => { throw new Error('handler exploded') },
})
await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
.rejects.toThrow('handler exploded')
expect(lifecycleOf(agent)).toMatchObject([
{ type: 'command/run', data: { name: 'boom' } },
{ type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
])
})
it('logs command/done kind error when the signal aborts a hanging handler', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register({
name: 'hang',
description: 'Hang',
handler: () => new Promise(() => undefined),
})
const controller = new AbortController()
const pending = ctx.commands.execute(agent, '/hang', controller.signal)
// The run append must land before the abort so the pair stays complete.
await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
controller.abort('operator cancelled command')
await expect(pending).rejects.toThrow('operator cancelled command')
await vi.waitFor(() => {
expect(lifecycleOf(agent)).toMatchObject([
{ type: 'command/run', data: { name: 'hang' } },
{ type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
])
})
})
it('logs nothing for admission misses (syntax or unknown name)', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('real'))
const signal = new AbortController().signal
await ctx.commands.execute(agent, 'not a command', signal)
await ctx.commands.execute(agent, '/missing', signal)
expect(agent.session.events).toEqual([])
})
it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
ctx.commands.register(command('mid'))
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
expect(agent.session.events.map(event => event.type)).toEqual([
'turn/start', 'command/run', 'command/done',
])
})
it.each([
[undefined, /CommandResult/],
[null, /CommandResult/],

View File

@@ -20,6 +20,12 @@
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: 48eb6106fe4b015f114b264a312249a92128266f
README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
README.zh.md: 63615654769bf4ed7a69c09dc818af034c3a3c3c

View File

@@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
## Model Experience

View File

@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验

View File

@@ -56,6 +56,7 @@ export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
private model = 'deepseek'
private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
@@ -113,9 +114,14 @@ export class HarnessSdkServer {
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
if (params.maxTokens !== undefined
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
throw new TypeError('initialize maxTokens must be a positive safe integer')
}
this.cwd = resolve(params.cwd)
this.provider = params.provider
this.model = params.model
this.maxTokens = params.maxTokens
if (!this.hasAdapterFor(this.provider)) {
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
@@ -231,7 +237,11 @@ export class HarnessSdkServer {
const handle = await this.ctx.agents.create({
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: { provider: this.provider, model: this.model },
agentOptions: {
provider: this.provider,
model: this.model,
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
},
})
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
this.sessions.set(sessionId, rec)

View File

@@ -122,6 +122,7 @@ describe('HarnessSdkServer', () => {
cwd: storageDir,
provider: 'deepseek',
model: 'dsagent-model',
maxTokens: 321,
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
@@ -131,8 +132,9 @@ describe('HarnessSdkServer', () => {
})
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
expect(body.model).toBe('dsagent-model')
expect(body.max_tokens).toBe(321)
expect(body.messages[0]?.role).toBe('system')
expect(body.messages.at(-1)?.role).toBe('user')
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
@@ -859,6 +861,27 @@ describe('HarnessSdkServer', () => {
}
})
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid initialize maxTokens %s at the wire boundary',
async (maxTokens) => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-max-tokens-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({
cwd: storageDir,
provider: 'deepseek',
model: 'model',
maxTokens,
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
},
)
it('classifies defensive finish states', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
const ctx = await makeHarness(storageDir)
@@ -973,15 +996,18 @@ describe('HarnessSdkServer', () => {
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>>
}
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
await server.getOrCreateSession('relative')
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
expect(create).toHaveBeenCalledWith(expect.objectContaining({
meta: { cwd: process.cwd() },
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
}))
await server.shutdown()
})

View File

@@ -1109,12 +1109,12 @@ export function createTuiChat(
const controller = new AbortController()
commandControllers.add(controller)
void ctx.commands.execute(agent, text, controller.signal).then(
(result) => {
(execution) => {
if (disposed) return
if (result === undefined) {
if (execution === undefined) {
appendNotice(`Unknown command: ${text}`, 'warning')
} else if (result.text !== undefined && result.text !== '') {
appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
} else if (execution.result.text !== undefined && execution.result.text !== '') {
appendNotice(execution.result.text, execution.result.kind === 'error' ? 'error' : 'info')
}
},
(error: unknown) => {

View File

@@ -48,7 +48,7 @@ buffer
17| "│ │"
style 0-0 dim
style 55-55 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim

View File

@@ -45,7 +45,7 @@ buffer
16| "│ │"
style 0-0 dim
style 81-81 dim
17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
17| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim

View File

@@ -1309,6 +1309,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
result.terminal.send('/clear')
result.terminal.send('\r')
await tick() // the executor logs command/run durably before the handler clears
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
await tick()
expect(result.terminal.output).toContain('answer after clear')
@@ -2139,7 +2140,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('hidden)')
expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
// 6 domain events + the /status invocation's own command/run (open turn: joined directly).
expect(result.terminal.output).toContain('running · 7 events · 1 turn · 1 step · 2 tool calls')
expect(result.terminal.output).toContain('1,250 input + 340 output')
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)')
@@ -2180,7 +2182,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('untitled')
expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
// The /status invocation's command/run lands directly on the empty log — no turn wraps it.
expect(result.terminal.output).toContain('idle · 1 event · 0 turns · 0 steps · 0 tool calls')
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
expect(result.terminal.output).toContain('7 used · capacity unknown')
expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC')
@@ -2232,8 +2235,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
for (const command of ['/clear', '/wat']) {
result.terminal.send(command)
result.terminal.send('\r')
await tick() // /clear's handler runs after the durable command/run append; keep it from wiping the next notice
}
await tick()
result.terminal.send('draft')
result.terminal.send('\x03')
result.terminal.send('\x04')