fix(feedback): keep payload in feedback event
This commit is contained in:
@@ -304,9 +304,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
|
||||
/**
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` set the wanted target (`off` → false, else true);
|
||||
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
|
||||
* boundary (the fixture's agent/step parallel).
|
||||
* records named `plan` with recorded input set the wanted target (`off` →
|
||||
* false, else true); `plan/mode` commits and clears it. `wanted` is exposed
|
||||
* for the prompt boundary (the fixture's agent/step parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
@@ -315,7 +315,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo
|
||||
const item = event as unknown as { type: string; data?: Record<string, unknown> }
|
||||
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
|
||||
const args = item.data['args']
|
||||
wanted = (typeof args === 'string' ? args : '').trim() !== 'off'
|
||||
if (typeof args !== 'string') continue
|
||||
wanted = args.trim() !== 'off'
|
||||
} else if (item.type === 'plan/mode') {
|
||||
active = item.data?.['active'] === true
|
||||
wanted = null
|
||||
@@ -374,8 +375,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
}]
|
||||
}
|
||||
// The plan unit advances on its two folded event kinds.
|
||||
const commandData = event as unknown as { data: { name?: string; args?: unknown } }
|
||||
if (type === 'plan/mode' || (type === 'command/run'
|
||||
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
|
||||
&& commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
|
||||
@@ -140,7 +140,10 @@ export interface CommandNode {
|
||||
commandId: CommandId
|
||||
/** Command name (run payload's structured field); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||
/**
|
||||
* Verbatim rawInput after the name, including separator whitespace; null
|
||||
* when omitted by the command or when the run fell outside the window.
|
||||
*/
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
|
||||
@@ -234,10 +234,10 @@ export class FoldAdapter {
|
||||
// enter the client program, so this wire consumer narrows structurally
|
||||
// (the same posture as tool/code-dispatch in session.ts).
|
||||
if ((event.type as string) === 'command/run') {
|
||||
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
|
||||
const data = event.data as unknown as { commandId: CommandId; name: string; args?: string }
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
|
||||
commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ export const ev = {
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
}
|
||||
|
||||
@@ -195,6 +195,14 @@ describe('FoldAdapter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('represents command input omitted by the host as null', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', name: 'feedback', args: null, outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
|
||||
|
||||
@@ -21,8 +21,8 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
|
||||
? '执行中…'
|
||||
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
|
||||
// Display line rebuilt from the structured payload (args carries its own
|
||||
// separator whitespace verbatim); a cross-window node whose run page fell
|
||||
// out of the window has neither.
|
||||
// separator whitespace verbatim); omitted input and a cross-window node
|
||||
// whose run page fell out both render without it.
|
||||
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
|
||||
return (
|
||||
<ToolRow
|
||||
|
||||
@@ -1585,7 +1585,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CommandDefinition',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommandDescriptor',
|
||||
|
||||
@@ -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/feedback/README.md
|
||||
README.md: ab7bc6f3e3a3be0c280855ff80e92c7d7a7e665e
|
||||
README.zh.md: 9c050ac42aa468895c04124a76a3bce58756df0e
|
||||
README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f
|
||||
README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e
|
||||
|
||||
@@ -6,6 +6,6 @@ The feedback family lets a human record a remark about the session without actin
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `command-feedback/` | Human-facing `/feedback` command recorded through the command plane | — |
|
||||
| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — |
|
||||
|
||||
A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads the command records from the session log rather than changing how they are captured.
|
||||
A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured.
|
||||
|
||||
@@ -6,6 +6,6 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `command-feedback/` | 面向用户的 `/feedback` 命令,通过命令平面完成记录 | 无 |
|
||||
| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 |
|
||||
|
||||
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取命令记录,而不是改变它们的采集方式。
|
||||
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。
|
||||
|
||||
@@ -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/feedback/command-feedback/README.md
|
||||
README.md: 90992b7295536a9099766910f616e640d4b4bcfe
|
||||
README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e
|
||||
README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9
|
||||
README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Human-facing `/feedback` capture. The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn.
|
||||
Trigger-independent session feedback plus human-facing `/feedback` capture. The package exports `recordFeedback(session, text)`, which appends one log-only `feedback/record` event. Its plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn.
|
||||
|
||||
## Command contract
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/feedback <text>` | Acknowledge with `Feedback recorded.` The registry's `command/run` record carries the verbatim text. |
|
||||
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded.` |
|
||||
| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. |
|
||||
|
||||
Feedback text is never parsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own record; nothing is replaced or merged.
|
||||
Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged.
|
||||
|
||||
## What this plugin does and does not do
|
||||
|
||||
The command records a remark and does nothing else. It appends no session event of its own, starts no model work, and no plugin in this repository reads its records.
|
||||
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer, starts no model work, and no plugin in this repository reads the event.
|
||||
|
||||
The record is the command registry's own `command/run` / `command/done` pairing, which [`dsh-commands`](../../ui/commands/README.md) appends for every dispatched command. Those appends start persistence's ordinary eager drain; neither the registry nor this command forces a `session/flush`, so the acknowledgement means the entry is in the log, not that it has already reached disk. `command/run` carries the command name, the verbatim unparsed suffix, and the invocation source; the paired `command/done` carries the outcome. Both are log-only and are absent from the ordered surface, from `deriveMessages()`, and from every model request. A rejected empty input still leaves that pairing, settled as `kind: 'error'`, so no entry can be mistaken for accepted feedback.
|
||||
The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`.
|
||||
|
||||
A dedicated `session/feedback` event was considered and rejected: it would duplicate a record the registry already writes, and a consumer can select feedback by the command name it already stores.
|
||||
The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text.
|
||||
|
||||
## Composition
|
||||
|
||||
@@ -40,7 +40,7 @@ The TUI app mounts this command unconditionally; it has no configuration and no
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. The slash input, the recorded text, and the acknowledgement are all absent from model requests. The registry's `command/run` and `command/done` records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests.
|
||||
Nothing. The slash input, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -52,9 +52,8 @@ Independent of the model request path. Recording appends to the session log only
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads it; a consumer is a separate package that selects `command/run` records by command name.
|
||||
- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads `feedback/record`; a consumer is a separate package.
|
||||
- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
|
||||
- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
|
||||
- **Untrimmed text in the record** — the handler trims only to validate; `command/run` stores the raw suffix, including its leading separator whitespace, so a consumer trims at read time.
|
||||
- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`.
|
||||
- **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not mount `ctx.commands`, so `/feedback` is unavailable there.
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向用户的 `/feedback` 采集。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。
|
||||
与触发方式无关的会话反馈,以及面向用户的 `/feedback` 采集。本包(package)导出 `recordFeedback(session, text)`,后者追加一个仅写入日志的 `feedback/record` 事件。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。
|
||||
|
||||
## 命令契约
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/feedback <text>` | 以 `Feedback recorded.` 确认。注册表的 `command/run` 记录携带原样文本。 |
|
||||
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 |
|
||||
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
|
||||
|
||||
反馈文本从不被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令会各自产生自己的记录,不会替换或合并。
|
||||
前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。
|
||||
|
||||
## 本插件做什么、不做什么
|
||||
|
||||
该命令记录一条评价,不做别的事。它不追加属于自己的会话事件,不启动任何模型工作,本仓库中也没有任何插件读取它的记录。
|
||||
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件。
|
||||
|
||||
记录来自命令注册表自身的 `command/run` / `command/done` 配对,由 [`dsh-commands`](../../ui/commands/README.md) 为每个已分发命令追加。这些追加会启动持久化的常规即时排空;注册表与本命令都不会强制 `session/flush`,因此确认文本表示条目已进入日志,而不表示它已经落盘。`command/run` 携带命令名、原样未解析的后缀以及调用来源;配对的 `command/done` 携带结果。两者都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及任何模型请求中。被拒绝的空输入仍会留下该配对,并以 `kind: 'error'` 结算,因此任何条目都不会被误认为已接受的反馈。
|
||||
反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。
|
||||
|
||||
曾考虑并否决了专用的 `session/feedback` 事件:它会重复注册表已经写入的记录,而消费方可以依据注册表已存储的命令名筛选反馈。
|
||||
权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。
|
||||
|
||||
## 组合
|
||||
|
||||
@@ -40,7 +40,7 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
无。斜杠输入、被记录的文本以及确认文本都不出现在模型请求中。注册表的 `command/run` 与 `command/done` 记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。
|
||||
无。斜杠输入、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -52,9 +52,8 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取它;消费方是另一个依据命令名筛选 `command/run` 记录的独立包。
|
||||
- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包。
|
||||
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
|
||||
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。
|
||||
- **记录中的文本未修剪**:处理器只为校验而修剪;`command/run` 存储原始后缀,包含其前导分隔空白,因此消费方需在读取时修剪。
|
||||
- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。
|
||||
- **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不挂载 `ctx.commands`,因此 `/feedback` 在那里不可用。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-command-feedback",
|
||||
"description": "Human-facing slash command that records session feedback as a log-only event",
|
||||
"description": "Log-only session feedback producer and human-facing slash command",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -29,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
/**
|
||||
* Human-facing `/feedback` command. It records a remark about the session and
|
||||
* does nothing else: the command registry's own `command/run` and
|
||||
* `command/done` events are the whole record, so this plugin only validates the
|
||||
* input and acknowledges it. Those appends are eager but unflushed, so the
|
||||
* acknowledgement reports the entry is logged, not that it reached disk.
|
||||
* Session feedback event plus the human-facing `/feedback` producer. Recording
|
||||
* appends one authoritative log-only event and does not start model work. The
|
||||
* append is eager but unflushed, so acknowledgement reports that the entry is
|
||||
* logged, not that it reached disk.
|
||||
* @module @deepseek-ai/dsh-command-feedback
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export const name = 'command-feedback'
|
||||
export const inject = ['commands']
|
||||
|
||||
const USAGE = 'Usage: /feedback <text>'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* One recorded human remark about this session. Log-only and independent
|
||||
* of its trigger; it never enters the model surface or derived history.
|
||||
*/
|
||||
'feedback/record': { text: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and acknowledge one feedback entry. `command/run` already carries
|
||||
* the verbatim text, so no further append is needed; returning an error instead
|
||||
* settles that record as `kind: 'error'` and leaves no accepted feedback.
|
||||
* Record feedback independently of any UI trigger.
|
||||
* @param session - session the feedback describes.
|
||||
* @param text - human-authored feedback; surrounding whitespace is discarded.
|
||||
* @throws {TypeError} when the normalized text is empty.
|
||||
*/
|
||||
export function recordFeedback(session: Session, text: string): void {
|
||||
const normalized = text.trim()
|
||||
if (normalized.length === 0) throw new TypeError('feedback text must not be empty')
|
||||
session.append('feedback/record', { text: normalized })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, record, and acknowledge one feedback entry. Returning an error
|
||||
* leaves no `feedback/record` event.
|
||||
* @param invocation - receiving agent, raw command input, and UI cancellation.
|
||||
* @returns an acknowledgement, or a usage error when no feedback text was supplied.
|
||||
*/
|
||||
@@ -26,6 +47,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
|
||||
if (invocation.rawInput.trim().length === 0) {
|
||||
return { kind: 'error', text: `Feedback text is required. ${USAGE}` }
|
||||
}
|
||||
recordFeedback(invocation.agent.session, invocation.rawInput)
|
||||
return { kind: 'success', text: 'Feedback recorded.' }
|
||||
}
|
||||
|
||||
@@ -35,6 +57,7 @@ export function apply(ctx: Context): void {
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
recordInput: false,
|
||||
handler: executeFeedbackCommand,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ export const name = 'command-feedback-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this command declares no session event and owns no state projection. The
|
||||
* `command/run`/`command/done` pairing that records feedback belongs to `dsh-commands`.
|
||||
* No runtime invariant: each `feedback/record` is an independent append-only
|
||||
* fact with no cross-event or mutable-data relationship.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -58,15 +58,11 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s
|
||||
return settled.result
|
||||
}
|
||||
|
||||
/** The registry's durable record of each accepted command, in log order. */
|
||||
function commandRecords(session: Session): { name: string; args: string; kind: string }[] {
|
||||
const runs = session.events.filter(event => event.type === 'command/run')
|
||||
return runs.map((event) => {
|
||||
const done = session.events.find(item =>
|
||||
item.type === 'command/done' && item.data.commandId === event.data.commandId)
|
||||
if (done?.type !== 'command/done') throw new Error('every command/run must be paired')
|
||||
return { name: event.data.name, args: event.data.args, kind: done.data.kind }
|
||||
})
|
||||
/** Authoritative feedback payloads in log order. */
|
||||
function feedbackTexts(session: Session): string[] {
|
||||
return session.events
|
||||
.filter(event => event.type === 'feedback/record')
|
||||
.map(event => event.data.text)
|
||||
}
|
||||
|
||||
describe('@deepseek-ai/dsh-command-feedback registration', () => {
|
||||
@@ -83,7 +79,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => {
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
})
|
||||
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined()
|
||||
expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
|
||||
@@ -91,38 +87,47 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => {
|
||||
})
|
||||
|
||||
describe('/feedback human command', () => {
|
||||
it('acknowledges feedback and leaves the registry record as its durable trace', async () => {
|
||||
it('acknowledges feedback and records its payload exactly once in the domain event', async () => {
|
||||
const test = await harness()
|
||||
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: 'Feedback recorded.',
|
||||
})
|
||||
expect(commandRecords(test.session)).toEqual([
|
||||
{ name: 'feedback', args: ' the diff view is unreadable', kind: 'success' },
|
||||
])
|
||||
expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
|
||||
const commandRun = test.session.events.find(event => event.type === 'command/run')
|
||||
expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false)
|
||||
expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adds no event of its own beyond the registry pairing', async () => {
|
||||
it('exports a command-independent feedback producer', async () => {
|
||||
const test = await harness()
|
||||
commandFeedback.recordFeedback(test.session, ' recorded outside a command ')
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['feedback/record'])
|
||||
expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
|
||||
expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') })
|
||||
.toThrow('feedback text must not be empty')
|
||||
expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
|
||||
})
|
||||
|
||||
it('keeps command bookkeeping around the authoritative feedback event', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' nothing else happens')
|
||||
// The whole point of the command: record and do nothing. Only the
|
||||
// registry's own pairing appears, and no turn of model work starts.
|
||||
expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done'])
|
||||
expect(test.session.events.map(event => event.type)).toEqual([
|
||||
'command/run', 'feedback/record', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it('records verbatim text, including input that looks like another command', async () => {
|
||||
it('normalizes surrounding whitespace without parsing command-like content', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' /plan felt SLOW\n\ttwice today ')
|
||||
expect(commandRecords(test.session)).toEqual([
|
||||
{ name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' },
|
||||
])
|
||||
expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today'])
|
||||
})
|
||||
|
||||
it('records each entry separately without replacing earlier ones', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' first')
|
||||
await run(test, ' second')
|
||||
expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
|
||||
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('records concurrent submissions in dispatch order', async () => {
|
||||
@@ -137,7 +142,7 @@ describe('/feedback human command', () => {
|
||||
{ kind: 'success', text: 'Feedback recorded.' },
|
||||
{ kind: 'success', text: 'Feedback recorded.' },
|
||||
])
|
||||
expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
|
||||
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('keeps every recorded event off the model surface and out of derived history', async () => {
|
||||
@@ -160,9 +165,12 @@ describe('/feedback human command', () => {
|
||||
}
|
||||
await expect(run(test)).resolves.toEqual(expected)
|
||||
await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
|
||||
// Rejected input still leaves the registry's own pairing, settled as an
|
||||
// error, so no entry is mistaken for accepted feedback.
|
||||
expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error'])
|
||||
expect(feedbackTexts(test.session)).toEqual([])
|
||||
const done = test.session.events.filter(event => event.type === 'command/done')
|
||||
expect(done.map(event => event.data.kind)).toEqual(['error', 'error'])
|
||||
for (const event of test.session.events) {
|
||||
if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('records nothing when dispatch rejects an already-cancelled request', async () => {
|
||||
|
||||
@@ -92,11 +92,14 @@ describe('/feedback real Loader composition through cordis.yml', () => {
|
||||
text: 'Feedback text is required. Usage: /feedback <text>',
|
||||
})
|
||||
|
||||
// The command records itself through the registry and does nothing else.
|
||||
// The domain event owns the payload; generic command bookkeeping omits it.
|
||||
expect(owner.session.events.map(event => event.type))
|
||||
.toEqual(['command/run', 'command/done', 'command/run', 'command/done'])
|
||||
.toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done'])
|
||||
const run = owner.session.events.find(event => event.type === 'command/run')
|
||||
expect(run?.type === 'command/run' && run.data.args).toBe(' the diff view is unreadable')
|
||||
expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false)
|
||||
const feedback = owner.session.events.find(event => event.type === 'feedback/record')
|
||||
expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable')
|
||||
expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1)
|
||||
|
||||
// Nothing reached the model.
|
||||
expect(owner.session.deriveMessages()).toEqual([])
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
|
||||
README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf
|
||||
README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba
|
||||
README.md: e3a98115d2d9f14fa0bb46e4d867f6b79cbf269d
|
||||
README.zh.md: f8481cff12992e83af39498908c5ca2624a4f974
|
||||
|
||||
@@ -20,7 +20,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t
|
||||
|
||||
## Session projection
|
||||
|
||||
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
|
||||
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一
|
||||
|
||||
## 会话投影
|
||||
|
||||
当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。
|
||||
当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan` 且带有已记录 `args` 的 `command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量,host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉)。key 从 `src/types.ts` merge 进 `SessionProjectionMap`(host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -234,6 +234,7 @@ export class PlanModeService extends Service {
|
||||
init: () => ({ active: false, wanted: null }),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'command/run' && event.data.name === 'plan') {
|
||||
if (event.data.args === undefined) return state
|
||||
const wanted = event.data.args.trim() !== 'off'
|
||||
return wanted === state.wanted ? state : { active: state.active, wanted }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The `plan` projection unit (session-projection RFC's complete example): a
|
||||
* double-event fold over the session log. `command/run` records named `plan`
|
||||
* set the wanted target (`off` → false, anything else → true); `plan/mode`
|
||||
* commits and clears it; `view` derives `{ active, pending }` where pending
|
||||
* is true only while an outstanding selection differs from the logged state.
|
||||
* with recorded input set the wanted target (`off` → false, anything else
|
||||
* → true); `plan/mode` commits and clears it. `view` reports pending only
|
||||
* while an outstanding selection differs from the logged state.
|
||||
* Pending is thereby a pure replay quantity — a cold fold answers it without
|
||||
* the service's in-memory intent. Composition without plan-mode has no `plan`
|
||||
* key; unloading the fiber removes it (HMR safety).
|
||||
@@ -88,6 +88,11 @@ describe('plan projection unit', () => {
|
||||
commandId: CommandId('other-1'), name: 'compact', args: '', source: { kind: 'user' },
|
||||
})
|
||||
expect(bench.values().plan).toEqual({ active: true, pending: false })
|
||||
// A command lifecycle with omitted input carries no plan selection.
|
||||
bench.session.append('command/run', {
|
||||
commandId: CommandId('plan-no-input'), name: 'plan', source: { kind: 'user' },
|
||||
})
|
||||
expect(bench.values().plan).toEqual({ active: true, pending: false })
|
||||
runPlanCommand(bench.session, ' off', 1)
|
||||
expect(bench.values().plan).toEqual({ active: true, pending: true })
|
||||
commitPlanMode(bench.session, false, 1)
|
||||
|
||||
@@ -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: 4ad72cf9e232c8d41e525f42eecde5637032a391
|
||||
README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd
|
||||
README.md: 77397aadf8dd070d962d1a4f95dea2e4700a6c15
|
||||
README.zh.md: 8f02325271548b652b069433bcdb9c1c99de547e
|
||||
|
||||
@@ -6,9 +6,9 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
|
||||
|
||||
## Service contract
|
||||
|
||||
`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.
|
||||
`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`/`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.
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
## 服务契约
|
||||
|
||||
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
|
||||
`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`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。
|
||||
`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'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。
|
||||
|
||||
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
||||
|
||||
|
||||
@@ -71,6 +71,12 @@ export interface CommandDefinition {
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/**
|
||||
* Whether `command/run` records `rawInput`. Defaults to true. A command
|
||||
* whose domain event owns the payload sets this false to avoid duplicating
|
||||
* that payload in the session log.
|
||||
*/
|
||||
readonly recordInput?: boolean
|
||||
/** Execute against the receiving agent without sending the command to the model. */
|
||||
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
|
||||
}
|
||||
@@ -127,9 +133,10 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* 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.
|
||||
* a line. `args` is absent when the definition sets `recordInput: false`
|
||||
* because an authoritative domain event owns the input payload.
|
||||
*/
|
||||
'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource }
|
||||
'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
|
||||
@@ -239,6 +246,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
...input === undefined ? {} : { input },
|
||||
...definition.recordInput === undefined ? {} : { recordInput: definition.recordInput },
|
||||
handler: definition.handler,
|
||||
})
|
||||
const descriptor = Object.freeze({
|
||||
@@ -357,7 +365,10 @@ export class CommandService extends Service {
|
||||
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' },
|
||||
commandId,
|
||||
name: parsed.name,
|
||||
...command.definition.recordInput === false ? {} : { args: parsed.rawInput },
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
let result: CommandResult
|
||||
|
||||
@@ -320,6 +320,25 @@ describe('CommandService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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')
|
||||
const seen = vi.fn(() => ({ kind: 'success' as const }))
|
||||
ctx.commands.register({
|
||||
name: 'private',
|
||||
description: 'Record privately',
|
||||
recordInput: false,
|
||||
handler: seen,
|
||||
})
|
||||
|
||||
await ctx.commands.execute(agent, '/private keep this once', new AbortController().signal)
|
||||
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' }))
|
||||
const run = agent.session.events.find(event => event.type === 'command/run')
|
||||
expect(run?.type).toBe('command/run')
|
||||
expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false)
|
||||
})
|
||||
|
||||
it('mints distinct monotonic commandIds across executions', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
|
||||
Reference in New Issue
Block a user