From d9d2b11b9f1483f5d3af9f79edb2b81129ad3537 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:03:12 +0800 Subject: [PATCH 01/23] =?UTF-8?q?wip(web):=20agent=20preset=20UI=20flow=20?= =?UTF-8?q?=E2=80=94=20creator=20intro,=20custom=20group,=20subagent=20fla?= =?UTF-8?q?sh,=20chrome=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/AgentPresetLabel.tsx | 4 +- .../src/client/AgentPresetSeat.module.css | 53 ++++ .../src/client/AgentPresetSeat.tsx | 65 ++++- .../src/client/AgentPresetSection.module.css | 12 +- .../src/client/AgentPresetSection.tsx | 237 +++++++++--------- .../ui-agent-preset/src/client/index.ts | 5 +- .../ui-agent-preset/src/client/seat-store.ts | 20 +- .../ui-agent-preset/tests/components.spec.tsx | 7 +- .../src/client/skeleton/PermissionSelect.tsx | 8 +- .../client/ui-primitives/src/icons/index.tsx | 29 +++ .../src/client/SettingsRoot.module.css | 6 +- .../ui-settings/src/client/SettingsRoot.tsx | 4 +- .../src/client/SidebarRoot.module.css | 8 +- .../src/client/SubagentCatalogAction.tsx | 8 +- .../tests/conversation-ui.spec.tsx | 19 +- .../src/client/WorkspaceBrowser.module.css | 5 +- 16 files changed, 330 insertions(+), 160 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 517a856e9a..fb4b56490c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSettingsState } from './settings-store.ts' @@ -57,7 +57,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index a4e4c50309..93d9f2b6fe 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,6 +36,59 @@ color: var(--dsw-alias-label-primary); } +/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the + name's characters fade up on a stagger (delays set inline per character). + All chars occupy their width from the start, so nothing reflows mid-run. */ +.introIcon { + animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes seat-icon-in { + from { + opacity: 0; + transform: scale(0.5); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +/* Wraps the staggered characters into one flex item, so the chip's gap + applies around the name as a whole rather than between characters. */ +.introText { + display: inline-block; + white-space: pre; +} + +.introChar { + display: inline-block; + white-space: pre; + opacity: 0; + animation: seat-char-in 0.4s ease-out forwards; +} + +@keyframes seat-char-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .introIcon, + .introChar { + animation: none; + opacity: 1; + } +} + .chevron { flex: none; color: var(--dsw-alias-label-caption); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f4357870bb..84734dccfc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' @@ -32,8 +32,17 @@ export interface AgentPresetSeatInjected { load: () => Promise /** Stage one preset for the next session. */ select: (id: string) => Promise + /** Clear the one-shot introduce cue once the chip has played it. */ + introduced: () => void } +/* Introduce timeline: the icon eases in first; the name's characters start + fading up once the icon has mostly landed, one every stagger tick, each + taking the fade duration to settle. The cue clears after the last one. */ +const INTRO_TEXT_DELAY_MS = 300 +const INTRO_CHAR_STAGGER_MS = 60 +const INTRO_CHAR_FADE_MS = 400 + /** Full component props. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -45,7 +54,7 @@ export type AgentPresetSeatProps = * @param props - composed slot props. * @returns the chip, or null when the deployment composes no presets. */ -export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { +export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) { const state = useAgentPresetSeat(snapshot => snapshot) const [open, setOpen] = useState(false) @@ -53,12 +62,52 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr void load() }, [load]) - // Nothing to choose between: the deployment composes no presets and every - // session shares the host composition. - if (state.options.length === 0 || state.current === '') return null - const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) + const label = chosenText?.name ?? state.current + const ready = state.options.length > 0 && state.current !== '' + + // The introduce cue: the pick was staged from another screen (the settings + // creator entry), so the chip announces it — the icon eases in and each + // character of the name fades up on a stagger (CSS owns the motion; this + // effect only arms it and acknowledges the cue once the run is over). + const [introducing, setIntroducing] = useState(false) + useEffect(() => { + if (!state.introduce || !ready) return + const characters = Array.from(label) + if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + introduced() + return + } + setIntroducing(true) + const done = window.setTimeout(() => { + setIntroducing(false) + introduced() + }, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS) + return () => { window.clearTimeout(done) } + }, [state.introduce, ready, label, introduced]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (!ready) return null + + // One wrapper span: the chip is a flex row with a gap, so loose character + // spans would each pick up the gap between them. + const shownLabel = introducing + ? ( + + {Array.from(label).map((character, index) => ( + + {character} + + ))} + + ) + : label return ( { setOpen(value => !value) }} > - - {chosenText?.name ?? state.current} + + {shownLabel} )} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index f29bf7cdf5..79dca43f7b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -363,6 +363,7 @@ create button vacated. Dashed like the Models page's add affordances: it reads as a place a preset will appear, not a command. */ .creatorButton { + box-sizing: border-box; align-self: stretch; display: flex; align-items: center; @@ -372,17 +373,18 @@ border: 1px dashed var(--dsw-alias-border-l3); border-radius: 12px; font: inherit; - font-size: 13px; - background: none; - color: inherit; + font-size: 14px; + line-height: 22px; + background: transparent; + color: var(--dsw-alias-label-primary); cursor: pointer; } .creatorButton:hover:not(:disabled) { - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-interactive-bg-hover); } .creatorButton:disabled { - opacity: 0.5; + opacity: 0.4; cursor: default; } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index f5a31fcdf8..4580f436bc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { ) } + /* The guided alternative to copying: the self-referential preset can + read this very composition and author a new one in conversation. + Offered only where that preset is actually on the roster and a + session can be landed; without a writable root the draft could + never be discovered, so the reason rides the disabled button. */ + const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') + ? ( + + ) + : null + return (

{t('nav')}

@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const group = state.rows .filter(row => row.trust === trust) .map(row => ({ row, text: presetDisplayText(row, t) })) - if (group.length === 0) return null + // The custom group is where a preset of one's own will appear, so it + // stays on screen even while empty: heading plus the creator entry. + const tail = trust === 'user' ? creatorButton : null + if (group.length === 0 && tail === null) return null return (

{heading}

-
    - {group.map(({ row, text }) => ( -
  • - {/* The card body IS the control: picking a preset is the + {group.length === 0 ? null : ( +
      + {group.map(({ row, text }) => ( +
    • + {/* The card body IS the control: picking a preset is the common act, so it should not hide behind a small button. The action row sits outside it — nesting buttons is invalid, and these act on the card rather than select it. A broken preset cannot compose a session, so its body is disabled and the card says why instead of offering it. */} - -
      - {/* Shipped presets are the compositions a copy starts + {text.description ?? t('noDescription')} + {row.broken === undefined + ? null + : {row.broken}} + {row.id} + +
      + {/* Shipped presets are the compositions a copy starts from, so READING one is the point; a custom preset is edited in its files instead, which the location action leads to. A broken shipped preset has no readable composition to offer, so its viewer is withheld; a broken custom one keeps the location action — the files are where it gets fixed. */} - {row.trust === 'system' - ? row.broken === undefined - ? ( + {row.trust === 'system' + ? row.broken === undefined + ? ( + + ) + : null + : ( + )} + + {row.trust === 'user' + ? ( + ) - : null + : null} +
      + {state.revealedPaths[row.id] === undefined + ? null : ( - +

      + {t('revealedPathLabel')} + {state.revealedPaths[row.id]} +

      )} - - {row.trust === 'user' - ? ( - - ) - : null} -
      - {state.revealedPaths[row.id] === undefined - ? null - : ( -

      - {t('revealedPathLabel')} - {state.revealedPaths[row.id]} -

      - )} -
    • - ))} -
    +
  • + ))} +
+ )} + {tail}
) })} - {/* The guided alternative to copying: the self-referential preset can - read this very composition and author a new one in conversation. - Offered only where that preset is actually on the roster and a - session can be landed; without a writable root the draft could - never be discovered, so the reason rides the disabled button. */} - {props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') - ? ( - - ) - : null} seat.load(), select: (id: string) => seat.select(id), + introduced: () => { seat.introduced() }, }) const labelInjected = (): AgentPresetLabelInjected => ({ @@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void { // on: the chip's list-change applier composes the blank session the // workspace connect produces or reuses. creatorDraft = () => { - seat.stage('cordis') + // The introduce cue makes the chip announce the pick the user never + // made on this screen — the stage happened back in settings. + seat.stage('cordis', true) scope.workspaces.startSession() } const chip = scope.slots.register({ diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 27a414e4a3..ab973ec5b5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -26,10 +26,16 @@ export interface AgentPresetSeatState { /** A rejected apply's message, cleared by the next attempt. */ error: string | null busy: boolean + /** + * One-shot cue that the chip should introduce itself (the creator-draft + * entry staged the pick from another screen, so the user never touched the + * chip); the renderer clears it via `introduced()` once played. + */ + introduce: boolean } const INITIAL: AgentPresetSeatState = { - options: [], current: '', error: null, busy: false, + options: [], current: '', error: null, busy: false, introduce: false, } /** One session's identity and whether it has started. */ @@ -121,10 +127,18 @@ export class AgentPresetSeatController { * list-change applier, which fires when the started session becomes * current. * @param id - the preset to stage. + * @param introduce - true when the stage came from another screen and the + * chip should announce itself on the session it lands on. */ - stage(id: string): void { + stage(id: string, introduce = false): void { this.staged = id - this.set({ current: id, error: null }) + this.set({ current: id, error: null, introduce }) + } + + /** Acknowledge the introduction cue once the chip has played it. */ + introduced(): void { + if (!this.store.getSnapshot().introduce) return + this.set({ introduce: false }) } /** diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 8a37a7af43..b63b9ce63c 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = { ], busy: false, error: null, + introduce: false, } function renderRow(state: Partial = {}) { @@ -56,7 +57,11 @@ function renderRow(state: Partial = {}) { function renderSeat(state: Partial = {}) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + const actions = { + load: vi.fn(() => Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + introduced: vi.fn(), + } render({permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} - - - + {/* Same glyph + open rotation as the sibling ModelSelect trigger; + class on the svg itself — an inline wrapper span leaves + baseline descent under the icon and floats it off-center. */} + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 02f4913751..354adc4454 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_agent_preset_outline_16. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + + +) + /** ic_ds_browse_outline_16 */ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 9163e68ba8..04e8c98cd6 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(800px, calc(100vh - 48px)); + height: min(824px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; @@ -205,11 +205,11 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */ .options { flex: 1; min-height: 0; - padding: 0 24px 8px; + padding: 0 24px 24px; overflow-y: auto; } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 54e0e0dbb7..de00fa372e 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -14,7 +14,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { - IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, + IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' @@ -22,7 +22,7 @@ import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return - if (id === 'agent-presets') return + if (id === 'agent-presets') return return } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index a98d8ea26e..67310853a2 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -84,7 +84,7 @@ gap: 8px; height: 60px; padding: 8px 0 8px 4px; - margin-bottom: 16px; + margin-bottom: 8px; box-sizing: border-box; overflow: hidden; } @@ -157,8 +157,8 @@ color: var(--dsw-alias-label-primary); } -/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the - rail's plain icon control. */ +/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off + corners); collapsed it renders as the rail's plain icon control. */ .newSession { flex: none; display: flex; @@ -170,7 +170,7 @@ margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsw-alias-button-elevated-fill); color: var(--dsw-alias-label-primary); font-size: 14px; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 3a6ace14a5..2a730245e7 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -519,8 +519,14 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) + // Visibility needs evidence of children (entries, summary-known descendants, + // or a failed load worth retrying). A bare loading catalog is not evidence: + // selecting any session schedules a refresh whose loading snapshot would + // otherwise flash the action in and out on childless sessions. const visible = presentedCatalog !== undefined - && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) + && (presentedCatalog.state === 'error' + || presentedCatalog.entries.length > 0 + || descendantCount > 0) useEffect(() => { if (visible || !open) return setOpen(false) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..ae6c0f07ee 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -522,22 +522,23 @@ describe('SubagentCatalogAction', () => { expect(staleEmpty.openChild).not.toHaveBeenCalled() }) - it('renders empty loading and fallback error states without focusable rows', async () => { + it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => { + // Selecting any session schedules a catalog refresh; a loading snapshot + // with no other evidence of children must not flash the action in. const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() - const trigger = screen.getByRole('button', { name: /0 个子代理/ }) - fireEvent.click(trigger) - expect(screen.getByText('正在加载子代理…')).toBeTruthy() - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - await Promise.resolve() - expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) + expect(screen.queryByRole('button')).toBeNull() view.unmount() const failed = props(catalog({ entries: [], state: 'error', error: null })) render() - fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ })) + const trigger = screen.getByRole('button', { name: /0 个子代理/ }) + fireEvent.click(trigger) expect(screen.getByText('无法加载子代理')).toBeTruthy() + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + await Promise.resolve() + expect(screen.getByRole('tree')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) }) it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6052b5075f..6c6c44c2c7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -64,7 +64,8 @@ line-height: 20px; } -/* Search input: 38px capsule (figma 133:7649); rail state renders it as the +/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off + corners); rail state renders it as the region's search control. Upstream binds a dedicated design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token pinned to the static scale mirrors it. */ @@ -79,7 +80,7 @@ padding: 0 14px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsh-search-input-fill); color: var(--dsw-alias-label-caption); overflow: hidden; From a95e3265f6b596f2cec47b594c7ff967004c2fd9 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:41:32 +0800 Subject: [PATCH 02/23] fix(web): polish preset chrome and subagent menu after review --- .../ui-agent-preset/src/client/AgentPresetLabel.module.css | 2 +- .../client/ui-agent-preset/src/client/AgentPresetLabel.tsx | 2 +- .../src/client/AgentPresetSection.module.css | 6 ++++++ .../src/client/skeleton/ConversationRoot.module.css | 1 + .../client/ui-settings/src/client/SettingsRoot.module.css | 2 +- .../ui-subagent/src/client/SubagentCatalogAction.module.css | 1 - 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index 5468f0d592..6d2cdd814b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -5,7 +5,7 @@ align-items: center; gap: 4px; max-width: 180px; - padding: 0 8px; + padding: 0 2px 0 0; height: 22px; border-radius: 6px; background: var(--dsw-alias-fill-tsp-secondary); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index fb4b56490c..3e98310cca 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -57,7 +57,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 79dca43f7b..0438e23b17 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -26,6 +26,12 @@ gap: 10px; } +/* Group-to-group breathing room: the section's 12px gap plus 20px reads the + two rosters as separate blocks (32px total). */ +.group + .group { + margin-top: 20px; +} + .groupHead { margin: 0; font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index ca15f77c4d..040e656bd8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -292,6 +292,7 @@ .heroWorkspaceRow { display: flex; align-items: center; + gap: 2px; min-width: 0; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 04e8c98cd6..f1bd87e9af 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(824px, calc(100vh - 48px)); + height: min(800px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index fc3ddfea46..75f0040cf6 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -54,7 +54,6 @@ max-height: min(560px, calc(100vh - 140px)); padding: 4px; overflow: auto; - border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); From 4bee3f73ef887f35825fc8b155a0d6e36646d1f1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:49:45 +0800 Subject: [PATCH 03/23] fix(web): tighten hero row spacing and round the hero chips --- .../ui-agent-preset/src/client/AgentPresetSeat.module.css | 2 +- .../src/client/skeleton/ConversationRoot.module.css | 6 ++++-- .../src/client/skeleton/HeroShell.module.css | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 93d9f2b6fe..0763ffff02 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -9,7 +9,7 @@ min-height: 28px; padding: 0 8px; border: none; - border-radius: 12px; + border-radius: 16px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 040e656bd8..932fd8e4e8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -263,8 +263,9 @@ .composerHero { position: relative; /* .heroGlow positioning context */ align-self: center; - /* figma 75:8208: 12 between hero chrome / workspace row / card. */ - gap: 12px; + /* figma 75:8208 drew 12 between all three rows; the workspace row now sits + 6 above the card (its margin-top restores 12 under the hero chrome). */ + gap: 6px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; /* Card cap + both clearances: the hero input card lands at exactly the same @@ -294,6 +295,7 @@ align-items: center; gap: 2px; min-width: 0; + margin-top: 6px; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ padding-left: 20px; diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 0e730a5b30..3d9281b96b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -105,7 +105,7 @@ min-height: 28px; padding: 0 8px; border: none; - border-radius: 12px; + border-radius: 16px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; From 021ecb53c5b3781d959fb4ab8195cf13c2b463be Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:52:23 +0800 Subject: [PATCH 04/23] fix(web): set hero row-to-card spacing to 8 --- .../src/client/skeleton/ConversationRoot.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 932fd8e4e8..971661cd48 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -264,8 +264,8 @@ position: relative; /* .heroGlow positioning context */ align-self: center; /* figma 75:8208 drew 12 between all three rows; the workspace row now sits - 6 above the card (its margin-top restores 12 under the hero chrome). */ - gap: 6px; + 8 above the card (its margin-top restores 12 under the hero chrome). */ + gap: 8px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; /* Card cap + both clearances: the hero input card lands at exactly the same @@ -295,7 +295,7 @@ align-items: center; gap: 2px; min-width: 0; - margin-top: 6px; + margin-top: 4px; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ padding-left: 20px; From eb298a439fa6e2ad1bf0b5c64b6ed98113309974 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 22:54:19 +0800 Subject: [PATCH 05/23] test(web): count the preset icon and keep the access chevron aria-hidden --- .../src/client/skeleton/PermissionSelect.module.css | 3 +++ .../src/client/skeleton/PermissionSelect.tsx | 8 ++++---- packages/client/ui-primitives/src/icons/index.tsx | 2 +- packages/client/ui-primitives/tests/icons.spec.tsx | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 60aceaa120..22f64d6e61 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -51,6 +51,9 @@ } .chevron { + /* inline-flex, not inline: an inline seat reserves baseline descent under + the svg and floats the glyph off-center in the 28px trigger. */ + display: inline-flex; flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 47bd4e274d..73f4080c1c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -147,10 +147,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect {permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger; - class on the svg itself — an inline wrapper span leaves - baseline descent under the icon and floats it off-center. */} - + {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} + + + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 354adc4454..972e0ec14d 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,7 +349,7 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** ic_ds_agent_preset_outline_16. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */ export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index fd15671b73..f6560a4cc1 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(67) + it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(68) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { From 00708b950b5d562515377453ce856caa53f44e33 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:13:32 +0800 Subject: [PATCH 06/23] feat: direct issue status from PR review events --- .agents/notes/archived/manifest.json | 6 + ...-04-forward-only-pr-issue-status.i18n.yaml | 4 +- ...2026-08-04-forward-only-pr-issue-status.md | 1 + ...6-08-04-forward-only-pr-issue-status.zh.md | 1 + ...-driven-issue-lifecycle-triggers.i18n.yaml | 4 +- ...-review-driven-issue-lifecycle-triggers.md | 1 + ...view-driven-issue-lifecycle-triggers.zh.md | 1 + ...-event-directed-pr-review-status.i18n.yaml | 6 + ...6-08-10-event-directed-pr-review-status.md | 41 ++++++ ...8-10-event-directed-pr-review-status.zh.md | 41 ++++++ .github/issue-management/config.json | 1 + .github/issue-management/policy.mjs | 139 ++++++++++++++---- .github/issue-management/policy.test.mjs | 88 ++++++++--- .github/workflows/issue-lifecycle.yml | 1 + lefthook.yml | 4 + scripts/ci-workflow.spec.ts | 31 +++- 16 files changed, 313 insertions(+), 57 deletions(-) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.zh.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml (66%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md (99%) create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..f1613ab0d6 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -301,6 +301,12 @@ "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", + "process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925", + "process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501", + "process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml similarity index 68% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml index b8e885d109..a7df92883f 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.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/process/2026-08-04-forward-only-pr-issue-status.md -2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 -2026-08-04-forward-only-pr-issue-status.zh.md: f7fee58d6afb812f97569ae4d86c3d6504f35752 +2026-08-04-forward-only-pr-issue-status.md: 56004a39ce52c77429574f481d9945cdc4936d30 +2026-08-04-forward-only-pr-issue-status.zh.md: ee85319842d3245bdfab9668de0a42ab29597fac diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md index dd567707bc..56004a39ce 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md @@ -1,6 +1,7 @@ # Agent Note: Forward-only PR-to-Issue status projection Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md index f7fee58d6a..ee85319842 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -1,6 +1,7 @@ # Agent Note: PR 到 Issue 的状态仅向前投射 Status: implemented +Archived: 2026-08-10 [English](2026-08-04-forward-only-pr-issue-status.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml similarity index 66% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml index a82d54640c..4c3a8c8db5 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.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/process/2026-08-08-review-driven-issue-lifecycle-triggers.md -2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83 -2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507 +2026-08-08-review-driven-issue-lifecycle-triggers.md: 444927968912d93f473e27ae8576e8371b9c287c +2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 6e00e2a936b6421824743e779756011fcd4a1c9e diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md index 8a2d48ee23..4449279689 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -1,6 +1,7 @@ # Agent Note: Review-driven Issue lifecycle triggers Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md index 004739ff47..6e00e2a936 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -1,6 +1,7 @@ # Agent Note: 由评审驱动的 Issue 生命周期触发器 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml new file mode 100644 index 0000000000..08607d5317 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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/process/2026-08-10-event-directed-pr-review-status.md +2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1 +2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md new file mode 100644 index 0000000000..9db9c64fc8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -0,0 +1,41 @@ +# Agent Note: Event-directed PR review status commands + +Status: implemented + +English | [中文](2026-08-10-event-directed-pr-review-status.zh.md) + +## Problem + +The Issue Project status records who owns the next step of resolving work. Aggregate pull-request review state answers whether GitHub considers the pull request mergeable, but it cannot represent that handoff: an earlier `CHANGES_REQUESTED` review can remain effective after the author fixes the code and requests review again. + +A monotonic projection also cannot return an automation-owned Issue from `In review` to `In progress` when a reviewer requests changes. Reconstructing review rounds or reviewer blockers would add state that the required two-event contract does not need. + +## Decision + +The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed. + +Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. + +The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. + +[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review. + +## Verification + +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger. + +## Alternatives considered + +**Derive status from `reviewDecision` or a reconstructed review round.** GitHub's aggregate can remain `CHANGES_REQUESTED` after a repeated review request, while a round reducer introduces reviewer and ordering semantics beyond the two explicit handoffs. + +**Keep the forward-only projection.** Monotonic advancement protects later statuses, but it leaves an Issue in `In review` while the author is implementing requested changes. + +**Apply every review command unconditionally.** This is the smallest event handler, but it lets automation overwrite a human-owned Project status. The latest target-Project status actor therefore guards the only backward transition. + +**Restore `ready_for_review` or add a debounce queue.** Ready status carries neither review handoff, while another queue adds latency and control-plane state without changing either command. + +## Consequences + +A repeated review request moves an automation-managed resolving Issue to `In review` even while GitHub still reports an older blocking review. A later changes-requested review returns it to `In progress`; approval, comments, dismissal, pushes, and reviewer removal leave the most recent command's status unchanged. + +The projection remains event-driven and does not repair an event that never runs. Replaying an old workflow run can replay its old command, and ProjectV2 still provides no atomic compare-and-swap between the latest-state read and mutation. Per-pull-request workflow concurrency and the human-ownership guard reduce these races without introducing durable lifecycle state. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md new file mode 100644 index 0000000000..381a3f64a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 由事件直接指定的 PR 评审状态命令 + +Status: implemented + +[English](2026-08-10-event-directed-pr-review-status.md) | 中文 + +## 问题 + +Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责。PR(Pull Request)的汇总评审状态可以回答 GitHub 是否认为该 PR 可合并,却无法表示这次交接:作者修复代码并重新请求评审后,先前的 `CHANGES_REQUESTED` 评审仍可能继续生效。 + +单调投影也无法在评审人提出修改要求时,将由自动化管理的 Issue 从 `In review` 退回 `In progress`。重建评审轮次或评审人阻塞项会引入既定双事件约定并不需要的状态。 + +## 决策 + +Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。 + +工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 + +处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。 + +[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。 + +## 验证 + +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。 + +## 考虑过的替代方案 + +**根据 `reviewDecision` 或重建的评审轮次派生状态。** GitHub 的汇总状态在重复请求评审后仍可能保持为 `CHANGES_REQUESTED`,而轮次归约器会引入超出两个显式交接动作所需范围的评审人语义和顺序语义。 + +**保留只向前推进的投影。** 单调推进可保护较后的状态不被回退,但作者正在按要求修改代码时,Issue 会一直停留在 `In review`。 + +**无条件应用每条评审命令。** 这是最精简的事件处理器,但会让自动化覆盖由人工管理的 Project 状态。因此,处理器通过目标 Project 最新状态事件的执行主体保护唯一允许的回退转换。 + +**恢复 `ready_for_review` 或添加防抖队列。** Ready 状态并不表示两种评审交接中的任何一种;新增队列只会增加延迟和控制平面状态,不会改变任一命令。 + +## 后果 + +即使 GitHub 仍报告一个较早的阻塞性评审,重复请求评审也会将正由当前 PR 解决且由自动化管理的 Issue 推进至 `In review`。后续提出修改要求的评审会将其退回 `In progress`;批准、评论、撤销评审、推送和移除评审人都不会改变最近一条命令设定的状态。 + +投影仍由事件驱动;如果某个事件从未触发工作流运行,投影不会自行修复。回放旧的工作流运行可能会再次执行其中的旧命令;ProjectV2 仍不提供在读取最新状态与执行变更之间进行原子比较并交换(compare-and-swap)的能力。以单个 PR 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。 diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json index 41019f0aa2..5dc925f245 100644 --- a/.github/issue-management/config.json +++ b/.github/issue-management/config.json @@ -3,6 +3,7 @@ "repository": "deepseek-harness", "projectNumber": 1, "projectTitle": "DSH Issue Management", + "lifecycleActor": "dsh-issue-management", "priorityField": "Priority", "allowUnassignedOwner": true, "statuses": [ diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 2125ba2f36..24a82cf15f 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([ ]) const TERMINAL_STATUSES = new Set(['Done', 'No action']) const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) +const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([ + 'opened', + 'edited', + 'synchronize', + 'reopened', + 'labeled', + 'unlabeled', +]) for (const status of ['In progress', 'In review']) { if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) } +if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) { + throw new Error('config.lifecycleActor 未设置') +} /** * Return Markdown outside balanced details elements. @@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({ } /** - * Derive a forward-only Issue status from the current PR phase. - * @param {string|null} currentStatus Current Project status. - * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. - * @returns {string|null} Status to write, or null when no forward transition exists. + * Translate a repository event into one resolving-Issue lifecycle command. + * @param {string} eventName GitHub event name. + * @param {{action?: string, review?: {state?: string}}} event GitHub event payload. + * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command. */ -export function nextResolvingIssueStatus(currentStatus, pull) { - const target = - !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) - ? 'In review' - : 'In progress' +export function resolvingIssueStatusCommand(eventName, event) { + if (eventName === 'pull_request') { + if (event.action === 'review_requested') return 'review-requested' + return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null + } + if ( + eventName === 'pull_request_review' && + event.action === 'submitted' && + event.review?.state?.toLowerCase() === 'changes_requested' + ) { + return 'changes-requested' + } + return null +} + +/** + * Plan one event-directed resolving-Issue status transition. + * @param {string|null} currentStatus Current Project status. + * @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command. + * @param {string|null} currentStatusActor Actor that last set the current Project status. + * @returns {string|null} Status to write, or null when no permitted transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) { + let target + if (command === 'review-requested') target = 'In review' + else if (command === 'implementation' || command === 'changes-requested') target = 'In progress' + else throw new Error(`未知 lifecycle command:${command}`) + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + if ( + command === 'changes-requested' && + currentStatus === 'In review' && + currentStatusActor === config.lifecycleActor + ) { + return target + } return currentIndex >= 0 && currentIndex < targetIndex ? target : null } @@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) { } } -async function projectContext(number) { +async function projectContext(number, includeStatusActor = false) { const data = await graphql( - `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + `query( + $organization: String! + $repository: String! + $number: Int! + $project: Int! + $includeStatusActor: Boolean! + ) { organization(login: $organization) { projectV2(number: $project) { id @@ -413,6 +460,16 @@ async function projectContext(number) { repository(owner: $organization, name: $repository) { issue(number: $number) { id + timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT]) + @include(if: $includeStatusActor) { + nodes { + ... on ProjectV2ItemStatusChangedEvent { + actor { login } + project { id } + status + } + } + } projectItems(first: 20, includeArchived: true) { nodes { id @@ -430,6 +487,7 @@ async function projectContext(number) { repository: config.repository, number, project: config.projectNumber, + includeStatusActor, }, ) const project = data.organization?.projectV2 @@ -439,7 +497,14 @@ async function projectContext(number) { const statusField = project.fields.nodes.find((field) => field?.name === 'Status') if (!statusField) throw new Error('Project 缺少 Status 字段') const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) - return { project, issue, statusField, item } + const latestStatusEvent = issue.timelineItems?.nodes + ?.filter((event) => event?.project?.id === project.id) + .at(-1) + const statusActor = + latestStatusEvent?.status === item?.fieldValueByName?.name + ? (latestStatusEvent.actor?.login ?? null) + : null + return { project, issue, statusField, item, statusActor } } async function projectStatus(number) { @@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) { return errors } -async function pullRequestSnapshot(number) { - const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) - const [reviewRequests, reviews] = await Promise.all([ - api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), - api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), - ]) +async function resolvingReferencesSnapshot(number, pull) { const references = parseReferences({ body: pull.body ?? '', repository: `${config.organization}/${config.repository}`, @@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) { } return { number, - isDraft: pull.draft, - authorType: pull.user?.type ?? 'User', - reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, - reviewCount: reviews.length, - labels: pull.labels.map((label) => label.name), references: retainIssueReferences(references, issues), issues, } } -async function advanceResolvingIssues(pull) { +async function pullRequestSnapshot(number) { + const [pull, reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const resolving = await resolvingReferencesSnapshot(number, pull) + return { + ...resolving, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + } +} + +async function lifecyclePullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + return resolvingReferencesSnapshot(number, pull) +} + +async function transitionResolvingIssues(pull, command) { for (const number of pull.references.resolving) { - const context = await projectContext(number) - const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) + const context = await projectContext(number, command === 'changes-requested') + const target = nextResolvingIssueStatus( + context.item?.fieldValueByName?.name ?? null, + command, + context.statusActor, + ) if (!target) continue // TODO: Replace this latest-state guard with per-Issue serialization or a // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. @@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) { } if (eventName === 'pull_request' || eventName === 'pull_request_review') { - const pull = await pullRequestSnapshot(event.pull_request.number) - await advanceResolvingIssues(pull) + const command = resolvingIssueStatusCommand(eventName, event) + if (!command) return + const pull = await lifecyclePullRequestSnapshot(event.pull_request.number) + await transitionResolvingIssues(pull, command) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8a9b0f91e6..c03a7c3513 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -6,6 +6,7 @@ import { nextResolvingIssueStatus, parseReferences, retainIssueReferences, + resolvingIssueStatusCommand, requiresPullRequestPolicy, validateBody, validateIssue, @@ -243,32 +244,73 @@ test('requires policy only after a human PR enters review', () => { ) }) -test('advances resolving Issues to the live PR phase', () => { - const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } - const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } - const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } - const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } - - for (const status of ['Inbox', 'Backlog', 'Ready']) { - assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') - assert.equal(nextResolvingIssueStatus(status, open), 'In progress') - assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') - assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') +test('maps only explicit review handoffs to review status commands', () => { + assert.equal( + resolvingIssueStatusCommand('pull_request', { + action: 'review_requested', + }), + 'review-requested', + ) + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'submitted', + review: { state: 'changes_requested' }, + }), + 'changes-requested', + ) + for (const state of ['approved', 'commented']) { + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'submitted', + review: { state }, + }), + null, + ) } - assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') - assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'dismissed', + review: { state: 'changes_requested' }, + }), + null, + ) }) -test('never regresses or reopens a resolving Issue', () => { - const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } - const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } +test('keeps ordinary pull request events as forward-only implementation signals', () => { + for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) { + assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation') + } + assert.equal( + resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }), + null, + ) +}) - assert.equal(nextResolvingIssueStatus('In progress', implementation), null) - assert.equal(nextResolvingIssueStatus('In review', implementation), null) - assert.equal(nextResolvingIssueStatus('In review', review), null) - assert.equal(nextResolvingIssueStatus('Done', review), null) - assert.equal(nextResolvingIssueStatus('No action', review), null) - assert.equal(nextResolvingIssueStatus(null, review), null) +test('toggles automation-owned work on request changes and repeated review request', () => { + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress') + assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review') + assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress') + } + let status = nextResolvingIssueStatus( + 'In review', + 'changes-requested', + 'dsh-issue-management', + ) + assert.equal(status, 'In progress') + status = nextResolvingIssueStatus(status, 'review-requested') + assert.equal(status, 'In review') +}) + +test('preserves human review status and terminal Issues', () => { + assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null) + assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null) + assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null) + assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null) + assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null) + assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null) + assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null) + assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null) }) test('keeps lifecycle projection independent of PR metadata enforcement', () => { @@ -283,7 +325,7 @@ test('keeps lifecycle projection independent of PR metadata enforcement', () => } assert.ok(validatePullRequest(pull).length > 0) - assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') + assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review') }) test('exempts Draft, Bot, and App PRs', () => { diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 7a25b5223d..300d8e4bfa 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,6 +36,7 @@ concurrency: jobs: lifecycle: name: Issue lifecycle + if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/lefthook.yml b/lefthook.yml index 0ea7f4e537..7ed1fcb886 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,6 +6,8 @@ pre-commit: jobs: - name: translation pairing (staged records) glob: '*.i18n.yaml' + exclude: + - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} - name: lint (staged) @@ -35,6 +37,8 @@ pre-merge-commit: jobs: - name: translation pairing (staged records) glob: '*.i18n.yaml' + exclude: + - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} pre-push: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2269c7cbee..a67dc0818a 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -113,20 +113,40 @@ describe('E2B e2e workflow', () => { }) describe('Issue lifecycle workflow', () => { - it('uses review signals instead of rerunning when a draft becomes ready', () => { + it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') const policy = loadWorkflow('.github/workflows/issue-policy.yml') const policyPullRequest = workflowEvent(policy, 'pull_request') expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') - expect(lifecycleReview.types).toContain('submitted') + expect(lifecycleReview.types).toEqual(['submitted']) + expect(lifecycleJob.if).toBe( + "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}", + ) expect(policyPullRequest.types).toContain('ready_for_review') }) }) +describe('Git hooks', () => { + it('leaves frozen Agent Note sidecars to the archive verifier', () => { + const lefthook = loadWorkflow('lefthook.yml') + + for (const hookName of ['pre-commit', 'pre-merge-commit']) { + const hook = lefthook[hookName] + if (!isRecord(hook) || !Array.isArray(hook.jobs)) { + throw new TypeError(`lefthook must define ${hookName} jobs`) + } + const pairing = hook.jobs.find(job => isRecord(job) && job.name === 'translation pairing (staged records)') + + expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) + } + }) +}) + function loadWorkflow(path: string): Record { const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`) @@ -140,6 +160,13 @@ function workflowEvent(workflow: Record, event: string): Record return workflow.on[event] } +function workflowJob(workflow: Record, job: string): Record { + if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) { + throw new TypeError(`workflow must define the ${job} job`) + } + return workflow.jobs[job] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } From 9e8cd1acfb94613f121cfd105fd36a26e6a0d406 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:14:27 +0800 Subject: [PATCH 07/23] test(web): re-record the preset section golden and borderless menu inset --- .../tests/snapshots/agent-preset-authoring/section.expected.md | 1 + apps/web/tests/subagent-conversation.e2e.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index dcbe72641c..9f87471f02 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -58,6 +58,7 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - heading "自定义" [level=3] - button "用「创造模式」创作自定义预设": - img - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index fa33e5cd3d..6756b093d2 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - treeBox!.x), Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - ]).toEqual([5, 5]) + // Menu padding alone insets the rows now that the border is gone. + ]).toEqual([4, 4]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), From a8fae974c2be649633990080f7060421dfeb0213 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:19:23 +0800 Subject: [PATCH 08/23] test(web): the custom group heading outlives its last preset --- apps/web/tests/agent-preset-authoring.e2e.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index e8ff5aa538..6a13b791c5 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) - // Custom group gone with its only member; the shipped set stands. - expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + // The custom group outlives its only member: the heading stays with the + // creator entry so the place to author a preset never disappears. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1) expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) }, 60_000) From 3716459223f7f23a78639b35da608141fb1f95b9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:05 +0800 Subject: [PATCH 09/23] fix(ci): narrow issue lifecycle review events --- .github/workflows/issue-lifecycle.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 300d8e4bfa..e324cfefc2 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,7 +36,7 @@ concurrency: jobs: lifecycle: name: Issue lifecycle - if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} + if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }} runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index a67dc0818a..db5ea9a0fa 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -125,7 +125,7 @@ describe('Issue lifecycle workflow', () => { expect(lifecyclePullRequest.types).toContain('review_requested') expect(lifecycleReview.types).toEqual(['submitted']) expect(lifecycleJob.if).toBe( - "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}", + "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", ) expect(policyPullRequest.types).toContain('ready_for_review') }) From e9a3a388736700f19e3a4ec9d21b53de58aad47f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:55:27 +0800 Subject: [PATCH 10/23] fix(ci): type workflow fixture search safely --- scripts/ci-workflow.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db5ea9a0fa..2e4df208f4 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -140,7 +140,9 @@ describe('Git hooks', () => { if (!isRecord(hook) || !Array.isArray(hook.jobs)) { throw new TypeError(`lefthook must define ${hookName} jobs`) } - const pairing = hook.jobs.find(job => isRecord(job) && job.name === 'translation pairing (staged records)') + const pairing: unknown = hook.jobs.find( + (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)', + ) expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) } From cac8e1c53deb24bc0123463391046b85811aef2b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:59:53 +0800 Subject: [PATCH 11/23] fix(client): finish the preset intro inside one shared reveal window A fixed 60ms per-character tick made a Latin preset name run three times longer than its CJK counterpart. The stagger is now capped by a 200ms shared window (min(40, 200/(n-1))), the icon lands in 150ms with the characters starting the moment it does, and the whole timeline is pinned by component tests alongside the store acknowledgement and the empty custom group. --- ...0-creator-guidance-introduce-cue.i18n.yaml | 6 ++ ...26-08-10-creator-guidance-introduce-cue.md | 33 +++++++ ...08-10-creator-guidance-introduce-cue.zh.md | 33 +++++++ .../src/client/AgentPresetSeat.module.css | 10 ++- .../src/client/AgentPresetSeat.tsx | 32 +++++-- .../ui-agent-preset/tests/apply.spec.ts | 9 ++ .../ui-agent-preset/tests/components.spec.tsx | 88 +++++++++++++++++++ .../ui-agent-preset/tests/section.spec.tsx | 14 +++ 8 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml new file mode 100644 index 0000000000..08233cc4f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.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-10-creator-guidance-introduce-cue.md +2026-08-10-creator-guidance-introduce-cue.md: 888fee7b3def585ed3098fedcb7bc6169ee26a22 +2026-08-10-creator-guidance-introduce-cue.zh.md: d80260abd1995df1f95e3f24fefcb265bda64c11 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md new file mode 100644 index 0000000000..888fee7b3d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md @@ -0,0 +1,33 @@ +# Agent Note: Creator guidance lands as an introduce cue on the preset chip + +Status: implemented + +English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md) + +## Problem + +Authoring a preset happens inside a Creator-mode session, but the settings section gave no path into that fact. The creator entry sat outside the roster groups, the custom group vanished entirely while it had no member, and clicking the entry dropped the user onto the new-session screen with nothing marking what had changed: the staged preset chip rendered exactly as if the user had picked it by hand. Users reported not understanding that the flow had moved, or that the session they were about to start was the place where the preset gets built (#2184). + +## Decision + +The custom group stays on screen while empty — heading plus the creator entry, which lives inside the group as the standing "your preset will appear here" affordance rather than floating below the roster. + +A pick staged from another screen carries a one-shot `introduce` flag through the seat store (`stage(id, introduce)`), and the chip announces it: the preset icon eases in over 150ms, then the name's characters fade up on a stagger the moment the icon lands. The stagger is capped twice — 40ms per tick for short CJK names, and one shared 200ms reveal window (`min(40, 200/(n-1))`) so a long Latin name finishes in the same time as its CJK counterpart instead of dragging the run out per character. CSS owns the motion; the component arms it and acknowledges the cue once the run is over, so the flag never replays on a later mount. `prefers-reduced-motion` and an empty display name acknowledge immediately with no run. + +The cue is pure presentation: it is client-side seat-store state, never a session event, because the model-visible composition is already carried by the staged preset itself. + +## Alternatives considered + +**A toast or callout on the new-session screen.** It explains more, but it points at nothing — the chip is the artifact the user must find again later, and a dismissable box teaches the box, not the control. The cue puts the motion on the control itself. + +**A fixed per-character tick.** The first implementation used 60ms per character unconditionally; an English preset name took over three times as long as its four-character Chinese counterpart, reading as lag rather than emphasis. The shared reveal window makes duration a property of the cue, not of the locale. + +**Animating the pick inside the settings dialog before leaving.** The dialog closes as part of the gesture — leaving settings is how the flow says the work happens in the session — so anything played there would be cut off or would delay the navigation it exists to explain. + +## Consequences + +The intro timeline lives in two places that must agree: the component's `INTRO_TEXT_DELAY_MS` and the `.introIcon` CSS animation duration. The component's constants are the source of the character delays and the acknowledgement timeout; the CSS comment names the coupling. The seat store gains one bit of UI state (`introduce`) that every stage decides explicitly, and the section keeps rendering a group with no members — a shape the section golden and unit tests now pin. + +## Testing + +Component tests pin the capped stagger (11-character Latin name at 20ms steps, 4-character CJK name at the 40ms tick, single character with no stagger), the acknowledgement timing, and the reduced-motion and empty-name skips. `apply.spec.ts` drives the cross-screen stage end to end: the creator draft stages with the cue set, one acknowledgement clears it, and a repeat acknowledgement leaves the snapshot untouched. The `agent-preset-authoring` web e2e holds the empty custom group (heading plus creator entry) in its goldens. diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md new file mode 100644 index 0000000000..d80260abd1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 创造模式引导以介绍动效落在预设 chip 上 + +Status: implemented + +[English](2026-08-10-creator-guidance-introduce-cue.md) | 中文 + +## 问题 + +预设的创作发生在创造模式 session 内部,但设置分区没有把这条路径讲清楚。创建入口游离在名册分组之外;自定义分组在没有成员时整个消失;点击入口后用户被抛到新会话屏幕,没有任何标记说明发生了什么变化:暂存的预设 chip 渲染得和用户亲手挑选时一模一样。用户反馈看不懂流程已经移动,也不明白即将开始的 session 正是构建预设的地方(#2184)。 + +## 决定 + +自定义分组在空的时候也常驻屏幕——分组标题加创建入口,入口移入分组内部,作为"你的预设会出现在这里"的常设指引,而不是漂在名册下方。 + +从另一屏幕暂存的选择会经由 seat store 携带一次性的 `introduce` 标志(`stage(id, introduce)`),chip 据此自我介绍:预设图标在 150ms 内缓入,落定的瞬间名称逐字符错峰浮现。错峰有两重上限——短的中文名按每字符 40ms 的节拍,同时共享一个 200ms 的整体揭示窗口(`min(40, 200/(n-1))`),让长的拉丁名与中文名在相同时间内完成,而不是按字符数拖长整轮动画。动效由 CSS 负责;组件只负责触发,并在一轮结束后确认该提示,因此标志不会在后续挂载时重放。`prefers-reduced-motion` 与空显示名会立即确认、不播放动画。 + +该提示纯属呈现层:它是客户端 seat-store 状态,永远不是 session 事件,因为模型可见的组合已由暂存的预设本身承载。 + +## 曾考虑的替代方案 + +**在新会话屏幕上弹 toast 或提示框。** 它能解释更多,但什么也没指向——chip 才是用户之后必须再次找到的对象,可关闭的提示框教会的是提示框本身,不是控件。介绍动效把动作放在控件本体上。 + +**固定的每字符节拍。** 第一版实现无条件使用每字符 60ms;英文预设名的时长超过四字中文名的三倍,读起来像卡顿而非强调。共享揭示窗口让时长成为提示的属性,而不是语言的属性。 + +**离开前在设置对话框内播放选中动画。** 关闭对话框本身就是这个手势的一部分——离开设置正是流程在表达"工作发生在 session 里"——在那里播放的任何内容要么被截断,要么会拖延它本要解释的跳转。 + +## 后果 + +介绍时间线存在于两处且必须一致:组件的 `INTRO_TEXT_DELAY_MS` 与 `.introIcon` 的 CSS 动画时长。组件常量是字符延迟与确认超时的来源;CSS 注释点明了这层耦合。seat store 多出一位 UI 状态(`introduce`),每次暂存都显式决定它;分区则会渲染没有成员的分组——这一形态现由分区 golden 与单元测试钉住。 + +## 测试 + +组件测试钉住带上限的错峰(11 字符拉丁名走 20ms 步进、4 字中文名走 40ms 节拍、单字符无错峰)、确认时机,以及 reduced-motion 与空名的跳过路径。`apply.spec.ts` 端到端驱动跨屏暂存:创造模式草稿携带提示暂存,一次确认将其清除,重复确认让快照原样不动。`agent-preset-authoring` web e2e 在 golden 中保持空自定义分组(标题加创建入口)。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 0763ffff02..55fe22e81b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,11 +36,13 @@ color: var(--dsw-alias-label-primary); } -/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the - name's characters fade up on a stagger (delays set inline per character). - All chars occupy their width from the start, so nothing reflows mid-run. */ +/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration + matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands), + then the name's characters fade up on a stagger (delays set inline per + character). All chars occupy their width from the start, so nothing + reflows mid-run. */ .introIcon { - animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; + animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; } @keyframes seat-icon-in { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 84734dccfc..f7350076c2 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -36,13 +36,27 @@ export interface AgentPresetSeatInjected { introduced: () => void } -/* Introduce timeline: the icon eases in first; the name's characters start - fading up once the icon has mostly landed, one every stagger tick, each - taking the fade duration to settle. The cue clears after the last one. */ -const INTRO_TEXT_DELAY_MS = 300 -const INTRO_CHAR_STAGGER_MS = 60 +/* Introduce timeline: the icon eases in first (the CSS animation shares this + duration); the name's characters start fading up the moment it lands, each + taking the fade duration to settle. The cue clears after the last one. The + stagger is capped twice: per tick for short CJK names, and by one shared + reveal window so a long Latin name finishes in the same time as its CJK + counterpart instead of dragging the run out per character. */ +const INTRO_TEXT_DELAY_MS = 150 +const INTRO_CHAR_STAGGER_MS = 40 +const INTRO_TEXT_REVEAL_MS = 200 const INTRO_CHAR_FADE_MS = 400 +/** + * Per-character start offset for the introduce reveal. + * @param count - character count of the shown preset name. + * @returns milliseconds between successive character starts. + */ +function introStaggerMs(count: number): number { + if (count <= 1) return 0 + return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1)) +} + /** Full component props. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -83,7 +97,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, const done = window.setTimeout(() => { setIntroducing(false) introduced() - }, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS) + }, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS) return () => { window.clearTimeout(done) } }, [state.introduce, ready, label, introduced]) @@ -93,14 +107,16 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, // One wrapper span: the chip is a flex row with a gap, so loose character // spans would each pick up the gap between them. + const characters = Array.from(label) + const stagger = introStaggerMs(characters.length) const shownLabel = introducing ? ( - {Array.from(label).map((character, index) => ( + {characters.map((character, index) => ( {character} diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 23e1944948..a886569037 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => { expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') expect(workspaces.starts).toHaveLength(1) + + // A cross-screen stage carries the introduce cue; the chip acknowledges + // it once, and a repeat acknowledgement leaves the snapshot untouched. + expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true) + seat.introduced() + const acknowledged = seat.hooks.agentPresetSeat.getSnapshot() + expect(acknowledged.introduce).toBe(false) + seat.introduced() + expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged) conversation() }) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index b63b9ce63c..0c29175a60 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -277,6 +277,94 @@ describe('the new-session chip', () => { }) }) +describe('the chip introduce cue', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + /** Character spans carry inline animation delays; nothing else does. */ + function delayedChars(): HTMLElement[] { + return Array.from(screen.getByRole('button').querySelectorAll('[style]')) + } + + it('reveals a long Latin name inside the shared window, then acknowledges', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }], + introduce: true, + }) + + // Eleven characters split the 200ms window into 20ms steps, where the + // fixed 40ms tick would have doubled the run for a Latin name. + const chars = delayedChars() + expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode') + expect(chars[0]!.style.animationDelay).toBe('150ms') + expect(chars[1]!.style.animationDelay).toBe('170ms') + expect(chars[10]!.style.animationDelay).toBe('350ms') + + // 150 delay + 200 window + 400 fade: acknowledged only once the last + // character has settled, and the label is plain text again after. + act(() => { vi.advanceTimersByTime(749) }) + expect(actions.introduced).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) + + it('keeps the per-tick cap for a short CJK name', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: '创造模式' }], + introduce: true, + }) + + // Four characters fit under the window, so the 40ms tick applies as-is. + const chars = delayedChars() + expect(chars).toHaveLength(4) + expect(chars[1]!.style.animationDelay).toBe('190ms') + expect(chars[3]!.style.animationDelay).toBe('270ms') + }) + + it('starts a one-character name with no stagger at all', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: 'C' }], + introduce: true, + }) + + expect(delayedChars()[0]!.style.animationDelay).toBe('150ms') + act(() => { vi.advanceTimersByTime(550) }) + expect(actions.introduced).toHaveBeenCalledTimes(1) + }) + + it('skips the run under reduced motion and acknowledges at once', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const actions = renderSeat({ introduce: true }) + + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) + + it('acknowledges an empty staged name without arming a run', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: '' }], + introduce: true, + }) + + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) +}) + describe('the session-header label', () => { it('names the preset the session runs, and never offers a switch', async () => { const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 05c2b28d67..93e7fdd1e5 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -253,6 +253,20 @@ describe('the preset list', () => { expect(actions.close).toHaveBeenCalledTimes(1) }) + it('keeps the empty custom group on screen: heading plus the creator entry', () => { + renderSection({ + rows: [ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式' }, + { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }, + ], + }) + + // No member yet, but the place where one's own preset will appear stays. + expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() + expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy() + expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull() + }) + it('hides the creator entry without the flow or the preset, disables it without a root', () => { renderSection() expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() From 9e5135e338c89f7b3727a8031f6ee66ca46c9cd1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 11 Aug 2026 00:00:07 +0800 Subject: [PATCH 12/23] =?UTF-8?q?fix(client):=20correct=20the=20Chinese=20?= =?UTF-8?q?hero=20slogan=20to=20=E6=8E=A2=E7=B4=A2=E6=9C=AA=E8=87=B3?= =?UTF-8?q?=E4=B9=8B=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped copy read 探索未知之境; the product slogan is 探索未至之境. English copy is untouched. --- packages/client/ui-conversation/src/client/locales.ts | 2 +- packages/client/ui-conversation/tests/skeleton.spec.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b022219bc7..dcf04264e8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -60,7 +60,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', - 'hero.headline': '探索未知之境', + 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 7d7596a49e..ee05eb3a3e 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -356,7 +356,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('探索未知之境')).toBeTruthy() + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -380,7 +380,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('探索未知之境')).toBeNull() + expect(b.view.queryByText('探索未至之境')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -405,7 +405,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('探索未知之境')).toBeTruthy() + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -423,7 +423,7 @@ describe('ConversationRoot resident composer', () => { expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('探索未知之境')).toBeNull() + expect(b.view.queryByText('探索未至之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) From 580d85d2a8a4867a82c6ead01dc9e123f8bb943b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:28:00 +0800 Subject: [PATCH 13/23] fix(ci): await token stats before aria snapshot --- apps/web/tests/message-actions.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 7555be866b..4284d3d71a 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) await page.getByRole('button', { name: /^Select model, current/ }) .waitFor({ timeout: 10_000 }) + await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() From 94abd8631ae83d2ff1e65a422e8a59abfb7d369c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 7 Aug 2026 16:01:41 +0800 Subject: [PATCH 14/23] fix(feedback): include session id in acknowledgement --- .../feedback/command-feedback/src/index.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 37205b76e2..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Telemetry, TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import type { Session } from '@deepseek-ai/dsh-session' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -16,6 +17,42 @@ export const inject = ['commands'] const USAGE = 'Usage: /feedback ' +/** Fail closed when a future sharing status reaches the sentence switch. */ +/* v8 ignore next 3 -- only the ignored default arm calls this; the closed union cannot reach it via the public API. */ +function assertNever(value: never): never { + throw new Error(`command-feedback: unsupported sharing status ${JSON.stringify(value)}`) +} + +/** The acknowledgement's sharing sentence for a disclosed policy. */ +function sharingSentence(sharing: TelemetrySharingStatus): string { + switch (sharing) { + case 'full': + return 'Session sharing is enabled.' + case 'feedback-only': + return 'Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.' + case 'disabled': + return 'Session sharing is disabled.' + /* v8 ignore next 2 -- the seam's closed union cannot reach the default; a future status must be given a sentence here. */ + default: + return assertNever(sharing) + } +} + +/** + * The sharing disclosure appended to the acknowledgement: the mounted + * backend's disclosed policy, or a "not configured" notice when no backend + * is mounted. Read through the plugin context so the command still works + * when the telemetry service is absent. + * @param telemetry - the mounted telemetry service, or undefined. + * @returns one sentence describing this session's sharing policy. + */ +function sharingDisclosure(telemetry: Telemetry | undefined): string { + if (telemetry === undefined) { + return 'Session sharing is not configured.' + } + return sharingSentence(telemetry.sharing) +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -42,17 +79,20 @@ export function recordFeedback(session: Session, text: string): void { * 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. + * @param ctx - plugin context used to read the optional telemetry service. * @returns an acknowledgement containing the receiving session and anonymous - * user ids, or a usage error when no feedback text was supplied. + * user ids plus the session-sharing disclosure, or a usage error when no + * feedback text was supplied. */ -function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { +function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } recordFeedback(invocation.agent.session, invocation.rawInput) + const telemetry = ctx.get('telemetry') return { kind: 'success', - text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`, + text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, } } @@ -63,6 +103,6 @@ export function apply(ctx: Context): void { description: 'record feedback about this session', input: { hint: '' }, recordInput: false, - handler: executeFeedbackCommand, + handler: invocation => executeFeedbackCommand(invocation, ctx), }) } From 3f9d0436eb4ec1b070ae49a651091044a80ad9d6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 8 Aug 2026 02:27:37 +0800 Subject: [PATCH 15/23] 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. --- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 2 +- .../feature/2026-07-28-feedback-command.zh.md | 2 +- ...knowledgement-sharing-disclosure.i18n.yaml | 6 ++ ...back-acknowledgement-sharing-disclosure.md | 27 ++++++ ...k-acknowledgement-sharing-disclosure.zh.md | 27 ++++++ apps/web/tests/feedback-command.e2e.ts | 89 +++++++++++++++++++ apps/web/tests/scaffold.ts | 15 +++- .../feedback-command/ack.expected.md | 35 ++++++++ .../snapshots/feedback-command/session.jsonl | 17 ++++ apps/web/tsconfig.json | 1 + packages/feedback/command-feedback/README.md | 16 +++- .../feedback/command-feedback/README.zh.md | 16 +++- .../feedback/command-feedback/package.json | 2 + .../feedback/command-feedback/src/index.ts | 5 ++ .../tests/command-feedback.spec.ts | 57 ++++++++++-- .../tests/loader-composition.spec.ts | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 2 + .../session-telemetry-otel/README.zh.md | 2 + .../session-telemetry-otel/src/index.ts | 14 +++ .../session-telemetry-otel/tests/otel.spec.ts | 25 ++++++ .../session-telemetry/README.i18n.yaml | 4 +- packages/session/session-telemetry/README.md | 6 ++ .../session/session-telemetry/README.zh.md | 8 ++ .../session/session-telemetry/src/index.ts | 18 ++++ scripts/type-equiv.manifest.json | 5 ++ tsconfig.host.json | 1 + 28 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md create mode 100644 apps/web/tests/feedback-command.e2e.ts create mode 100644 apps/web/tests/snapshots/feedback-command/ack.expected.md create mode 100644 apps/web/tests/snapshots/feedback-command/session.jsonl 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", From 6a6148a08c103ad321dc72012d22754465e3e830 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 14:53:05 +0800 Subject: [PATCH 16/23] test(feedback): fix the assembled e2e record path and teardown restore The feedback-command e2e now drives the recorded prompt through a separate all-modes test that arms whenTurnSettled before sending and writes the fixture back via recordFixture in record mode; the acknowledgement golden test runs only in replay/refresh. The scaffold restores the pinned DSH_HOME on the persistence-root setup failure path, and the telemetry subsystems page links the README's sharing-disclosure anchor. --- apps/web/tests/feedback-command.e2e.ts | 28 +++++++++++----- .../feedback-command/ack.expected.md | 2 ++ docs/module-graph.i18n.yaml | 4 +-- docs/subsystems/telemetry.i18n.yaml | 4 +-- docs/subsystems/telemetry.md | 32 ++++++++++++++----- docs/subsystems/telemetry.zh.md | 32 ++++++++++++++----- .../command-feedback/README.i18n.yaml | 4 +-- 7 files changed, 76 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 6dd8ac19a0..577e77a97a 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -16,7 +16,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -56,20 +56,32 @@ describe('web e2e: /feedback command acknowledgement', () => { await scaffold?.close() }) - it('records feedback and renders the acknowledgement with session id and sharing status', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive')) if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. 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.waitFor({ timeout: 10_000 }) + // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot + // miss the turn/end that settles the recorded turn. + const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) await input.press('Enter') - await scaffold.whenTurnSettled() - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + // The drive test settled the recorded turn: the transcript is active (a + // command row does not render while a fresh session is still blank) and + // the replayed reply is on screen. + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const input = page.locator('textarea').first() 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 diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 5e6a769e74..fdc43ad90d 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1f864cf46d..a5f2d4e167 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 2218d79e28e835ab96abce96eaf92bbae25e2182 -module-graph.zh.md: 276b70d69c2898d74ac6897e398b02a8944fd503 +module-graph.md: 8dea030a68f5dde3ce072a8f9ae7156ad162967b +module-graph.zh.md: a611ca8300f17b19c5d4f01032dc767dd04743c1 diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index f5cda71a1d..5c8d376079 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.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 docs/subsystems/telemetry.md -telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb -telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 +telemetry.md: 1b34f25361049611483ac9a10b2f90d7dac64439 +telemetry.zh.md: 9d20831d74792944f5b17e43e6ed14f02dc00275 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 5ea5c67210..1b34f25361 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. +Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,12 +56,28 @@ interface TelemetryRecord { Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-started signal; the rest drop at capture, so `seq` gaps are routine on the wire and never a loss signal. Every other [session event](session.md) type, including plugin-merged ones the seam never heard of, passes through whole. Delivery is best-effort: the cursor marks handed-off, not delivered, records can be lost (crash, reload window) and duplicated (cursor-less re-adoption, SDK retries), so receivers dedupe ledger records on `(session.id, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead. +## The sharing disclosure + +The seam's acknowledgement contract (owned by the [Service Definition README's sharing-disclosure section](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)): every backend discloses its deployment-selected sharing policy through the required abstract `sharing` member on `ctx.telemetry`, and consumers render "not configured" only when no telemetry service is mounted. The disclosure states the current policy, never delivery or retention — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the reporting SDK's. + +```ts type-equiv +/** + * 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. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## The backend contract ```ts type-equiv /** - * The minimum backend contract the coordinator requires. {@link Telemetry} is - * its service-registered form; tests compose the coordinator with a bare + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -76,8 +92,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a turn ended. A backend may forward it to its SDK's - * flush so records are exported after each turn. Called + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -104,7 +120,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. ## The redact waterfall: `telemetry/record` @@ -122,7 +138,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -141,7 +157,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index bd8fc8acc4..9d20831d74 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 +对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,12 +56,28 @@ interface TelemetryRecord { 每个 `(turn, step)` 只发出第一条 `assistant/chunk`,即「流已开始」的信号;其余分片在捕获时丢弃,因此导出流中的 `seq` 缺口是常态,绝不是丢失信号。其他所有[会话事件](session.md)类型都会完整透传,包括该 seam 从未听说过、由插件合并进来的事件类型。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(无游标的重新接管、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。 +## 共享披露 + +该 seam 的确认契约(归属 [Service Definition README 的共享披露段](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)):每个后端都通过 `ctx.telemetry` 上必需的抽象 `sharing` 成员披露其部署级共享策略,消费方只有在未挂载任何遥测服务时才渲染「未配置」。披露只陈述当前策略,绝不承诺投递或留存——交接是非阻塞入队,批处理、重试与丢失策略仍归上报 SDK。 + +```ts type-equiv +/** + * 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. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## 后端约定 ```ts type-equiv /** - * The minimum backend contract the coordinator requires. {@link Telemetry} is - * its service-registered form; tests compose the coordinator with a bare + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -76,8 +92,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a turn ended. A backend may forward it to its SDK's - * flush so records are exported after each turn. Called + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -104,7 +120,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 ## 脱敏 waterfall:`telemetry/record` @@ -122,7 +138,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -141,7 +157,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index ea0c591ae2..f199e9f4eb 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023 -README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f +README.md: 6db5bc4b18815778628d5caa74b75c8b46d3f6a8 +README.zh.md: d9ad1aba9bdbc04110430aa3e3f6604313b8ee20 From ac7c44a5dfebb7cc3f8514d780a13442c2233c55 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 18:33:44 +0800 Subject: [PATCH 17/23] fix(feedback): drop rebase residue from the sharing acknowledgement The post-rebase cleanup removes leftover conflict-marker lines and the superseded acknowledgement text from the command source, re-adds the session-telemetry project reference, and restores the lockfile importer link for the sharing dependency. --- packages/feedback/command-feedback/src/index.ts | 5 ----- packages/feedback/command-feedback/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index daeee26f79..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -83,9 +83,6 @@ 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) { @@ -96,8 +93,6 @@ 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/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index c39f55f60f..fe189a9c3e 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../session/user-id" }, + { + "path": "../../session/session-telemetry" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771706fb80..a268f8e951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3782,6 +3782,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../../session/session-telemetry '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../../session/user-id From d9f8270cc3e3ed796572851e02a133a222d292e1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 18:33:49 +0800 Subject: [PATCH 18/23] docs: sync sharing-disclosure catalogs and module graph after rebase Regenerates the ack golden for the merged acknowledgement format, records the zh counterparts and pairing hashes for the telemetry and catalog pages, and restores the command-feedback to session-telemetry edge and dependency in the module graph. --- .../snapshots/feedback-command/ack.expected.md | 6 ++++-- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.md | 2 +- docs/subsystems/telemetry.i18n.yaml | 4 ++-- docs/subsystems/telemetry.md | 13 ++++++------- docs/subsystems/telemetry.zh.md | 13 ++++++------- packages/feedback/command-feedback/README.i18n.yaml | 4 ++-- 12 files changed, 30 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index fdc43ad90d..9a854ec81a 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -23,8 +23,10 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- img -- text: feedback Feedback recorded for session session-{{uuid}}. Session sharing is enabled. +- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled." - textbox "Message the agent" - button "Commands": - img diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3957d29f57..2f05b260a2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 0813c9e1f1d761b69180bc919d0629e10c7661bc +config-catalog.md: 646198cea4d30ddc799ef4886af309f376cfa9f2 config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0813c9e1f1..646198cea4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1562,7 +1562,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a5f2d4e167..71b5e667c7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 8dea030a68f5dde3ce072a8f9ae7156ad162967b -module-graph.zh.md: a611ca8300f17b19c5d4f01032dc767dd04743c1 +module-graph.md: aaa75f1578679495555f4169a5b031159a6e3cbb +module-graph.zh.md: 56f76bbac65fd485fcdbb2f9bdeedb52cecc8616 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2218d79e28..cbe1a2d75e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -715,6 +715,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands @@ -1373,7 +1374,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 276b70d69c..3c7511cfd3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -717,6 +717,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands @@ -1375,7 +1376,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ee0b5cbdd6..5778b13667 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d +persistence-catalog.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f44569d3ba..1b94ecc541 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -364,7 +364,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact 'feedback/record': { text: string } ``` -Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/index.ts:62`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index 5c8d376079..09caaa6039 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.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 docs/subsystems/telemetry.md -telemetry.md: 1b34f25361049611483ac9a10b2f90d7dac64439 -telemetry.zh.md: 9d20831d74792944f5b17e43e6ed14f02dc00275 +telemetry.md: 97694a9a5a209224087d0d8454d83e29ce568ea4 +telemetry.zh.md: 9e8b17f4bddb3debdf4dff9d3c3fed1296ebf3d7 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 1b34f25361..97694a9a5a 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -75,9 +75,8 @@ type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -92,8 +91,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -138,7 +137,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -157,7 +156,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 9d20831d74..9e8b17f4bd 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -75,9 +75,8 @@ type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -92,8 +91,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -138,7 +137,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -157,7 +156,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index f199e9f4eb..fe49d8e490 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 6db5bc4b18815778628d5caa74b75c8b46d3f6a8 -README.zh.md: d9ad1aba9bdbc04110430aa3e3f6604313b8ee20 +README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f +README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245 From 806f6d625f97662d82331f7014d3049a0eb67041 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 23:11:29 +0800 Subject: [PATCH 19/23] fix(web): accept sharing disclosure suffix in seeded-history feedback test The /feedback acknowledgement now appends a sharing-policy sentence after the anonymous user id. The seeded-history e2e regex anchored on the end of the User line, and the golden snapshot did not include the disclosure. Update both to match the new format, and re-record the module-graph translation-pairing hash after rebasing onto master (which picked up the windows-native ACL coverage fix in #2182). --- apps/web/tests/seeded-history.e2e.ts | 4 ++-- .../tests/snapshots/seeded-history/feedback-row.expected.md | 6 +++--- docs/module-graph.i18n.yaml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a112933f9c..9a521a9d48 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => { if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) - expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i) expect(extraLine).toBeUndefined() - const userId = userLine?.slice('User: '.length) + const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 87b763d37c..6928b95777 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -38,10 +38,10 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]': +- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]': - img - - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" -- text: "Feedback recorded for session {{seededId}} User: {{uuid}}" + - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." +- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." - textbox "Message the agent" - button "Commands": - img diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 71b5e667c7..91e49bbc86 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: aaa75f1578679495555f4169a5b031159a6e3cbb -module-graph.zh.md: 56f76bbac65fd485fcdbb2f9bdeedb52cecc8616 +module-graph.md: 1cc8764c34e01386c4d8ce9a66198bcfa6ece1e7 +module-graph.zh.md: 508d2f4789aa2d50b22efc35254aa20fb031d5e5 From 725f0639ef089c3360cca420159418a7968f5036 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 00:50:56 +0800 Subject: [PATCH 20/23] ci: retrigger after rebase onto master From 4786b3be89cde0448fd777db2593274e7d06e85c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 01:05:53 +0800 Subject: [PATCH 21/23] ci: trigger From 893228b19063e16f84b47d0e1a2040fc9dc1126b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 01:32:32 +0800 Subject: [PATCH 22/23] test(feedback): refresh ack golden for master banner locale --- apps/web/tests/snapshots/feedback-command/ack.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 9a854ec81a..89d40acb3b 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" From bb40c2b07936ee6be4884f20f8c2093e884e1ecb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 11:02:05 +0800 Subject: [PATCH 23/23] docs: re-record module-graph translation pairing after rebase --- docs/module-graph.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 91e49bbc86..e0a5822779 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 1cc8764c34e01386c4d8ce9a66198bcfa6ece1e7 -module-graph.zh.md: 508d2f4789aa2d50b22efc35254aa20fb031d5e5 +module-graph.md: cbe1a2d75ef33c44b31ac3b84bb9a54def97d0e5 +module-graph.zh.md: 3c7511cfd391ec69548588df3ab597457a420334