diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index 809e37044f..e0ba016659 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 3edb29283c289d6d006891a4c19087b01fa8166f -2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14 +2026-07-28-feedback-command.md: d3b2774e41a82f6edb4303280f813ddbed75ebd1 +2026-07-28-feedback-command.zh.md: 3eeef92f2ed39c9546f013f217dd7f851d30c78c diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 3edb29283c..d3b2774e41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -18,7 +18,7 @@ The package declares the log-only `feedback/record { text }` session event and e `dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends enter persistence's ordinary bounded write path; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). +Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md) and the [acknowledgement sharing disclosure](2026-08-07-feedback-acknowledgement-sharing-disclosure.md). ### Why feedback owns an event diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index c2513d2570..3eeef92f2e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -18,7 +18,7 @@ Status: implemented `dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 +采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)与[确认文本中的共享披露](2026-08-07-feedback-acknowledgement-sharing-disclosure.md)。 ### 为何反馈拥有自己的事件 diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml new file mode 100644 index 0000000000..b5c7f142f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md +2026-08-07-feedback-acknowledgement-sharing-disclosure.md: 1e9cd0fb95d78aff9f6434e0583154e2c3f847da +2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md: ac26b18ad523feeabc297b212210dd73eff93a0a diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md new file mode 100644 index 0000000000..1e9cd0fb95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md @@ -0,0 +1,27 @@ +# Agent Note: Feedback acknowledgement sharing disclosure + +Status: implemented + +English | [中文](2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md) + +## Problem + +The `/feedback` command records a log-only `feedback/record` event and acknowledges the user, but the acknowledgement carried no durable context about what happened to the session: deployments that mount session telemetry (`FULL`, `FEEDBACK_ONLY`, or `DISABLED`) had no way to tell the user whether their feedback and session left the process, and the receiving session id was not echoed. The command plugin could not read the sharing policy because the telemetry seam exposed capture only, and the OTel mode enum lived in the optional backend package. + +## Decision + +The telemetry seam (`@deepseek-ai/dsh-session-telemetry`) now owns a backend-independent sharing vocabulary: `TelemetrySharingStatus` (`full` | `feedback-only` | `disabled`) plus a required abstract `sharing` member on the `Telemetry` service class — every backend must disclose its policy, so a consumer renders "not configured" only when no telemetry service is mounted. `@deepseek-ai/dsh-session-telemetry-otel` maps its serialized `TelemetryMode` (the [feedback-gated delivery decision](2026-08-05-feedback-gated-session-telemetry.md) owns the mode semantics) onto that status in the constructor and discloses it, including in `DISABLED`. The `/feedback` handler reads the mounted service through the plugin context (`ctx.get('telemetry')`, never a declared injection, so the command loads and runs without telemetry) and appends one sharing sentence to the acknowledgement: `Feedback recorded for session {id}. `. No service → `Session sharing is not configured.`; `disabled` → `Session sharing is disabled.`; `feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`; `full` → `Session sharing is enabled.` + +The disclosure states the current sharing policy only; it never promises delivery or retention. Handoff is the backend's non-blocking enqueue and batching, retry, and loss policy stay the backend SDK's, and a later reconfiguration can change what was shared, so the sentences claim nothing about what reached a collector or about future retention. The disclosure adds no session event and never reaches the model surface; the web client renders it through the existing command row (`CommandNode` outcome text) with no client change. + +## Alternatives considered + +**A client-side status RPC and badge.** Rejected because the acknowledgement is host-produced and the web client already renders the command result text verbatim in the command row; a separate RPC would duplicate the status in a second surface and add a wire contract for a sentence. + +**Declared `telemetry` injection in `command-feedback`.** Rejected because telemetry is optional: a declared injection fails plugin load when the service is absent, while the command must work without it. The plugin reads the service with `ctx.get('telemetry')` at handler time instead. + +**OTel package owns the vocabulary.** Rejected because `command-feedback` must not depend on the optional OTel backend package. The seam owns `TelemetrySharingStatus` so any backend can disclose a policy. + +## Consequences + +The acknowledgement is user-visible: it names the receiving session and reports the current sharing policy, honest about the fire-and-forget handoff. Package tests pin the sentence for each status and for the absent-service case; the assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence (`Session sharing is enabled.`) as a golden. The seam member is required, so a mounted backend always discloses a policy and the "not configured" sentence truthfully means no telemetry service; the `/feedback` command keeps working with no telemetry mounted. A still-blank web session renders no command row, so feedback recorded before the first message gets no visible acknowledgement (documented under the package README's limitations). diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md new file mode 100644 index 0000000000..ac26b18ad5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 反馈确认中的会话共享披露 + +Status: implemented + +[English](2026-08-07-feedback-acknowledgement-sharing-disclosure.md) | 中文 + +## 问题 + +`/feedback` 命令会记录一个仅写入日志的 `feedback/record` 事件并确认用户,但确认文本没有携带关于会话去向的持久信息:挂载了会话遥测(`FULL`、`FEEDBACK_ONLY` 或 `DISABLED`)的部署无法告知用户其反馈和会话是否离开了进程,确认文本也没有回显接收会话的 id。命令插件无法读取共享策略,因为遥测 seam 只暴露采集能力,而 OTel 模式枚举位于可选的后端包中。 + +## 决策 + +遥测 seam(`@deepseek-ai/dsh-session-telemetry`)现在拥有与后端无关的共享词汇:`TelemetrySharingStatus`(`full` | `feedback-only` | `disabled`),并在 `Telemetry` 服务类上增加一个必需的抽象 `sharing` 成员——每个后端都必须披露其策略,因此消费方只有在未挂载任何遥测服务时才渲染「未配置」。`@deepseek-ai/dsh-session-telemetry-otel` 在构造函数中把序列化的 `TelemetryMode`(模式语义由[反馈门控投递决策](2026-08-05-feedback-gated-session-telemetry.md)负责)映射到该状态并披露,包括 `DISABLED` 模式。`/feedback` 处理器通过插件上下文读取已挂载的服务(`ctx.get('telemetry')`,绝不是声明的注入,因此命令在无遥测时也能加载和运行),并在确认文本后追加一句共享披露:`Feedback recorded for session {id}. <句子>`。无服务 → `Session sharing is not configured.`;`disabled` → `Session sharing is disabled.`;`feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`;`full` → `Session sharing is enabled.` + +披露只陈述当前的共享策略,绝不承诺投递或留存:交接是后端的非阻塞入队,批处理、重试与丢失策略仍归后端 SDK,且后续重新配置可能改变已共享的内容,因此句子不声称任何内容已到达采集端,也不声称未来的留存。披露不新增任何会话事件,也绝不会进入模型 surface;Web 客户端通过现有的命令行(`CommandNode` 的结果文本)原样渲染,无需客户端改动。 + +## 备选方案 + +**客户端新增状态 RPC 与徽标。** 拒绝,因为确认文本由宿主生成,Web 客户端已经在命令行中原样渲染命令结果文本;单独的 RPC 会在第二个 surface 重复该状态,并为一句文案新增线上契约。 + +**在 `command-feedback` 中声明 `telemetry` 注入。** 拒绝,因为遥测是可选的:服务缺失时声明注入会导致插件加载失败,而命令必须在无遥测时可用。插件改为在处理器执行时用 `ctx.get('telemetry')` 读取服务。 + +**由 OTel 包拥有词汇。** 拒绝,因为 `command-feedback` 不能依赖可选的 OTel 后端包。seam 拥有 `TelemetrySharingStatus`,任何后端都能披露策略。 + +## 后果 + +确认文本对用户可见:它点名接收会话并报告当前的共享策略,如实说明 fire-and-forget 交接。包级测试为每种状态以及无服务场景固定句子;组装浏览器 e2e 以 FULL 模式挂载随附的遥测行(指向本地 dead 端点),并以 golden 固定随附默认句子(`Session sharing is enabled.`)。seam 成员是必需的,因此已挂载的后端总会披露策略,「未配置」句子如实地表示没有遥测服务;`/feedback` 命令在未挂载遥测时仍能正常工作。仍为空白的新 Web 会话不渲染命令行,因此首条消息之前记录的反馈没有可见确认(已在包 README 的限制中记录)。 diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts new file mode 100644 index 0000000000..6dd8ac19a0 --- /dev/null +++ b/apps/web/tests/feedback-command.e2e.ts @@ -0,0 +1,89 @@ +// Keyless assembled-browser coverage for the /feedback command over the +// shipped Web bundles and the real host wire. The command plane settles +// without a model turn: the host appends the log-only command/run + +// feedback/record + command/done lifecycle, and the transcript renders the +// acknowledgement — the recorded session id plus the session-sharing +// disclosure — as a persistent command row. The scaffold mounts the shipped +// telemetry row in FULL mode against a local dead endpoint (no record leaves +// the process), so the golden pins the shipped default sentence +// `Session sharing is enabled.`; the per-status sentences are pinned by the +// package and OTel unit tests. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const MODE = webSnapshotMode() +// Discard port: loopback listener never binds, so FULL telemetry discloses +// the shipped default policy without any record reaching a collector. +const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs' + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: /feedback command acknowledgement', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + telemetryUrl: TELEMETRY_URL, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Fresh world: connecting a workspace births the blank session whose + // live composer accepts the slash line. + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + + // First send the recorded prompt so the transcript is active — a command + // row does not render while a fresh session is still blank. + const input = page.locator('textarea').first() + await input.fill(PROMPT) + await input.press('Enter') + await scaffold.whenTurnSettled() + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + // The command plane settles without a model turn: the ack row names the + // recorded session and the mounted FULL backend's disclosure. + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a93828282e..772bd4ae91 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -245,6 +245,13 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** + * Mount the shipped telemetry row in FULL mode against this exporter URL + * instead of disabling it. Used to pin a real backend disclosure in + * assembled coverage; point the URL at a local dead endpoint so no record + * leaves the process. + */ + telemetryUrl?: string /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise failures.push(cleanupError)) + restoreSkillRootEnvironment() if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') throw error } @@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. | +| `/feedback ` | 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. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index ca74d53f25..12a4dcace0 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,11 +8,24 @@ | 输入 | 结果 | |---|---| -| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 | +| `/feedback ` | 追加 `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` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index b557eb788b..f45d504814 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -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:^" } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 8922df008e..daeee26f79 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -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) } } diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 453d9c17fc..ca965bff0d 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -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> } +/** 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 {} +} + /** 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 { +/** + * 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 { 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') diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 2060fe2207..e777f13124 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -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({ diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index 2897eb7dac..161f201cfb 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/README.i18n.yaml @@ -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/session/session-telemetry-otel/README.md -README.md: 585995ce409255df9608bc33b76625374bc67669 -README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f +README.md: e3eae475a180419c7822d51858ae156052a663d6 +README.zh.md: cfdf36ac5783850cc5e63bbb2b622584f1064b0c diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 585995ce40..e3eae475a1 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -29,6 +29,8 @@ Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`T Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present. +The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. ## What leaves the machine diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 7f0b93363f..cfdf36ac57 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -29,6 +29,8 @@ 上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 +已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。 ## 哪些数据会离开本机 diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 5a5102ca51..1f208394ff 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -22,6 +22,7 @@ import { type TelemetryBackend, type TelemetryRecord, type TelemetrySeverity, + type TelemetrySharingStatus, } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -71,6 +72,17 @@ function assertNever(value: never): never { throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`) } +/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */ +function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus { + switch (mode) { + case TelemetryMode.FULL: return 'full' + case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only' + case TelemetryMode.DISABLED: return 'disabled' + /* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */ + default: return assertNever(mode) + } +} + /** * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint @@ -139,10 +151,12 @@ export class TelemetryOtel extends Telemetry { private readonly directEmit: TelemetryBackend['emit'] private readonly provider: LoggerProvider | undefined private readonly shutdownTimeoutMillis: number + override readonly sharing: TelemetrySharingStatus constructor(ctx: Context, config: Config) { const mode = resolveMode(config.mode) super(ctx) + this.sharing = sharingStatusFor(mode) if (mode === TelemetryMode.DISABLED) { this.directEmit = DROP_RECORD this.provider = undefined diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 3e14bf3c9d..a5bb9d06ae 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -364,6 +364,31 @@ describe('TelemetryOtel wire', () => { expect(captures).toEqual([]) }) + it('discloses the sharing policy for every mode', async () => { + const { url, captures } = await mockCollector() + + const fullCtx = new Context() + await fullCtx.plugin(SessionStore) + const full = await fullCtx.plugin(TelemetryOtel, { exporter: { url } }) + expect(fullCtx.telemetry.sharing).toBe('full') + await full.dispose() + + const gatedCtx = new Context() + await gatedCtx.plugin(SessionStore) + const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } }) + expect(gatedCtx.telemetry.sharing).toBe('feedback-only') + await gated.dispose() + + const disabledCtx = new Context() + await disabledCtx.plugin(SessionStore) + const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) + expect(disabledCtx.telemetry.sharing).toBe('disabled') + await disabled.dispose() + + // No record was emitted by any mode, so nothing reached the collector. + expect(captures).toEqual([]) + }) + it('defaults direct construction to full delivery', async () => { const { url, captures } = await mockCollector() const ctx = new Context() diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index 3d4650361f..4b3169d5fe 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/README.i18n.yaml @@ -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/session/session-telemetry/README.md -README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 -README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 +README.md: 707dcfcdb0c8dfbd622630351928ac43562535ec +README.zh.md: bd080adceebf83cd9e53d72a7093db376cf6cbd1 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 827554dd53..707dcfcdb0 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -8,6 +8,12 @@ The telemetry Service Definition declares the `TelemetryBackend` contract, and i `TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. +The service also carries the required [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package. + +## The sharing disclosure + +The acknowledgement of a recorded feedback entry reports whether and how the session is shared, read from the mounted backend's `sharing`. A backend sets the property from its deployment configuration: `full` (every event is handed over as it happens), `feedback-only` (nothing is handed over until a `feedback/record` event releases the unreleased prefix through it), or `disabled` (nothing is handed over at all). Consumers map the status onto user-facing copy; the disclosure never claims delivery — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the backend SDK's. + ## Capture points In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local. diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index a350ea5935..bd080adcee 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -8,6 +8,14 @@ `TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 +该服务还携带必需的 [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface(`/feedback` 命令的确认文本)披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。 + + + +## 共享披露 + +一条已记录的反馈条目的确认文本会报告该会话是否以及如何被共享,读取自已挂载后端的 `sharing`。后端根据其部署配置设置该属性:`full`(每个事件在发生时立即交接)、`feedback-only`(在 `feedback/record` 事件释放其之前的未释放前缀之前,不交接任何内容)或 `disabled`(完全不交接任何内容)。消费方把状态映射为面向用户的文案;披露从不声称投递——交接是非阻塞入队,批处理、重试与丢失策略仍归后端 SDK。 + ## 捕获点 在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 19b58d1ee2..0900d9cdff 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -130,6 +130,15 @@ export interface TelemetryBackend { shutdown(): Promise } +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +export type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' + /** * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a @@ -141,6 +150,15 @@ export abstract class Telemetry extends Service implements TelemetryBackend { super(ctx, 'telemetry') } + /** + * Deployment-selected session-sharing policy, disclosed for acknowledgement + * surfaces that report whether recorded feedback leaves the process. Every + * backend must disclose its policy; a consumer renders "not configured" only + * when no telemetry service is mounted. The seam owns this vocabulary so the + * disclosure is backend-independent. + */ + abstract readonly sharing: TelemetrySharingStatus + /** * See {@link TelemetryBackend.emit} — that declaration is the contract's one home. * @param record - the logical record to report; owned by the backend after the call. diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..c1b1b2753a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1620,6 +1620,11 @@ "symbol": "WebBootGraph", "source": "packages/client/modules/src/client/manifest.ts" }, + { + "doc": "docs/subsystems/telemetry.md", + "symbol": "TelemetrySharingStatus", + "source": "packages/session/session-telemetry/src/index.ts" + }, { "doc": "docs/subsystems/telemetry.md", "symbol": "TelemetrySeverity", diff --git a/tsconfig.host.json b/tsconfig.host.json index 32ae7df42d..12c5d365d6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -52,6 +52,7 @@ "apps/web/tests/agent-preset-authoring.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", + "apps/web/tests/feedback-command.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts",