fix(feedback): keep payload in feedback event

This commit is contained in:
Turtle
2026-07-29 21:33:53 +08:00
parent 0ccd3ed463
commit eb6fa86481
42 changed files with 248 additions and 130 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/feedback/README.md
README.md: ab7bc6f3e3a3be0c280855ff80e92c7d7a7e665e
README.zh.md: 9c050ac42aa468895c04124a76a3bce58756df0e
README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f
README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e

View File

@@ -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.

View File

@@ -6,6 +6,6 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动
| 包 | 职责 | ctx 键 |
|---|---|---|
| `command-feedback/` | 面向用户的 `/feedback` 命令,通过命令平面完成记录 | 无 |
| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 |
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取命令记录,而不是改变它们的采集方式。
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式。

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/feedback/command-feedback/README.md
README.md: 90992b7295536a9099766910f616e640d4b4bcfe
README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e
README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9
README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc

View File

@@ -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.

View File

@@ -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` 在那里不可用。

View File

@@ -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": {

View File

@@ -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,
})
}

View File

@@ -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 = () => {}

View File

@@ -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 () => {

View File

@@ -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([])

View File

@@ -17,6 +17,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
}