feat(feedback): add a /feedback command recorded through the command plane
Register a global `/feedback` command so a user can record a remark about the session without spending a model turn. `/feedback <text>` acknowledges; empty or whitespace-only input returns a usage error. The plugin appends no session event of its own. `dsh-commands` already writes a `command/run` / `command/done` pair for every dispatched command, carrying the verbatim text and the settled outcome, and both records are log-only and non-surface. The feedback is therefore durably in the session log and invisible to the model without this package touching the log format. Text is never parsed, so `/feedback /plan felt slow` records that literal content. Nothing consumes the records; capture is deliberately inert. New group `packages/feedback/` — no existing group owns feedback capture. Its row raises the packages/README.md word ceiling by 10, which had no headroom; one redundant sentence there was removed to offset most of the cost.
This commit is contained in:
6
packages/feedback/command-feedback/README.i18n.yaml
Normal file
6
packages/feedback/command-feedback/README.i18n.yaml
Normal file
@@ -0,0 +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 packages/feedback/command-feedback/README.md
|
||||
README.md: 90992b7295536a9099766910f616e640d4b4bcfe
|
||||
README.zh.md: a7c4f03997cea182ed24dcfc7f309dc3bd872d5e
|
||||
60
packages/feedback/command-feedback/README.md
Normal file
60
packages/feedback/command-feedback/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# @deepseek-ai/dsh-command-feedback
|
||||
|
||||
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.
|
||||
|
||||
## Command contract
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/feedback <text>` | Acknowledge with `Feedback recorded.` The registry's `command/run` record carries the verbatim text. |
|
||||
| `/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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Composition
|
||||
|
||||
The producer injects only `commands`. A custom app mounts the registry plus this plugin:
|
||||
|
||||
```yaml
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
- id: command-feedback
|
||||
name: '@deepseek-ai/dsh-command-feedback'
|
||||
```
|
||||
|
||||
The TUI app mounts this command unconditionally; it has no configuration and no dependency on the persisted-goal stack. The headless CLI, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`, so they do not expose it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Human `/feedback` capture
|
||||
|
||||
#### 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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero direct token effect. Neither an accepted entry nor a usage error adds model tokens, in the recording turn or any later one.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the model request path. Recording appends to the session log only, leaving an already-reusable request prefix untouched. Nothing this package contributes can invalidate cache reuse.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
60
packages/feedback/command-feedback/README.zh.md
Normal file
60
packages/feedback/command-feedback/README.zh.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# @deepseek-ai/dsh-command-feedback
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向用户的 `/feedback` 采集。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附 TUI 无需模型轮次即可执行。
|
||||
|
||||
## 命令契约
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/feedback <text>` | 以 `Feedback recorded.` 确认。注册表的 `command/run` 记录携带原样文本。 |
|
||||
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
|
||||
|
||||
反馈文本从不被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令会各自产生自己的记录,不会替换或合并。
|
||||
|
||||
## 本插件做什么、不做什么
|
||||
|
||||
该命令记录一条评价,不做别的事。它不追加属于自己的会话事件,不启动任何模型工作,本仓库中也没有任何插件读取它的记录。
|
||||
|
||||
记录来自命令注册表自身的 `command/run` / `command/done` 配对,由 [`dsh-commands`](../../ui/commands/README.md) 为每个已分发命令追加。这些追加会启动持久化的常规即时排空;注册表与本命令都不会强制 `session/flush`,因此确认文本表示条目已进入日志,而不表示它已经落盘。`command/run` 携带命令名、原样未解析的后缀以及调用来源;配对的 `command/done` 携带结果。两者都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及任何模型请求中。被拒绝的空输入仍会留下该配对,并以 `kind: 'error'` 结算,因此任何条目都不会被误认为已接受的反馈。
|
||||
|
||||
曾考虑并否决了专用的 `session/feedback` 事件:它会重复注册表已经写入的记录,而消费方可以依据注册表已存储的命令名筛选反馈。
|
||||
|
||||
## 组合
|
||||
|
||||
生产方只注入 `commands`。自定义应用挂载注册表以及本插件:
|
||||
|
||||
```yaml
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
- id: command-feedback
|
||||
name: '@deepseek-ai/dsh-command-feedback'
|
||||
```
|
||||
|
||||
TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal 栈。无头 CLI、ACP 自动化和 JSON-RPC 适配器不消费 `ctx.commands`,因此不会暴露它。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户 `/feedback` 采集
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
无。斜杠输入、被记录的文本以及确认文本都不出现在模型请求中。注册表的 `command/run` 与 `command/done` 记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
无直接 token 影响。无论是已接受的条目还是用法错误,都不会在记录所在轮次或此后任何轮次增加模型 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
与模型请求路径无关。记录只追加到会话日志,不触碰已经可复用的请求前缀。本包贡献的任何内容都不会使缓存复用失效。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface,也没有面向模型的工具读取它;消费方是另一个依据命令名筛选 `command/run` 记录的独立包。
|
||||
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
|
||||
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。
|
||||
- **记录中的文本未修剪**:处理器只为校验而修剪;`command/run` 存储原始后缀,包含其前导分隔空白,因此消费方需在读取时修剪。
|
||||
- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。
|
||||
- **随附应用中只有 TUI 使用此命令**:无头 CLI、ACP 自动化和 JSON-RPC 适配器不挂载 `ctx.commands`,因此 `/feedback` 在那里不可用。
|
||||
44
packages/feedback/command-feedback/package.json
Normal file
44
packages/feedback/command-feedback/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-command-feedback",
|
||||
"description": "Human-facing slash command that records session feedback as a log-only event",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
40
packages/feedback/command-feedback/src/index.ts
Normal file
40
packages/feedback/command-feedback/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-command-feedback
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
export const name = 'command-feedback'
|
||||
export const inject = ['commands']
|
||||
|
||||
const USAGE = 'Usage: /feedback <text>'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param invocation - receiving agent, raw command input, and UI cancellation.
|
||||
* @returns an acknowledgement, or a usage error when no feedback text was supplied.
|
||||
*/
|
||||
function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
|
||||
if (invocation.rawInput.trim().length === 0) {
|
||||
return { kind: 'error', text: `Feedback text is required. ${USAGE}` }
|
||||
}
|
||||
return { kind: 'success', text: 'Feedback recorded.' }
|
||||
}
|
||||
|
||||
/** Register the global `/feedback` command for every composed command adapter. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.commands.register({
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
handler: executeFeedbackCommand,
|
||||
})
|
||||
}
|
||||
30
packages/feedback/command-feedback/src/invariant.ts
Normal file
30
packages/feedback/command-feedback/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-command-feedback`.
|
||||
* @module @deepseek-ai/dsh-command-feedback/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'command-feedback-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
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`.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
|
||||
|
||||
interface Harness {
|
||||
readonly ctx: Context
|
||||
readonly agent: Agent
|
||||
readonly session: Session
|
||||
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
/** Build a live idle agent over a store-owned session, as an app's spine does. */
|
||||
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
let status: AgentStatus = 'idle'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
/** Mount the real command registry and this producer. */
|
||||
async function harness(): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
const plugin = await ctx.plugin(commandFeedback)
|
||||
const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, agent, session, plugin }
|
||||
}
|
||||
|
||||
/** Execute `/feedback` through the same registry boundary as a UI adapter. */
|
||||
async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> {
|
||||
const settled = await test.ctx.commands.execute(
|
||||
test.agent,
|
||||
`/feedback${suffix}`,
|
||||
new AbortController().signal,
|
||||
)
|
||||
if (settled === undefined) throw new Error('feedback command was not registered')
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
describe('@deepseek-ai/dsh-command-feedback registration', () => {
|
||||
it('registers one global command with Loader-safe exports and disposes it', async () => {
|
||||
const test = await harness()
|
||||
expect(commandFeedback.name).toBe('command-feedback')
|
||||
expect(commandFeedback.inject).toEqual(['commands'])
|
||||
expect('default' in commandFeedback).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
|
||||
|
||||
expect(test.ctx.commands.list(test.agent)).toContainEqual({
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
})
|
||||
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined()
|
||||
|
||||
await test.plugin.dispose()
|
||||
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('/feedback human command', () => {
|
||||
it('acknowledges feedback and leaves the registry record as its durable trace', 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' },
|
||||
])
|
||||
})
|
||||
|
||||
it('adds no event of its own beyond the registry pairing', 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'])
|
||||
})
|
||||
|
||||
it('records verbatim text, including input that looks like another command', 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' },
|
||||
])
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
it('records concurrent submissions in dispatch order', async () => {
|
||||
const test = await harness()
|
||||
const signal = new AbortController().signal
|
||||
// The shipped TUI dispatches commands fire-and-forget.
|
||||
const settled = await Promise.all([
|
||||
test.ctx.commands.execute(test.agent, '/feedback first', signal),
|
||||
test.ctx.commands.execute(test.agent, '/feedback second', signal),
|
||||
])
|
||||
expect(settled.map(item => item?.result)).toEqual([
|
||||
{ kind: 'success', text: 'Feedback recorded.' },
|
||||
{ kind: 'success', text: 'Feedback recorded.' },
|
||||
])
|
||||
expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
|
||||
})
|
||||
|
||||
it('keeps every recorded event off the model surface and out of derived history', async () => {
|
||||
const test = await harness()
|
||||
await run(test, ' invisible to the model')
|
||||
for (const event of test.session.events) {
|
||||
expect('surfaceOp' in event).toBe(false)
|
||||
expect(test.session.deriveEventMessage(event)).toBeNull()
|
||||
}
|
||||
expect(foldSurface(test.session.events).nodes).toEqual([])
|
||||
expect(test.session.surface.nodes).toEqual([])
|
||||
expect(test.session.deriveMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects empty and whitespace-only input as a failed command record', async () => {
|
||||
const test = await harness()
|
||||
const expected = {
|
||||
kind: 'error',
|
||||
text: 'Feedback text is required. Usage: /feedback <text>',
|
||||
}
|
||||
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'])
|
||||
})
|
||||
|
||||
it('records nothing when dispatch rejects an already-cancelled request', async () => {
|
||||
const test = await harness()
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('user cancelled the command'))
|
||||
await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal))
|
||||
.rejects.toThrow('user cancelled the command')
|
||||
expect(test.session.events).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Register one idle agent over a store-owned session, as an app's spine does. */
|
||||
function agent(ctx: Context): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const id = SessionId('feedback-loader-agent')
|
||||
const session = ctx.sessions.create(id)
|
||||
let status: AgentStatus = 'idle'
|
||||
const value: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: scope.ctx,
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
}
|
||||
|
||||
describe('/feedback real Loader composition through cordis.yml', () => {
|
||||
it('boots cordis.yml and records feedback without model-visible output', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-agent'",
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
"- name: '@deepseek-ai/dsh-commands'",
|
||||
"- name: '@deepseek-ai/dsh-command-feedback'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-agent', AgentRegistry],
|
||||
['@deepseek-ai/dsh-session', SessionStore],
|
||||
['@deepseek-ai/dsh-commands', CommandService],
|
||||
['@deepseek-ai/dsh-command-feedback', CommandFeedback],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await context.loader.await()
|
||||
|
||||
const owner = agent(context)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
// Discoverable through the composed registry, as a UI adapter finds it.
|
||||
expect(context.commands.list(owner).map(command => command.name)).toContain('feedback')
|
||||
|
||||
const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal)
|
||||
expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' })
|
||||
const rejected = await context.commands.execute(owner, '/feedback', signal)
|
||||
expect(rejected?.result).toEqual({
|
||||
kind: 'error',
|
||||
text: 'Feedback text is required. Usage: /feedback <text>',
|
||||
})
|
||||
|
||||
// The command records itself through the registry and does nothing else.
|
||||
expect(owner.session.events.map(event => event.type))
|
||||
.toEqual(['command/run', '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')
|
||||
|
||||
// Nothing reached the model.
|
||||
expect(owner.session.deriveMessages()).toEqual([])
|
||||
expect(owner.session.surface.nodes).toEqual([])
|
||||
})
|
||||
})
|
||||
24
packages/feedback/command-feedback/tsconfig.json
Normal file
24
packages/feedback/command-feedback/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user