feat(web): merge compact status and summary cards

This commit is contained in:
Yichen Jiang
2026-08-08 14:11:17 +08:00
parent cc407a2672
commit 1db327ea6c
47 changed files with 725 additions and 121 deletions

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/commands/README.md
README.md: 3105ae1a866e03f3c8f621bfe588df15ee38957e
README.zh.md: 704a2daefb65fde12ca85d1c9051ad762c5ccc70
README.md: 1709bdcdce4e43d98cfea5ff3972ab95bfd3c33b
README.zh.md: 569f2aa8293793b26d63ee16e3ea7600e04a8397

View File

@@ -8,11 +8,11 @@ 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, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. 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 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, the issuing `CommandSource`, and `args` unless `recordInput` is false) 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.
`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, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; 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.
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
## Composition

View File

@@ -8,11 +8,11 @@
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true若载荷由命令的权威领域事件持有该命令会将 `recordInput` 设为 false`command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent智能体的定义该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop智能体循环依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer注册或移除命令时系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args``recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args``recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。
`parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。
处理器返回 `success``error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
处理器返回 `success``error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
## 组合

View File

@@ -47,7 +47,12 @@ export interface CommandInvocation {
/** Expected command outcome rendered directly by the dispatching UI. */
export type CommandResult =
| { readonly kind: 'success'; readonly text?: string }
| {
readonly kind: 'success'
readonly text?: string
/** Earlier authoritative domain event that owns a richer presentation. */
readonly sourceEventSeq?: number
}
| { readonly kind: 'error'; readonly text: string }
/**
@@ -140,9 +145,15 @@ declare module '@deepseek-ai/dsh-session' {
/**
* 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.
* rendered failure). A successful command may identify the earlier
* authoritative domain event for a richer client-computed presentation.
*/
'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string }
'command/done': {
commandId: CommandId
kind: 'success' | 'error'
text?: string
sourceEventSeq?: number
}
}
}
@@ -262,12 +273,20 @@ function normalizeResult(command: string, value: unknown): CommandResult {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
throw new TypeError(`command "${command}" handler must return a CommandResult`)
}
const result = value as { kind?: unknown; text?: unknown }
const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown }
if (result.kind === 'success') {
if (result.text !== undefined && typeof result.text !== 'string') {
throw new TypeError(`command "${command}" success text must be a string when supplied`)
}
return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
if (result.sourceEventSeq !== undefined
&& (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) {
throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`)
}
return Object.freeze({
kind: 'success',
...result.text === undefined ? {} : { text: result.text },
...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number },
})
}
if (result.kind === 'error') {
if (typeof result.text !== 'string' || result.text.trim().length === 0) {
@@ -389,6 +408,9 @@ export class CommandService extends Service {
this.appendLifecycle(agent.session, 'command/done', {
commandId, kind: result.kind,
...result.text === undefined ? {} : { text: result.text },
...result.kind === 'success' && result.sourceEventSeq !== undefined
? { sourceEventSeq: result.sourceEventSeq }
: {},
})
return Object.freeze({ commandId, result })
}

View File

@@ -34,6 +34,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
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`)
}
const source = event.data.sourceEventSeq
const sourceEvent = source === undefined ? undefined : session.events[source]
if (source !== undefined
&& (event.data.kind !== 'success'
|| !Number.isSafeInteger(source) || source < 0 || source >= event.seq
|| sourceEvent?.seq !== source
|| sourceEvent.type === 'command/run'
|| sourceEvent.type === 'command/done')) {
fail(`command/done ${JSON.stringify(event.data.commandId)} has invalid sourceEventSeq ${String(source)}`)
}
}
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(session, event)

View File

@@ -320,6 +320,25 @@ describe('CommandService', () => {
])
})
it('preserves an earlier authoritative domain-event reference on successful settlement', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const source = agent.session.append('turn/start', { turn: 1 })
ctx.commands.register({
name: 'linked',
description: 'Link outcome',
handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }),
})
const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq })
expect(lifecycleOf(agent)).toMatchObject([
{ type: 'command/run', data: { name: 'linked' } },
{ type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } },
])
})
it('omits raw input from command/run when an authoritative domain event owns it', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
@@ -427,6 +446,9 @@ describe('CommandService', () => {
[null, /CommandResult/],
[{}, /CommandResult/],
[{ kind: 'success', text: 1 }, /success text/],
[{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/],
[{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/],
[{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/],
[{ kind: 'error', text: '' }, /error text/],
[{ kind: 'error', text: 1 }, /error text/],
[{ kind: 'future', text: 'x' }, /unknown result kind/],

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { CommandId } from '@deepseek-ai/dsh-commands'
async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('commands-invariant'))
await ctx.plugin(InvariantService, { enabled: true })
if (installCompanion) await ctx.plugin(CommandInvariant)
return { ctx, session }
}
function appendRun(session: Session, id: string): void {
session.append('command/run', {
commandId: CommandId(id),
name: 'linked',
args: '',
source: { kind: 'user' },
})
}
describe('command lifecycle invariants', () => {
it('accepts a success outcome linked to an earlier non-command domain event', async () => {
const { session } = await mount()
const source = session.append('turn/start', { turn: 1 })
appendRun(session, 'cmd-valid')
expect(() => {
session.append('command/done', {
commandId: CommandId('cmd-valid'),
kind: 'success',
sourceEventSeq: source.seq,
})
}).not.toThrow()
})
it.each([-1, 1.5, 1])('rejects invalid or command-owned sourceEventSeq %s', async (sourceEventSeq) => {
const { session } = await mount()
appendRun(session, 'cmd-invalid')
expect(() => {
session.append('command/done', {
commandId: CommandId('cmd-invalid'),
kind: 'success',
sourceEventSeq,
})
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-commands',
}))
})
it('rejects an error settlement carrying a success-only source reference', async () => {
const { session } = await mount()
const source = session.append('turn/start', { turn: 1 })
appendRun(session, 'cmd-error-source')
expect(() => {
session.append('command/done', {
commandId: CommandId('cmd-error-source'),
kind: 'error',
text: 'failed',
sourceEventSeq: source.seq,
})
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-commands',
}))
})
it('attributes an invalid durable prefix during late companion loading', async () => {
const { ctx, session } = await mount(false)
appendRun(session, 'cmd-late')
session.append('command/done', {
commandId: CommandId('cmd-late'),
kind: 'success',
sourceEventSeq: 0,
})
await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-commands',
})
})
})