Kaynağa Gözat

fix(web): address the review round on the remaining context forms

- `relay` resolves its sender in `contextBody` like every other form. It was
  the one shape whose marker could claim a form the body did not render: an
  unreadable sender fell back inside the body while the row still said relay,
  contradicting the contract this PR's own note states.
- `recall` requires the retained, omitted, and truncated fields. Completeness
  is what the card exists to report, so a reference that cannot state it is
  not a readable recall — showing the label alone presents a confident card
  over unknown loss.
- The snapshot body states the supersession its producer framing line carries.
  That line is the one part of the model-facing text no section contains, and
  unlike an instruction context's `<system-reminder>` it states the form's own
  semantics rather than wrapping content.
- `GoalMessageSource` is a discriminated pair, so `{ form: 'notice' }` without
  its account no longer compiles. The guarantee this PR claims now holds at
  that seam too, not only through `ContextFormed` on plugin sources.
- Goal and tool-goal summaries are bounded by a shared `boundContextSummary`,
  which tool-tasks now uses as well. A goal objective is unbounded caller text
  in exactly the way a task label is.
- The runtime snapshot interpolates once per request: agent-loop renders the
  sections and joins them through `joinContextSections`.
- Every form's fallback branch is pinned, not only the notice one.
creatixchu 2 ay önce
ebeveyn
işleme
e7f2005ec7

+ 1 - 1
docs/cordis-catalog/events.md

@@ -567,7 +567,7 @@ Goal mutation accepted by one live agent. The matching context event is already
 
 Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)
 
-Source: [`packages/goal/goal/src/domain.ts:142`](../../packages/goal/goal/src/domain.ts)
+Source: [`packages/goal/goal/src/domain.ts:140`](../../packages/goal/goal/src/domain.ts)
 
 ## `llm/*`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -2163,7 +2163,7 @@ async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
 
 Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
 
-Source: [`packages/core/system-prompt/src/index.ts:310`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:322`](../../packages/core/system-prompt/src/index.ts)
 
 ## `ctx.tasks` — `TaskService` (abstract seam)
 

+ 2 - 2
docs/core-data-structures/goal.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/core-data-structures/goal.md
-goal.md: 8627b927fcff1c32faac0e76356f576bdbb29ba8
-goal.zh.md: f1c3a2e4d65d1efe849aab565e8beb79e0284aae
+goal.md: 5b9b751d734a98d5fc7b0333eeb52febadbca22a
+goal.zh.md: 0ce1a2166abc234225309bfb9a513bdf9ed30d4a

+ 7 - 9
docs/core-data-structures/goal.md

@@ -101,22 +101,20 @@ Goal state changes use round `0`. A continuation consumer attributes each admitt
 
 ```ts type-equiv
 /** Message attribution for durable goal state and continuation rounds. */
-interface GoalMessageSource {
+type GoalMessageSource = {
   readonly kind: 'goal'
-  /**
-   * Round-zero state changes are `notice`-form contexts; a continuation round
-   * carries the objective forward as ordinary context and declares no form.
-   */
-  readonly form?: 'notice'
-  /** Present with `form`: one-line account of the mutation. */
-  readonly summary?: string
   readonly goalId: GoalId
   readonly revision: number
   /** Zero for state changes; positive for admitted continuation rounds. */
   readonly round: number
   /** Complete durable mutation carried only by round-zero state-change messages. */
   readonly change?: GoalChangeMeta
-}
+  /**
+   * Round-zero state changes are `notice`-form contexts; a continuation round
+   * carries the objective forward as ordinary context and declares no form.
+   * Discriminated so the account cannot be omitted when the form is declared.
+   */
+} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never })
 ```
 
 ## Requests and notifications

+ 7 - 9
docs/core-data-structures/goal.zh.md

@@ -101,22 +101,20 @@ interface GoalClearChangeMeta {
 
 ```ts type-equiv
 /** Message attribution for durable goal state and continuation rounds. */
-interface GoalMessageSource {
+type GoalMessageSource = {
   readonly kind: 'goal'
-  /**
-   * Round-zero state changes are `notice`-form contexts; a continuation round
-   * carries the objective forward as ordinary context and declares no form.
-   */
-  readonly form?: 'notice'
-  /** Present with `form`: one-line account of the mutation. */
-  readonly summary?: string
   readonly goalId: GoalId
   readonly revision: number
   /** Zero for state changes; positive for admitted continuation rounds. */
   readonly round: number
   /** Complete durable mutation carried only by round-zero state-change messages. */
   readonly change?: GoalChangeMeta
-}
+  /**
+   * Round-zero state changes are `notice`-form contexts; a continuation round
+   * carries the objective forward as ordinary context and declares no form.
+   * Discriminated so the account cannot be omitted when the form is declared.
+   */
+} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never })
 ```
 
 ## 请求与通知

+ 1 - 1
docs/event-producer-consumer.md

@@ -31,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
-| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:142`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
+| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:140`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |

+ 49 - 30
packages/client/ui-conversation/src/client/chat/ContextBody.tsx

@@ -374,6 +374,13 @@ function snapshotSections(source: unknown): SnapshotSection[] | null {
  * The sections are the same bytes the model read, split at the boundaries the
  * producer assembled them on, so a reader sees which subsystem contributed
  * which state instead of one undifferentiated wall.
+ *
+ * One sentence of the model-facing text is NOT in any section: the producer's
+ * framing line declaring that this snapshot supersedes earlier ones. Unlike the
+ * `<system-reminder>` wrapper an instruction context carries — which wraps
+ * content and cannot be separated from it — that line states the form's own
+ * semantics, so the body states them as a caption instead of reprinting the
+ * joined prose beside the sections it was split from.
  * @param props - Durable content, its source, and the locale seat.
  * @returns The snapshot context body, or the opaque body when unreadable.
  */
@@ -383,16 +390,22 @@ export function SnapshotBody({ content, source, t }: {
   t: Translate
 }): ReactNode {
   const sections = snapshotSections(source)
+  /* v8 ignore next -- contextBody reads the sections before choosing this body. */
   if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
   return (
-    <dl className={css.sections} data-context-sections>
-      {sections.map((section, index) => (
-        <div key={index} className={css.section}>
-          <dt className={css.sectionName}>{section.name}</dt>
-          <dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
-        </div>
-      ))}
-    </dl>
+    <>
+      <p className={css.catalogNotice} data-context-snapshot-supersedes>
+        {t('message.context.snapshot.supersedes')}
+      </p>
+      <dl className={css.sections} data-context-sections>
+        {sections.map((section, index) => (
+          <div key={index} className={css.section}>
+            <dt className={css.sectionName}>{section.name}</dt>
+            <dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
+          </div>
+        ))}
+      </dl>
+    </>
   )
 }
 
@@ -425,10 +438,9 @@ export function RelayBody({ content, source, t }: {
   source: unknown
   t: Translate
 }): ReactNode {
-  const sender = asRecord(source)?.['senderSessionId']
-  if (typeof sender !== 'string' || sender === '') {
-    return <OpaqueBody content={content} source={source} t={t} />
-  }
+  const sender = relaySender(source)
+  /* v8 ignore next -- contextBody resolves the sender before choosing this body. */
+  if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
   return (
     <>
       <p className={css.relaySender} data-context-relay-sender>
@@ -439,11 +451,17 @@ export function RelayBody({ content, source, t }: {
   )
 }
 
+/** The sending agent's session id, or null when the record does not name one. */
+function relaySender(source: unknown): string | null {
+  const sender = asRecord(source)?.['senderSessionId']
+  return typeof sender === 'string' && sender !== '' ? sender : null
+}
+
 /** One recalled session, as the durable source records it. */
 interface RecalledSession {
   label: string
-  retained: number | null
-  omitted: number | null
+  retained: number
+  omitted: number
   truncated: boolean
 }
 
@@ -457,15 +475,16 @@ function recalledSessions(source: unknown): RecalledSession[] | null {
     const reference = asRecord(item)
     if (reference === null) return null
     const label = reference['label']
-    if (typeof label !== 'string' || label === '') return null
     const retained = reference['retainedMessages']
     const omitted = reference['omittedMessages']
-    sessions.push({
-      label,
-      retained: typeof retained === 'number' ? retained : null,
-      omitted: typeof omitted === 'number' ? omitted : null,
-      truncated: reference['truncated'] === true,
-    })
+    const truncated = reference['truncated']
+    // Completeness is the fact this card exists to report, so a reference that
+    // cannot state it is not a readable recall — showing the label alone would
+    // present a confident card over unknown loss.
+    if (typeof label !== 'string' || label === ''
+      || typeof retained !== 'number' || typeof omitted !== 'number'
+      || typeof truncated !== 'boolean') return null
+    sessions.push({ label, retained, omitted, truncated })
   }
   return sessions.length === 0 ? null : sessions
 }
@@ -493,14 +512,12 @@ export function RecallBody({ content, source, t }: {
         {sessions.map((session, index) => (
           <li key={index} className={css.recall}>
             <span className={css.recallLabel}>{session.label}</span>
-            {session.retained !== null && session.omitted !== null && (
-              <span className={css.recallCounts}>
-                {t('message.context.recall.counts', {
-                  retained: session.retained,
-                  omitted: session.omitted,
-                })}
-              </span>
-            )}
+            <span className={css.recallCounts}>
+              {t('message.context.recall.counts', {
+                retained: session.retained,
+                omitted: session.omitted,
+              })}
+            </span>
             {session.truncated && (
               <span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
             )}
@@ -555,7 +572,9 @@ export function contextBody(
         : { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
     }
     case 'relay':
-      return { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
+      return relaySender(props.source) === null
+        ? opaque
+        : { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
     case 'recall':
       return recalledSessions(props.source) === null
         ? opaque

+ 2 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -61,6 +61,7 @@ export const zh = {
   'message.context.instructions.removed': '已移除',
   'message.context.catalog.replaced': '替换目录',
   'message.context.catalog.more': '…还有 {count} 条',
+  'message.context.snapshot.supersedes': '取代先前的快照',
   'message.context.relay.from': '来自会话 {session}',
   'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
   'message.context.recall.truncated': '已截断',
@@ -185,6 +186,7 @@ export const en = {
   'message.context.instructions.removed': 'removed',
   'message.context.catalog.replaced': 'Replacement catalog',
   'message.context.catalog.more': '… {count} more',
+  'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
   'message.context.relay.from': 'From session {session}',
   'message.context.recall.counts': '{retained} kept · {omitted} omitted',
   'message.context.recall.truncated': 'truncated',

+ 39 - 0
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -617,6 +617,45 @@ describe('MessageItem arms', () => {
     expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
   })
 
+  it('each form falls back to the opaque body when its required facts are unreadable', () => {
+    // The fallback chain is the load-bearing wall: every dedicated form must
+    // reach it, and the row marker must not claim a form that did not render.
+    const cases = [
+      { form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
+      { form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
+      { form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
+    ] as const
+    for (const { form, source, label } of cases) {
+      cleanup()
+      const view = render(
+        <MessageItem t={t} node={{
+          kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
+          source, provenance: { role: 'inject', label }, form,
+        } as never}
+        />,
+      )
+      fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
+      expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
+      expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
+        .toBeNull()
+    }
+  })
+
+  it('a snapshot states the supersession its framing line carries', () => {
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
+        source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
+        provenance: { role: 'inject', label: 'plugin' },
+        form: 'snapshot',
+      } as never}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
+    expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
+      .toBe('取代先前的快照')
+  })
+
   it('a relay names the agent that sent it above what it said', () => {
     const view = render(
       <MessageItem t={t} node={{

+ 3 - 2
packages/core/agent-loop/src/agent.ts

@@ -51,7 +51,7 @@ import type {
 } from '@deepseek-ai/dsh-llm'
 import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
 import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
-import { renderContextSections, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
+import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-tools'
 import { executeToolCalls } from './tool-calls.ts'
 
@@ -700,7 +700,8 @@ export class ReactLoopAgent implements Agent {
     const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
     signal.throwIfAborted()
     const system = renderPrompt(assembly)
-    materializeRuntimeContext(session, renderContextSnapshot(assembly), renderContextSections(assembly))
+    const sections = renderContextSections(assembly)
+    materializeRuntimeContext(session, joinContextSections(sections), sections)
 
     // Commit the exact pending batch only after every asynchronous
     // pre-request contribution succeeded. Input accepted after this splice

+ 13 - 1
packages/core/system-prompt/src/index.ts

@@ -208,7 +208,19 @@ export function renderPrompt(assembly: PromptAssembly): string {
  * @returns the current full snapshot, or `''` when no context is active.
  */
 export function renderContextSnapshot(assembly: PromptAssembly): string {
-  const body = renderContextSections(assembly).map(section => section.text).join('\n\n')
+  return joinContextSections(renderContextSections(assembly))
+}
+
+/**
+ * The model-facing snapshot text for an already-rendered section list.
+ *
+ * A caller that also needs the sections renders them once and joins here, so a
+ * request does not interpolate every context twice.
+ * @param sections - sections from {@link renderContextSections}.
+ * @returns the current full snapshot, or `''` when no context is active.
+ */
+export function joinContextSections(sections: readonly ContextSnapshotSection[]): string {
+  const body = sections.map(section => section.text).join('\n\n')
   if (body.length === 0) return ''
   return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
 }

+ 7 - 9
packages/goal/goal/src/domain.ts

@@ -59,22 +59,20 @@ export interface GoalClearChangeMeta {
 export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
 
 /** Message attribution for durable goal state and continuation rounds. */
-export interface GoalMessageSource {
+export type GoalMessageSource = {
   readonly kind: 'goal'
-  /**
-   * Round-zero state changes are `notice`-form contexts; a continuation round
-   * carries the objective forward as ordinary context and declares no form.
-   */
-  readonly form?: 'notice'
-  /** Present with `form`: one-line account of the mutation. */
-  readonly summary?: string
   readonly goalId: GoalId
   readonly revision: number
   /** Zero for state changes; positive for admitted continuation rounds. */
   readonly round: number
   /** Complete durable mutation carried only by round-zero state-change messages. */
   readonly change?: GoalChangeMeta
-}
+  /**
+   * Round-zero state changes are `notice`-form contexts; a continuation round
+   * carries the objective forward as ordinary context and declares no form.
+   * Discriminated so the account cannot be omitted when the form is declared.
+   */
+} & ({ readonly form: 'notice'; readonly summary: string } | { readonly form?: never; readonly summary?: never })
 
 declare module '@deepseek-ai/dsh-llm' {
   interface MessageSourceMap {

+ 5 - 3
packages/goal/goal/src/render.ts

@@ -1,5 +1,6 @@
 /** Model-visible rendering for durable goal mutations. */
 
+import { boundContextSummary } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { GoalChangeMeta } from './domain.ts'
 
@@ -9,10 +10,11 @@ import type { GoalChangeMeta } from './domain.ts'
  * @returns the operation and, for a surviving goal, its objective.
  */
 export function goalChangeSummary(change: GoalChangeMeta): string {
-  // The row header already names the producer, so the account does not repeat it.
-  return change.operation === 'clear'
+  // The row header already names the producer, so the account does not repeat
+  // it. The objective is unbounded caller text, so the account is bounded.
+  return boundContextSummary(change.operation === 'clear'
     ? change.operation
-    : `${change.operation}: ${change.goal.objective}`
+    : `${change.operation}: ${change.goal.objective}`)
 }
 
 /**

+ 2 - 2
packages/goal/tool-goal/src/index.ts

@@ -8,7 +8,7 @@ import type { Context } from 'cordis'
 import z from 'schemastery'
 import { GoalId } from '@deepseek-ai/dsh-goal'
 import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
-import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
+import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import type { GenericCallView } from '@deepseek-ai/dsh-tools'
 import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -319,7 +319,7 @@ export function apply(ctx: Context, config: Config): void {
             kind: 'plugin',
             plugin: 'tool-goal',
             form: 'notice',
-            summary: `${args.action as string}: ${goal.objective}`,
+            summary: boundContextSummary(`${args.action as string}: ${goal.objective}`),
           },
         }))
       }

+ 18 - 0
packages/llm/llm/src/message.ts

@@ -104,6 +104,24 @@ export interface MessageSourceMap {
   tool: ToolMessageSource
 }
 
+/**
+ * Bound for a `notice` summary. The account rides a collapsed transcript row
+ * and is committed to the durable log, while its inputs — task labels, goal
+ * objectives, tool arguments — are caller text with no length of their own.
+ */
+export const CONTEXT_SUMMARY_MAX_CHARS = 120
+
+/**
+ * Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
+ * @param summary - the producer's one-line account, of any length.
+ * @returns the account, ellipsized when it exceeds the bound.
+ */
+export function boundContextSummary(summary: string): string {
+  return summary.length <= CONTEXT_SUMMARY_MAX_CHARS
+    ? summary
+    : `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…`
+}
+
 /** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
 export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
 

+ 3 - 14
packages/tasks/tool-tasks/src/index.ts

@@ -8,7 +8,7 @@
 
 import type { Context } from 'cordis'
 import z from 'schemastery'
-import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
+import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
 import { TextRetainer } from '@deepseek-ai/dsh-retention'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -114,24 +114,13 @@ function fitWithSuffix(
   return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`
 }
 
-/**
- * Bound for the durable one-line account. A notice summary rides a collapsed
- * transcript row, and both the task label and its status detail are caller
- * text with no length of their own, so the summary caps itself rather than
- * committing unbounded prose to the log.
- */
-const SUMMARY_MAX_CHARS = 120
-
 /**
  * One-line account of a settled task for the `notice` form's collapsed row.
  * @param snapshot - the settled task.
- * @returns its kind, label, and status, bounded to {@link SUMMARY_MAX_CHARS}.
+ * @returns its kind, label, and status, bounded like every notice summary.
  */
 function completionSummary(snapshot: TaskSnapshot): string {
-  const summary = `${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`
-  return summary.length <= SUMMARY_MAX_CHARS
-    ? summary
-    : `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…`
+  return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`)
 }
 
 function fitCompletionNotice(snapshot: TaskSnapshot): string {