feat(feedback): disclose session sharing in the /feedback acknowledgement
The /feedback acknowledgement now echoes the receiving session id and reports the mounted telemetry backend's sharing policy: the telemetry seam exposes a backend-independent TelemetrySharingStatus through a required abstract sharing member on the Telemetry service, the OTel backend maps its mode onto it, and the command appends one policy-only sharing sentence (full / feedback-only / disabled / not configured) to the acknowledgement. The web client renders the text through the existing command row without a client change; a new assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence as a keyless golden.
This commit is contained in:
@@ -8,11 +8,24 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. |
|
||||
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}`, `User: {userId}`, plus the session-sharing disclosure. |
|
||||
| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. |
|
||||
|
||||
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.
|
||||
|
||||
## Session-sharing disclosure
|
||||
|
||||
The acknowledgement names the receiving session id and reports how that session is shared, read from the mounted [`telemetry`](../../session/session-telemetry/README.md) service through the plugin context (`ctx.get('telemetry')`, never a declared injection). The disclosure is one sentence chosen from the backend's [`TelemetrySharingStatus`](../../session/session-telemetry/README.md):
|
||||
|
||||
| Disclosed status | Acknowledgement sentence |
|
||||
|---|---|
|
||||
| `full` | `Session sharing is enabled.` |
|
||||
| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` |
|
||||
| `disabled` | `Session sharing is disabled.` |
|
||||
| no service | `Session sharing is not configured.` |
|
||||
|
||||
The disclosure states the deployment's current sharing policy only; it never promises delivery or retention. With `full` or `feedback-only`, records are handed to the backend's non-blocking enqueue and the SDK owns batching, retry, and loss policy, so the sentence claims nothing about what reached a collector; `disabled` claims nothing about future reconfiguration. The disclosure adds no event and never enters the model surface.
|
||||
|
||||
## What this plugin does and does not do
|
||||
|
||||
`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 and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract.
|
||||
@@ -56,4 +69,5 @@ Independent of the model request path. Recording appends to the session log only
|
||||
- **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.
|
||||
- **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)`.
|
||||
- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally.
|
||||
- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.
|
||||
|
||||
@@ -8,11 +8,24 @@
|
||||
|
||||
| 输入 | 结果 |
|
||||
|---|---|
|
||||
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 |
|
||||
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}`、`User: {userId}` 加会话共享披露确认。 |
|
||||
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
|
||||
|
||||
前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。
|
||||
|
||||
## 会话共享披露
|
||||
|
||||
确认文本会点名接收会话的 id,并报告该会话如何被共享;该信息通过插件上下文(`ctx.get('telemetry')`,绝不是声明的注入)从已挂载的 [`telemetry`](../../session/session-telemetry/README.md) 服务读取。披露是依据后端 [`TelemetrySharingStatus`](../../session/session-telemetry/README.md) 选择的一句话:
|
||||
|
||||
| 披露的状态 | 确认文本中的句子 |
|
||||
|---|---|
|
||||
| `full` | `Session sharing is enabled.` |
|
||||
| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` |
|
||||
| `disabled` | `Session sharing is disabled.` |
|
||||
| 无服务 | `Session sharing is not configured.` |
|
||||
|
||||
披露只陈述部署当前的共享策略,绝不承诺投递或留存:在 `full` 或 `feedback-only` 下,记录被交给后端的非阻塞入队,批处理、重试与丢失策略归 SDK 负责,因此句子不声称任何内容已到达采集端;`disabled` 也不声称未来不会重新配置。披露不新增任何事件,也绝不会进入模型 surface。
|
||||
|
||||
## 本插件做什么、不做什么
|
||||
|
||||
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。
|
||||
@@ -56,4 +69,5 @@
|
||||
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
|
||||
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。
|
||||
- **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。
|
||||
- **新会话上没有可见的确认**:Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染。
|
||||
- **随附的产品入口中只有 Web 使用此命令**:无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-id": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -46,6 +47,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-id": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ export function recordFeedback(session: Session, text: string): void {
|
||||
* @returns an acknowledgement containing the receiving session and anonymous
|
||||
* user ids plus the session-sharing disclosure, or a usage error when no
|
||||
* feedback text was supplied.
|
||||
* @returns an acknowledgement containing the receiving session id and the
|
||||
* session-sharing disclosure, or a usage error when no feedback text was supplied.
|
||||
>>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement)
|
||||
*/
|
||||
function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult {
|
||||
if (invocation.rawInput.trim().length === 0) {
|
||||
@@ -93,6 +96,8 @@ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): Co
|
||||
return {
|
||||
kind: 'success',
|
||||
text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`,
|
||||
text: `Feedback recorded for session ${invocation.agent.session.id}. ${sharingDisclosure(telemetry)}`,
|
||||
>>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import AgentRegistry, { Inbox } 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 { Telemetry, type TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry'
|
||||
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
|
||||
|
||||
const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
|
||||
@@ -25,6 +26,20 @@ interface Harness {
|
||||
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
/** Minimal mounted backend disclosing one sharing policy. */
|
||||
class FakeTelemetry extends Telemetry {
|
||||
override readonly sharing: TelemetrySharingStatus
|
||||
|
||||
constructor(ctx: Context, config: { sharing: TelemetrySharingStatus }) {
|
||||
super(ctx)
|
||||
this.sharing = config.sharing
|
||||
}
|
||||
|
||||
emit(): void {}
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
|
||||
/** 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))
|
||||
@@ -48,12 +63,17 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
/** Mount the real command registry and this producer. */
|
||||
async function harness(): Promise<Harness> {
|
||||
/**
|
||||
* Mount the real command registry, this producer, and optionally a telemetry
|
||||
* backend disclosing one sharing policy. Without `sharing`, no telemetry
|
||||
* service exists and the acknowledgement reports "not configured".
|
||||
*/
|
||||
async function harness(sharing?: TelemetrySharingStatus): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
if (sharing !== undefined) await ctx.plugin(FakeTelemetry, { sharing })
|
||||
const plugin = await ctx.plugin(commandFeedback)
|
||||
const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
|
||||
ctx.agents.register(agent)
|
||||
@@ -104,7 +124,7 @@ describe('/feedback human command', () => {
|
||||
const test = await harness()
|
||||
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`,
|
||||
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.`,
|
||||
})
|
||||
expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
|
||||
const commandRun = test.session.events.find(event => event.type === 'command/run')
|
||||
@@ -152,12 +172,39 @@ describe('/feedback human command', () => {
|
||||
test.ctx.commands.execute(test.agent, '/feedback second', signal),
|
||||
])
|
||||
expect(settled.map(item => item?.result)).toEqual([
|
||||
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` },
|
||||
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` },
|
||||
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` },
|
||||
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` },
|
||||
])
|
||||
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('discloses full session sharing in the acknowledgement', async () => {
|
||||
const test = await harness('full')
|
||||
await expect(run(test, ' everything shared')).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is enabled.`,
|
||||
})
|
||||
expect(feedbackTexts(test.session)).toEqual(['everything shared'])
|
||||
})
|
||||
|
||||
it('discloses feedback-gated session sharing in the acknowledgement', async () => {
|
||||
const test = await harness('feedback-only')
|
||||
await expect(run(test, ' gated sharing')).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`,
|
||||
})
|
||||
expect(feedbackTexts(test.session)).toEqual(['gated sharing'])
|
||||
})
|
||||
|
||||
it('discloses disabled session sharing in the acknowledgement', async () => {
|
||||
const test = await harness('disabled')
|
||||
await expect(run(test, ' local only')).resolves.toEqual({
|
||||
kind: 'success',
|
||||
text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is disabled.`,
|
||||
})
|
||||
expect(feedbackTexts(test.session)).toEqual(['local only'])
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('/feedback real Loader composition through cordis.yml', () => {
|
||||
const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } })
|
||||
expect(accepted?.result).toEqual({
|
||||
kind: 'success',
|
||||
text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`,
|
||||
text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}. Session sharing is not configured.`,
|
||||
})
|
||||
const rejected = await context.commands.execute(owner, '/feedback', signal)
|
||||
expect(rejected?.result).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user