Просмотр исходного кода

test(web): strengthen plan dismissal checks and clarify review contracts

Yichen Jiang 3 дней назад
Родитель
Сommit
5d173b170d

+ 2 - 2
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.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-09-17-persistent-plan-cards.md
-2026-09-17-persistent-plan-cards.md: 53d0ca028a08faacec8f967c002fb525e50c33ac
-2026-09-17-persistent-plan-cards.zh.md: cfc2a67c17a5b4ed76a7cb9df49b6fdfb15e3c71
+2026-09-17-persistent-plan-cards.md: 341175aabc41100b98b7d2f16120fc9ac91a7020
+2026-09-17-persistent-plan-cards.zh.md: 8276bb6cc9985f0b856193a4ab861751089dd8e9

+ 1 - 1
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md

@@ -26,4 +26,4 @@ The document is read-only. The pending review shows its status, View full plan l
 
 ## Consequences
 
-Plans survive review closure and browser reload without a new Session event or file. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover multiple submissions, PTC deduplication, cropped history, and resource failures.
+Plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover multiple submissions, PTC deduplication, cropped history, and resource failures.

+ 1 - 1
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md

@@ -26,4 +26,4 @@ Status: implemented
 
 ## Consequences
 
-计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。
+计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。

+ 11 - 1
apps/web/tests/plan-review.e2e.ts

@@ -190,8 +190,17 @@ describe('web e2e: dismissed plan history', () => {
       await page.locator('[data-sidebar-right-toggle]').click()
       await review.getByRole('button', { name: 'Request changes', exact: true }).click()
       await settled
+      const call = events.find((event): event is SessionEvent<'tool/call'> =>
+        event.type === 'tool/call' && event.data.name === 'exit_plan_mode')
+      expect(call).toBeDefined()
       const results = events.filter(event => event.type === 'tool/result')
-      expect(results.some(event => JSON.stringify(event).includes('dismissed the plan review'))).toBe(true)
+      const result = results.find(event => event.data.message.source.callId === call?.data.callId)
+      expect(result?.data.message.content[0]).toMatchObject({ type: 'tool-result', isError: true })
+      expect(JSON.stringify(result)).toContain('dismissed the plan review')
+      expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false)
+      const modes = events.filter(event => event.type === 'plan/mode')
+      expect(modes).toHaveLength(1)
+      expect(modes[0]).toMatchObject({ data: { active: true } })
       expect(await review.count()).toBe(0)
       const card = page.locator('[data-plan-card]')
       await card.waitFor({ state: 'visible' })
@@ -208,6 +217,7 @@ describe('web e2e: dismissed plan history', () => {
       await page.locator('[data-plan-card]').waitFor({ state: 'visible' })
       expect(await page.locator('[data-plan-preview]').isVisible()).toBe(false)
       expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
     } finally {
       await browser?.close()
       await scaffold.close()

+ 1 - 0
packages/client/ui-plan/package.json

@@ -33,6 +33,7 @@
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-chat",
+        "@deepseek-ai/dsh-client-ui-user-questions",
         "@deepseek-ai/dsh-client-resources",
         "@deepseek-ai/dsh-api-session-controller"
       ],

+ 3 - 3
packages/client/ui-plan/src/client/PlanPreview.module.css

@@ -22,7 +22,7 @@
 }
 :global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); }
 .card:hover { background: var(--plan-card-hover); }
-.card:focus-visible, .reviewOpen:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
+.card:focus-visible, .iconButton:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
 .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); }
 .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
 .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; }
@@ -31,8 +31,8 @@
 .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; }
 .reviewLink:hover { color: var(--dsw-alias-label-primary); }
 .reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
-.reviewOpen { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
-.reviewOpen:hover { background: var(--dsw-alias-interactive-bg-hover); }
+.iconButton { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
+.iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); }
 .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; }
 .toolbar { display: flex; justify-content: flex-end; margin-bottom: 8px; }
 .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }

+ 8 - 2
packages/client/ui-plan/src/client/PlanPreview.tsx

@@ -16,7 +16,13 @@ type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'pl
 export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
   const tab = useTabInfo()
   const resource = useResource<'plan'>(tab.tab.navigation.address)
-  const labels = useMemo(() => ({ code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes') }), [t])
+  const labels = useMemo(() => ({
+    code: {
+      copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'),
+      lineNumbersLabel: t('markdown.code.lineNumbers'),
+    },
+    footnotes: t('markdown.footnotes'),
+  }), [t])
   const plan = resource.value
   if (plan === undefined) return (
     <div className={css.message} role="status">
@@ -26,7 +32,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
   )
   return (
     <section className={css.preview} data-plan-preview={plan.callId} aria-label={plan.title}>
-      <div className={css.toolbar}><button type="button" className={css.reviewOpen} aria-label={t('copy')}
+      <div className={css.toolbar}><button type="button" className={css.iconButton} aria-label={t('copy')}
         onClick={() => { void writeClipboard(plan.markdown) }}><IconCopyOutline16 /></button></div>
       <div className={css.document}><MarkdownText text={plan.markdown} labels={labels} /></div>
     </section>

+ 4 - 1
packages/client/ui-plan/src/client/plan-resource.ts

@@ -1,4 +1,4 @@
-/** Read immutable plan arguments through the existing cold-safe Session history API. */
+/** Read immutable plan arguments from a Session snapshot and earlier history pages. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
@@ -28,6 +28,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 
 /**
  * Bind plan reads to the generated Session Remote face.
+ * Opening a follow reads projections and may activate a prepared Session on the Host.
+ * Generated Remote streams can throw carrier failures; the provider reports failed
+ * reads as resource failure frames and preserves Remote error codes.
  * @param remote - Existing Session history API.
  * @returns a provider whose reads stop after finding the exact invocation.
  */

+ 1 - 1
packages/client/ui-user-questions/src/client/contract/slots.ts

@@ -94,7 +94,7 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u
     id: question.id,
     question: question.question,
     plan: question.detail,
-    ...intent.callId === undefined ? {} : { callId: intent.callId },
+    ...(intent.callId === undefined ? {} : { callId: intent.callId }),
     approve,
     ...(decline === undefined ? {} : { decline }),
   }

+ 1 - 1
packages/client/ui-user-questions/src/client/markdown-labels.ts

@@ -1,4 +1,4 @@
-/** Localized Markdown controls shared by question and plan-review bodies. */
+/** Localized Markdown controls for question details. */
 import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { QuestionComposerProps } from './contract/slots.ts'