- {mode === 'normal' ? field(CODES, 'retryCodes') : null}
- {NUMBER_FIELDS.filter(([path]) => mode === 'normal' || path !== MAX_RETRIES).map(([path, label]) => field(path, label))}
+
+ {mode === 'normal'
+ ? <>
+ {field(MAX_RETRIES, 'retryMaxRetries', `${styles['modelField']} ${styles['retryCount']}`)}
+ {field(CODES, 'retryCodes')}
+ >
+ : null}
+
+ {t('retryBackoff')}
+ {BACKOFF_FIELDS.map(([path, label]) => field(path, label))}
+
)
: null}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index 1c9eed2b28..0d722a95dc 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -57,16 +57,16 @@ export const en = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.',
retryPolicy: 'Retry policy',
- retryMode: 'Retry mode',
retryModeDefault: 'Default ({mode})',
- retryModeNormal: 'Retry transient errors',
- retryModeAlways: 'Retry every error',
+ retryModeNormal: 'Transient errors',
+ retryModeAlways: 'All errors',
retryAlwaysHint: 'Failed requests retry until they succeed or the turn is stopped.',
retryMaxRetries: 'Max retries',
- retryCodes: 'Retryable error codes',
+ retryCodes: 'Error codes',
retryInitialDelay: 'Initial delay (ms)',
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
+ retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'Max retries must be a whole number, 0 or more.',
retryCodesInvalid: 'Error codes must be non-empty text.',
retryCodesDuplicate: 'Each error code may appear once.',
@@ -218,16 +218,16 @@ export const zh: { [Key in keyof typeof en]: string } = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。',
retryPolicy: '重试策略',
- retryMode: '重试模式',
retryModeDefault: '默认({mode})',
- retryModeNormal: '仅重试临时错误',
- retryModeAlways: '重试所有错误',
+ retryModeNormal: '仅临时错误',
+ retryModeAlways: '所有错误',
retryAlwaysHint: '失败的请求会持续重试,直到成功或本轮被停止。',
retryMaxRetries: '最大重试次数',
- retryCodes: '可重试的错误码',
+ retryCodes: '错误码',
retryInitialDelay: '初始延迟 (ms)',
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
+ retryBackoff: '退避',
retryMaxRetriesInvalid: '最大重试次数必须是不小于 0 的整数。',
retryCodesInvalid: '错误码必须是非空文本。',
retryCodesDuplicate: '每个错误码只能出现一次。',
@@ -376,16 +376,16 @@ export const ja = {
'deepSeekBaseUrl': 'https://api.deepseek.com/anthropic (デフォルトはDEEPSEEK_BASE_URLを使用)',
'deepSeekEndpointHint': 'Anthropic Messagesと互換性のあるAPIエンドポイントを使用してください。',
'retryPolicy': '再試行ポリシー',
- 'retryMode': '再試行モード',
'retryModeDefault': 'デフォルト ({mode})',
- 'retryModeNormal': '一時的なエラーのみ再試行',
- 'retryModeAlways': 'すべてのエラーを再試行',
+ 'retryModeNormal': '一時的なエラーのみ',
+ 'retryModeAlways': 'すべてのエラー',
'retryAlwaysHint': '失敗したリクエストは、成功するかターンが停止されるまで再試行されます。',
'retryMaxRetries': '最大再試行回数',
- 'retryCodes': '再試行対象のエラーコード',
+ 'retryCodes': 'エラーコード',
'retryInitialDelay': '初期遅延 (ms)',
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
+ 'retryBackoff': 'バックオフ',
'retryMaxRetriesInvalid': '最大再試行回数は0以上の整数で指定してください。',
'retryCodesInvalid': 'エラーコードは空でないテキストにしてください。',
'retryCodesDuplicate': '各エラーコードは1回のみ指定できます。',
@@ -534,16 +534,16 @@ export const es = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Uso predeterminado DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Utilice un punto final API compatible con Anthropic Messages.',
retryPolicy: 'Política de reintentos',
- retryMode: 'Modo de reintento',
retryModeDefault: 'Predeterminado ({mode})',
- retryModeNormal: 'Reintentar errores transitorios',
- retryModeAlways: 'Reintentar todos los errores',
+ retryModeNormal: 'Errores transitorios',
+ retryModeAlways: 'Todos los errores',
retryAlwaysHint: 'Las solicitudes fallidas se reintentan hasta que tienen éxito o se detiene el turno.',
retryMaxRetries: 'Reintentos máximos',
- retryCodes: 'Códigos de error reintentables',
+ retryCodes: 'Códigos de error',
retryInitialDelay: 'Retraso inicial (ms)',
retryMaxDelay: 'Retraso máximo (ms)',
- retryJitter: 'Proporción de variación aleatoria',
+ retryJitter: 'Proporción de jitter',
+ retryBackoff: 'Retroceso',
retryMaxRetriesInvalid: 'Los reintentos máximos deben ser un número entero igual o mayor que 0.',
retryCodesInvalid: 'Los códigos de error deben ser texto no vacío.',
retryCodesDuplicate: 'Cada código de error solo puede aparecer una vez.',
@@ -692,16 +692,16 @@ export const fr = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (par défaut, DEEPSEEK_BASE_URL est utilisé)',
deepSeekEndpointHint: 'Utilisez un point de terminaison API compatible avec Anthropic Messages.',
retryPolicy: 'Politique de nouvelle tentative',
- retryMode: 'Mode de nouvelle tentative',
retryModeDefault: 'Par défaut ({mode})',
- retryModeNormal: 'Réessayer les erreurs temporaires',
- retryModeAlways: 'Réessayer toutes les erreurs',
+ retryModeNormal: 'Erreurs temporaires',
+ retryModeAlways: 'Toutes les erreurs',
retryAlwaysHint: "Les requêtes en échec sont réessayées jusqu'à ce qu'elles réussissent ou que le tour soit arrêté.",
- retryMaxRetries: 'Nombre maximum de tentatives',
- retryCodes: "Codes d'erreur à réessayer",
+ retryMaxRetries: 'Tentatives max.',
+ retryCodes: "Codes d'erreur",
retryInitialDelay: 'Délai initial (ms)',
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
+ retryBackoff: 'Temporisation',
retryMaxRetriesInvalid: 'Le nombre maximum de tentatives doit être un entier supérieur ou égal à 0.',
retryCodesInvalid: "Les codes d'erreur doivent être du texte non vide.",
retryCodesDuplicate: "Chaque code d'erreur ne peut apparaître qu'une fois.",
@@ -850,16 +850,16 @@ export const de = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Standardmäßig wird DEEPSEEK_BASE_URL verwendet)',
deepSeekEndpointHint: 'Verwenden Sie einen API-Endpunkt, der mit Anthropic Messages kompatibel ist.',
retryPolicy: 'Wiederholungsrichtlinie',
- retryMode: 'Wiederholungsmodus',
retryModeDefault: 'Standard ({mode})',
- retryModeNormal: 'Vorübergehende Fehler wiederholen',
- retryModeAlways: 'Alle Fehler wiederholen',
+ retryModeNormal: 'Vorübergehende Fehler',
+ retryModeAlways: 'Alle Fehler',
retryAlwaysHint: 'Fehlgeschlagene Anfragen werden wiederholt, bis sie erfolgreich sind oder der Durchlauf gestoppt wird.',
- retryMaxRetries: 'Maximale Anzahl an Wiederholungen',
- retryCodes: 'Wiederholbare Fehlercodes',
- retryInitialDelay: 'Anfängliche Verzögerung (ms)',
- retryMaxDelay: 'Maximale Verzögerung (ms)',
+ retryMaxRetries: 'Max. Wiederholungen',
+ retryCodes: 'Fehlercodes',
+ retryInitialDelay: 'Anfangsverzögerung (ms)',
+ retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
+ retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'Die maximale Anzahl an Wiederholungen muss eine ganze Zahl ab 0 sein.',
retryCodesInvalid: 'Fehlercodes müssen nicht leerer Text sein.',
retryCodesDuplicate: 'Jeder Fehlercode darf nur einmal vorkommen.',
@@ -1008,16 +1008,16 @@ export const ru = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (по умолчанию используется DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Используйте конечную точку API, совместимую с антропными сообщениями.',
retryPolicy: 'Политика повторных попыток',
- retryMode: 'Режим повторных попыток',
retryModeDefault: 'По умолчанию ({mode})',
- retryModeNormal: 'Повторять при временных ошибках',
- retryModeAlways: 'Повторять при любых ошибках',
+ retryModeNormal: 'Временные ошибки',
+ retryModeAlways: 'Любые ошибки',
retryAlwaysHint: 'Неудачные запросы повторяются до успеха или до остановки хода.',
- retryMaxRetries: 'Максимальное количество повторных попыток',
- retryCodes: 'Коды ошибок для повтора',
+ retryMaxRetries: 'Макс. число повторов',
+ retryCodes: 'Коды ошибок',
retryInitialDelay: 'Начальная задержка (ms)',
- retryMaxDelay: 'Максимальная задержка (ms)',
+ retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
+ retryBackoff: 'Отсрочка',
retryMaxRetriesInvalid: 'Максимальное количество повторных попыток должно быть целым числом не меньше 0.',
retryCodesInvalid: 'Коды ошибок должны быть непустым текстом.',
retryCodesDuplicate: 'Каждый код ошибки может встречаться только один раз.',
@@ -1166,16 +1166,16 @@ export const pt = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (padrão para usar DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Use um endpoint API compatível com Anthropic Messages.',
retryPolicy: 'Política de novas tentativas',
- retryMode: 'Modo de novas tentativas',
retryModeDefault: 'Padrão ({mode})',
- retryModeNormal: 'Tentar novamente erros transitórios',
- retryModeAlways: 'Tentar novamente todos os erros',
+ retryModeNormal: 'Erros transitórios',
+ retryModeAlways: 'Todos os erros',
retryAlwaysHint: 'As solicitações com falha são repetidas até terem sucesso ou até o turno ser interrompido.',
- retryMaxRetries: 'Máximo de novas tentativas',
- retryCodes: 'Códigos de erro que permitem nova tentativa',
+ retryMaxRetries: 'Máx. de novas tentativas',
+ retryCodes: 'Códigos de erro',
retryInitialDelay: 'Atraso inicial (ms)',
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
+ retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'O máximo de novas tentativas deve ser um número inteiro maior ou igual a 0.',
retryCodesInvalid: 'Os códigos de erro devem ser texto não vazio.',
retryCodesDuplicate: 'Cada código de erro só pode aparecer uma vez.',
diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
index 3a25dc45af..1d392f90fc 100644
--- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
@@ -69,7 +69,7 @@ function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) {
return { writeSettings, onClose }
}
-const mode = () => screen.getByLabelText
(en.retryMode)
+const mode = () => screen.getByRole('combobox', { name: en.retryPolicy })
const input = (label: string) => screen.getByLabelText(label)
const apply = () => screen.getByText(en.apply)
From 52dc37d9fe8c2bbcfa02e794321d7423cbecd858 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 23 Oct 2026 15:53:29 +0800
Subject: [PATCH 005/196] feat(ui-settings-models): pick retryable error codes
from a labelled list
---
.../ui-settings-models/README.i18n.yaml | 4 +-
packages/client/ui-settings-models/README.md | 2 +-
.../client/ui-settings-models/README.zh.md | 2 +-
.../src/client/ModelsSection.module.css | 7 ++
.../src/client/RetryPolicyEditor.tsx | 70 ++++++++++++++++---
.../ui-settings-models/src/client/locales.ts | 64 +++++++++++++++++
.../tests/retry-policy.client.spec.tsx | 50 ++++++++++---
7 files changed, 177 insertions(+), 22 deletions(-)
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index c571d9b3bc..d862c5b81a 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -21,8 +21,8 @@
en: f4817eb2e3537ed9
zh: e589c193fa85473f
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
- en: 57b27798e605dcb2
- zh: 04bacb88db40a237
+ en: 00c034b43eef5741
+ zh: 4eb3f646725dddbb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: c0b04b2b11af60d3
zh: d195eec281e445cb
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index c8ae8f23ab..227241213f 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits.
-The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the comma-separated error codes and the maximum retry count; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
+The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index c97e567754..9e3d8b9975 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -55,7 +55,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。
-同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑逗号分隔的错误码和最大重试次数;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
+同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
index c79a4deef0..85b4218596 100644
--- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css
+++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
@@ -638,6 +638,13 @@
margin-top: 4px;
}
+/* Code checkboxes in as many even columns as fit. */
+.retryCodeChoices {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 6px 16px;
+}
+
/* One backoff column wide, so the count lines up with the delay fields below. */
.retryCount {
width: max(112px, calc((100% - 16px) / 3));
diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
index 0f300121b4..e35e04373b 100644
--- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
@@ -8,6 +8,7 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
+import { Checkbox } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import type { ModelsKey } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -21,7 +22,7 @@ const INITIAL_DELAY = ['backoff', 'initialDelayMs'] as const
const MAX_DELAY = ['backoff', 'maxDelayMs'] as const
const JITTER = ['backoff', 'jitterRatio'] as const
-type FieldPath = typeof CODES | typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER
+type FieldPath = typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER
/** Backoff fields of both modes, as `[policy path, label]`. */
const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [
@@ -30,6 +31,22 @@ const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [
[JITTER, 'retryJitter'],
]
+/**
+ * Stable `LlmFailure` codes offered as retryable choices, with their labels: the
+ * transient defaults of `dsh-llm`, then the other request failures a retry can
+ * plausibly recover. A stored code outside this list stays listed after them.
+ */
+const CODE_CHOICES: ReadonlyMap = new Map([
+ ['EMPTY_RESPONSE', 'retryCodeEmptyResponse'],
+ ['RATE_LIMIT', 'retryCodeRateLimit'],
+ ['SERVER', 'retryCodeServer'],
+ ['TIMEOUT', 'retryCodeTimeout'],
+ ['TRANSPORT', 'retryCodeTransport'],
+ ['STREAM_CLOSED', 'retryCodeStreamClosed'],
+ ['MALFORMED_RESPONSE', 'retryCodeMalformedResponse'],
+ ['QUOTA', 'retryCodeQuota'],
+])
+
/** Plain decimal text; prefixed forms such as `0x10` stay invalid text. */
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu
@@ -149,12 +166,11 @@ export interface RetryPolicyEditorProps {
}
function textOf(value: unknown): string {
- if (Array.isArray(value)) return value.join(', ')
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
/** Set or remove one field of a plain policy object, dropping an emptied `backoff`. */
-function withField(policy: Record, path: FieldPath, value: unknown): Record {
+function withField(policy: Record, path: FieldPath | typeof CODES, value: unknown): Record {
const assign = (target: Record, key: string, next: unknown): void => {
if (next === undefined) Reflect.deleteProperty(target, key)
else target[key] = next
@@ -194,11 +210,8 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
const edit = (path: FieldPath, text: string): void => {
const trimmed = text.trim()
setTexts(previous => ({ ...previous, [path.join('.')]: trimmed.length === 0 ? '' : text }))
- const value = path === CODES
- ? trimmed.split(',').map(code => code.trim()).filter(code => code.length > 0)
- : DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
- const empty = Array.isArray(value) ? value.length === 0 : value === ''
- props.onChange(withField(current, path, empty ? undefined : value))
+ const value = DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
+ props.onChange(withField(current, path, value === '' ? undefined : value))
}
const field = (path: FieldPath, label: ModelsKey, className = styles['modelField']): ReactNode => {
const invalid = failure?.path?.join('.') === path.join('.')
@@ -208,7 +221,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
)
}
+ const codesField = (): ReactNode => {
+ const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(code => typeof code === 'string') : []
+ const inherited = strings(defaults.value(CODES))
+ const stored = current['retryableCodes']
+ const selected = Array.isArray(stored) ? strings(stored) : inherited
+ const choices = [...new Set([...CODE_CHOICES.keys(), ...inherited, ...selected])]
+ const invalid = failure?.path?.join('.') === CODES.join('.')
+ return (
+
+ {t('retryCodes')}
+
+ {choices.map((code) => {
+ const labelKey = CODE_CHOICES.get(code)
+ return (
+ {
+ const next = choices.filter(value => value === code ? checked : selected.includes(value))
+ // Selecting exactly the inherited codes keeps the field inherited.
+ const inherits = next.length === inherited.length && next.every(value => inherited.includes(value))
+ props.onChange(withField(current, CODES, inherits ? undefined : next))
+ }}
+ />
+ )
+ })}
+
+
+ )
+ }
const labelId = `${props.failureId}-label`
const modeLabel = (value: 'normal' | 'always'): string => t(value === 'normal' ? 'retryModeNormal' : 'retryModeAlways')
return (
@@ -256,7 +306,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
{mode === 'normal'
? <>
{field(MAX_RETRIES, 'retryMaxRetries', `${styles['modelField']} ${styles['retryCount']}`)}
- {field(CODES, 'retryCodes')}
+ {codesField()}
>
: null}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index 0d722a95dc..f0c1ad2cd8 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -63,6 +63,14 @@ export const en = {
retryAlwaysHint: 'Failed requests retry until they succeed or the turn is stopped.',
retryMaxRetries: 'Max retries',
retryCodes: 'Error codes',
+ retryCodeEmptyResponse: 'Empty response',
+ retryCodeRateLimit: 'Rate limited',
+ retryCodeServer: 'Server error',
+ retryCodeTimeout: 'Timeout',
+ retryCodeTransport: 'Network error',
+ retryCodeStreamClosed: 'Stream closed early',
+ retryCodeMalformedResponse: 'Malformed response',
+ retryCodeQuota: 'Quota exceeded',
retryInitialDelay: 'Initial delay (ms)',
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
@@ -224,6 +232,14 @@ export const zh: { [Key in keyof typeof en]: string } = {
retryAlwaysHint: '失败的请求会持续重试,直到成功或本轮被停止。',
retryMaxRetries: '最大重试次数',
retryCodes: '错误码',
+ retryCodeEmptyResponse: '空响应',
+ retryCodeRateLimit: '请求限流',
+ retryCodeServer: '服务器错误',
+ retryCodeTimeout: '超时',
+ retryCodeTransport: '网络错误',
+ retryCodeStreamClosed: '流提前结束',
+ retryCodeMalformedResponse: '响应格式错误',
+ retryCodeQuota: '配额已用尽',
retryInitialDelay: '初始延迟 (ms)',
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
@@ -382,6 +398,14 @@ export const ja = {
'retryAlwaysHint': '失敗したリクエストは、成功するかターンが停止されるまで再試行されます。',
'retryMaxRetries': '最大再試行回数',
'retryCodes': 'エラーコード',
+ 'retryCodeEmptyResponse': '空のレスポンス',
+ 'retryCodeRateLimit': 'レート制限',
+ 'retryCodeServer': 'サーバーエラー',
+ 'retryCodeTimeout': 'タイムアウト',
+ 'retryCodeTransport': 'ネットワークエラー',
+ 'retryCodeStreamClosed': 'ストリームの途中終了',
+ 'retryCodeMalformedResponse': '不正なレスポンス',
+ 'retryCodeQuota': 'クォータ超過',
'retryInitialDelay': '初期遅延 (ms)',
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
@@ -540,6 +564,14 @@ export const es = {
retryAlwaysHint: 'Las solicitudes fallidas se reintentan hasta que tienen éxito o se detiene el turno.',
retryMaxRetries: 'Reintentos máximos',
retryCodes: 'Códigos de error',
+ retryCodeEmptyResponse: 'Respuesta vacía',
+ retryCodeRateLimit: 'Límite de frecuencia',
+ retryCodeServer: 'Error del servidor',
+ retryCodeTimeout: 'Tiempo agotado',
+ retryCodeTransport: 'Error de red',
+ retryCodeStreamClosed: 'Flujo cerrado antes de tiempo',
+ retryCodeMalformedResponse: 'Respuesta mal formada',
+ retryCodeQuota: 'Cuota agotada',
retryInitialDelay: 'Retraso inicial (ms)',
retryMaxDelay: 'Retraso máximo (ms)',
retryJitter: 'Proporción de jitter',
@@ -698,6 +730,14 @@ export const fr = {
retryAlwaysHint: "Les requêtes en échec sont réessayées jusqu'à ce qu'elles réussissent ou que le tour soit arrêté.",
retryMaxRetries: 'Tentatives max.',
retryCodes: "Codes d'erreur",
+ retryCodeEmptyResponse: 'Réponse vide',
+ retryCodeRateLimit: 'Limite de débit',
+ retryCodeServer: 'Erreur serveur',
+ retryCodeTimeout: 'Délai dépassé',
+ retryCodeTransport: 'Erreur réseau',
+ retryCodeStreamClosed: 'Flux interrompu',
+ retryCodeMalformedResponse: 'Réponse mal formée',
+ retryCodeQuota: 'Quota dépassé',
retryInitialDelay: 'Délai initial (ms)',
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
@@ -856,6 +896,14 @@ export const de = {
retryAlwaysHint: 'Fehlgeschlagene Anfragen werden wiederholt, bis sie erfolgreich sind oder der Durchlauf gestoppt wird.',
retryMaxRetries: 'Max. Wiederholungen',
retryCodes: 'Fehlercodes',
+ retryCodeEmptyResponse: 'Leere Antwort',
+ retryCodeRateLimit: 'Ratenbegrenzung',
+ retryCodeServer: 'Serverfehler',
+ retryCodeTimeout: 'Zeitüberschreitung',
+ retryCodeTransport: 'Netzwerkfehler',
+ retryCodeStreamClosed: 'Stream vorzeitig beendet',
+ retryCodeMalformedResponse: 'Fehlerhafte Antwort',
+ retryCodeQuota: 'Kontingent erschöpft',
retryInitialDelay: 'Anfangsverzögerung (ms)',
retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
@@ -1014,6 +1062,14 @@ export const ru = {
retryAlwaysHint: 'Неудачные запросы повторяются до успеха или до остановки хода.',
retryMaxRetries: 'Макс. число повторов',
retryCodes: 'Коды ошибок',
+ retryCodeEmptyResponse: 'Пустой ответ',
+ retryCodeRateLimit: 'Лимит запросов',
+ retryCodeServer: 'Ошибка сервера',
+ retryCodeTimeout: 'Тайм-аут',
+ retryCodeTransport: 'Ошибка сети',
+ retryCodeStreamClosed: 'Поток прерван',
+ retryCodeMalformedResponse: 'Некорректный ответ',
+ retryCodeQuota: 'Квота исчерпана',
retryInitialDelay: 'Начальная задержка (ms)',
retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
@@ -1172,6 +1228,14 @@ export const pt = {
retryAlwaysHint: 'As solicitações com falha são repetidas até terem sucesso ou até o turno ser interrompido.',
retryMaxRetries: 'Máx. de novas tentativas',
retryCodes: 'Códigos de erro',
+ retryCodeEmptyResponse: 'Resposta vazia',
+ retryCodeRateLimit: 'Limite de taxa',
+ retryCodeServer: 'Erro do servidor',
+ retryCodeTimeout: 'Tempo esgotado',
+ retryCodeTransport: 'Erro de rede',
+ retryCodeStreamClosed: 'Fluxo encerrado cedo',
+ retryCodeMalformedResponse: 'Resposta malformada',
+ retryCodeQuota: 'Cota esgotada',
retryInitialDelay: 'Atraso inicial (ms)',
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
index 1d392f90fc..7d8ef15daa 100644
--- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
@@ -71,6 +71,15 @@ function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) {
const mode = () => screen.getByRole('combobox', { name: en.retryPolicy })
const input = (label: string) => screen.getByLabelText(label)
+const CODE_LABELS: Record = {
+ EMPTY_RESPONSE: en.retryCodeEmptyResponse,
+ RATE_LIMIT: en.retryCodeRateLimit,
+ SERVER: en.retryCodeServer,
+ TIMEOUT: en.retryCodeTimeout,
+ TRANSPORT: en.retryCodeTransport,
+ STREAM_CLOSED: en.retryCodeStreamClosed,
+}
+const code_ = (code: string) => screen.getByRole('checkbox', { name: CODE_LABELS[code] ?? code })
const apply = () => screen.getByText(en.apply)
describe('retry policy fields', () => {
@@ -82,13 +91,14 @@ describe('retry policy fields', () => {
fireEvent.change(mode(), { target: { value: 'normal' } })
expect(input(en.retryMaxRetries).placeholder).toBe('5')
- expect(input(en.retryCodes).placeholder).toBe('EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT')
+ expect(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'].every(code => code_(code).checked)).toBe(true)
+ expect(code_('STREAM_CLOSED').checked).toBe(false)
expect(input(en.retryInitialDelay).placeholder).toBe('500')
expect(input(en.retryMaxDelay).placeholder).toBe('10000')
expect(input(en.retryJitter).placeholder).toBe('0.1')
fireEvent.change(input(en.retryMaxRetries), { target: { value: '3' } })
- fireEvent.change(input(en.retryCodes), { target: { value: ' RATE_LIMIT, ,SERVER ' } })
+ for (const code of ['EMPTY_RESPONSE', 'TIMEOUT', 'TRANSPORT']) fireEvent.click(code_(code))
fireEvent.change(input(en.retryJitter), { target: { value: '0.' } })
expect(input(en.retryJitter).value).toBe('0.')
fireEvent.change(input(en.retryJitter), { target: { value: '0.25' } })
@@ -108,7 +118,6 @@ describe('retry policy fields', () => {
[en.retryMaxRetries, '-1', en.retryMaxRetriesInvalid],
[en.retryMaxRetries, '1.5', en.retryMaxRetriesInvalid],
[en.retryMaxRetries, 'many', en.retryMaxRetriesInvalid],
- [en.retryCodes, 'SERVER, SERVER', en.retryCodesDuplicate],
[en.retryInitialDelay, '0', delayInvalid],
[en.retryInitialDelay, '0x10', delayInvalid],
[en.retryMaxDelay, '3000000000', delayInvalid],
@@ -174,15 +183,38 @@ describe('retry policy fields', () => {
})
it.each([
- ['an empty code list', []],
- ['a blank code', ['']],
- ['a numeric code', [500]],
- ])('refuses a stored policy with %s', (_name, codes) => {
+ ['an empty code list', [], en.retryCodesInvalid],
+ ['a blank code', [''], en.retryCodesInvalid],
+ ['a numeric code', [500], en.retryCodesInvalid],
+ ['a repeated code', ['SERVER', 'SERVER'], en.retryCodesDuplicate],
+ ])('refuses a stored policy with %s', (_name, codes, message) => {
setup(deepSeekNamespace({ user: { retryPolicy: { mode: 'normal', retryableCodes: codes } } }))
- expect(screen.getByText(en.retryCodesInvalid)).toBeTruthy()
+ expect(screen.getByText(message)).toBeTruthy()
+ expect(screen.getByRole('group', { name: en.retryCodes }).getAttribute('aria-invalid')).toBe('true')
expect(apply().disabled).toBe(true)
})
+ it('picks codes from a list, keeps stored custom codes, and inherits an unchanged selection', async () => {
+ const { writeSettings } = setup(deepSeekNamespace({
+ base: { retryPolicy: { mode: 'normal', retryableCodes: ['SERVER', 'CUSTOM'] } },
+ user: { retryPolicy: { mode: 'normal' } },
+ }))
+ expect(code_('CUSTOM').checked).toBe(true)
+ expect(code_('RATE_LIMIT').checked).toBe(false)
+ fireEvent.click(code_('CUSTOM'))
+ expect(code_('SERVER').disabled).toBe(true)
+ fireEvent.click(code_('STREAM_CLOSED'))
+ fireEvent.click(code_('STREAM_CLOSED'))
+ fireEvent.click(code_('CUSTOM'))
+ expect(apply().disabled).toBe(false)
+ fireEvent.click(code_('RATE_LIMIT'))
+ fireEvent.click(apply())
+ await waitFor(() => { expect(writeSettings).toHaveBeenCalled() })
+ expect(writeSettings.mock.calls[0]?.[1]).toEqual([
+ { op: 'set', path: ['retryPolicy', 'retryableCodes'], value: ['RATE_LIMIT', 'SERVER', 'CUSTOM'] },
+ ])
+ })
+
it('refuses a stored policy that is not an object', () => {
setup(deepSeekNamespace({ user: { retryPolicy: 'sometimes' } }))
expect(mode().value).toBe('')
@@ -254,6 +286,8 @@ describe('retry policy fields', () => {
setup({ ...namespace, schema: json(Schema.object({ retryPolicy: always })) })
fireEvent.change(mode(), { target: { value: 'always' } })
expect(input(en.retryMaxDelay).placeholder).toBe('')
+ fireEvent.change(mode(), { target: { value: 'normal' } })
+ expect(code_('SERVER').checked).toBe(false)
})
it('renders no retry fields when the namespace schema has no retry policy', () => {
From 953548cd59b9c5a8b7e8551465daecee675916d7 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 17:50:30 +0800
Subject: [PATCH 006/196] feat(web): continue generation after transient
request failures
---
apps/web/tests/live-interactions.e2e.ts | 32 +++++++-
.../client/ui-conversation/README.i18n.yaml | 4 +-
packages/client/ui-conversation/README.md | 2 +
packages/client/ui-conversation/README.zh.md | 2 +
.../ui-conversation/src/client/apply.ts | 8 ++
.../src/client/contract/slots.ts | 9 ++-
.../src/client/input/continuation.ts | 55 +++++++++++++
.../ui-conversation/src/client/input/hub.ts | 42 +++++++++-
.../ui-conversation/src/client/locales.ts | 16 ++++
.../src/client/skeleton/InputBar.module.css | 5 ++
.../src/client/skeleton/InputBar.tsx | 29 +++++--
.../tests/apply-inject.client.spec.tsx | 46 +++++++++++
.../tests/continuation-fixture.client.ts | 28 +++++++
.../tests/continuation.client.spec.ts | 81 +++++++++++++++++++
.../tests/input-bar.client.spec.tsx | 40 +++++++--
.../tests/input-matrix.client.spec.tsx | 2 +
.../tests/input-scenarios.client.spec.tsx | 2 +
.../tests/skeleton.client.spec.tsx | 2 +
.../live-interactions/continued.expected.md | 39 +++++++++
.../retry-exhausted.expected.md | 2 +-
20 files changed, 426 insertions(+), 20 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/input/continuation.ts
create mode 100644 packages/client/ui-conversation/tests/continuation-fixture.client.ts
create mode 100644 packages/client/ui-conversation/tests/continuation.client.spec.ts
create mode 100644 snapshots/web/live-interactions/continued.expected.md
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index 1eedb317fd..0a6c78c8fc 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -414,15 +414,16 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(tripwire.warnings).toEqual([])
}, 120_000)
- it.skipIf(MODE === 'record')('surfaces the terminal turn error after transient retries exhaust', async () => {
+ it.skipIf(MODE === 'record')('continues after transient retries exhaust through a localized user message', async () => {
// A whole-script replacement: three throw entries cover the first request
// plus both budgeted retries (patches cannot reach past the one-call
// derived script). The scenario-owned policy keeps exhaustion fast and
// jitter-free instead of walking the shared default's five backed-off
// attempts.
const failure: ReplayEntry = { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' }
+ const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
await launch(
- () => [failure, failure, failure],
+ () => [failure, failure, failure, ...derived],
{ mode: 'normal', maxRetries: 2, retryableCodes: ['SERVER'], backoff: { initialDelayMs: 25, maxDelayMs: 50, jitterRatio: 0 } },
)
onTestFailed(() => saveFailureShot(page, 'web-e2e-retry-exhausted'))
@@ -443,6 +444,31 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// context; the golden pins both.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(RETRY_EXHAUSTED_EXPECTED, snapshot, MODE)
+ await page.reload({ waitUntil: 'load' })
+ const resume = page.getByRole('button', { name: 'Continue generating', exact: true })
+ await resume.waitFor()
+ const input = page.locator('[data-composer-input]').first()
+ await input.fill('A different instruction')
+ expect(await resume.count()).toBe(0)
+ expect(await page.getByRole('button', { name: 'Send message', exact: true }).isEnabled()).toBe(true)
+ await input.fill('')
+ await resume.waitFor()
+ await page.setViewportSize({ width: 390, height: 844 })
+ const bounds = await resume.boundingBox()
+ expect(bounds).not.toBeNull()
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390)
+ expect(await resume.isEnabled()).toBe(true)
+ const continued = scaffold!.whenTurnSettled()
+ await resume.click()
+ await continued
+ expect(turnEndReasons(sessionEvents)).toEqual(['error', 'completed'])
+ const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
+ ? [event.data.content] : [])
+ expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }], [{ type: 'text', text: 'continue' }]])
+ expect(await resume.count()).toBe(0)
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'continued.expected.md'),
+ await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd), MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
@@ -451,7 +477,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
- 'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md',
+ 'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md', 'continued.expected.md',
])
})
})
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 09a0211fa7..ac94283c97 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -21,8 +21,8 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
- en: c62e2e19c9680567
- zh: e05ba69f967918c0
+ en: 483f64e0a313ffaa
+ zh: 21a243552ac05038
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 3b57654892..cfe8144cec 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -76,6 +76,8 @@ The composer measures its expanded control groups after size, content, visibilit
The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale).
+An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted user input, a settled model attempt, and a loaded start. Clicking submits a normal user message in the current interface language (`继续` in Chinese, `continue` in English); drafts and attachments keep the Send action. Continued requests retain committed history and use the existing submission lifecycle.
+
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index eebbbfca94..f8a1872c79 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -76,6 +76,8 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
+普通 Session 的输入框在空草稿且空闲时,若最新一轮以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束,会提供播放图标按钮。失败轮次必须包含已提交的用户输入和已落盘的模型请求尝试,且起始事件已加载。点击后按当前界面语言提交一条普通用户消息(中文为“继续”,英文为“continue”);存在草稿或附件时保留发送按钮。继续请求保留已提交历史,并沿用现有提交流程。
+
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index b3c0d3bcd0..08b136bf69 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -78,6 +78,10 @@ const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
+const ABSENT_CONTINUATION = {
+ getSnapshot: () => undefined,
+ subscribe: () => () => {},
+}
const ABSENT_BLOCK = {
getSnapshot: (): ComposerBlock | undefined => undefined,
subscribe: () => () => {},
@@ -482,7 +486,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
retryFileUpload: undefined,
toggleCommandMenu: undefined,
stop: undefined,
+ continueGeneration: undefined,
hooks: {
+ continuation: ABSENT_CONTINUATION,
stopShortcut,
busyEnter: submissionPolicy.busyEnter,
fileUploads: ABSENT_FILE_UPLOADS,
@@ -561,7 +567,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
},
stop: () => { stop(sessionId) },
+ continueGeneration: (ending) => { inputHub.continueGeneration(sessionId, ending) },
hooks: {
+ continuation: inputHub.continuation(sessionId),
stopShortcut,
busyEnter: submissionPolicy.busyEnter,
fileUploads: conversation.fileUploads,
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index caec4849b4..c190443fb7 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -14,7 +14,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionPendingInteraction } from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { ComposerBlock } from './composer-blocks.ts'
import type { DraftAttachmentId, InputActions, InputNotice, InputState } from './input.ts'
@@ -529,7 +529,14 @@ export interface ComposerBarInjected {
retryFileUpload: ((id: DraftAttachmentId) => void) | undefined
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
stop: (() => void) | undefined
+ /**
+ * Submit a localized continuation message for the displayed failure.
+ * @param ending - sequence of the displayed failed turn/end.
+ */
+ continueGeneration: ((ending: SessionSeq) => void) | undefined
hooks: {
+ /** Latest transient failure with admitted user input and a loaded turn start. */
+ continuation: ObservableSnapshot
/** Readable sequence while the fixed Stop command is registered. */
stopShortcut: ObservableSnapshot
/**
diff --git a/packages/client/ui-conversation/src/client/input/continuation.ts b/packages/client/ui-conversation/src/client/input/continuation.ts
new file mode 100644
index 0000000000..261629c084
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/input/continuation.ts
@@ -0,0 +1,55 @@
+/** Manual continuation of settled transient model-request failures. */
+import type { SessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
+
+const CONTINUABLE_FAILURES: ReadonlySet = new Set([
+ 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED',
+])
+
+/**
+ * Derive the latest eligible failure from the loaded history. A loaded turn
+ * start, admitted user message, and settled failed request are required;
+ * preparation failures may have consumed input without admitting it.
+ * @param feed - existing Session event source, including replacement and paging.
+ * @returns a stable source of the eligible turn/end sequence, absent otherwise.
+ */
+export function createContinuationSource(feed: SessionEventSource): ObservableSnapshot {
+ return {
+ getSnapshot: () => {
+ const { entries } = feed.getSnapshot()
+ let ending: SessionSeq | undefined
+ let admitted = false
+ let attempted = false
+ for (let index = entries.length - 1; index >= 0; index--) {
+ // oxlint-disable-next-line typescript/no-non-null-assertion -- index is bounded by this immutable window's length.
+ const entry = entries[index]!
+ if (entry.type === 'transient') return undefined
+ const event = entry.event
+ switch (event.type) {
+ case 'turn/end':
+ if (ending !== undefined || event.data.reason.kind !== 'error'
+ || !CONTINUABLE_FAILURES.has(event.data.reason.error.code)) return undefined
+ ending = event.seq
+ break
+ case 'turn/start':
+ return admitted && attempted ? ending : undefined
+ case 'assistant/attempt':
+ attempted = true
+ break
+ case 'step/start':
+ case 'assistant/message':
+ if (!attempted) return undefined
+ break
+ case 'user/message':
+ if (ending !== undefined && event.surfaceOp === 'append' && event.data.source.kind === 'user') admitted = true
+ break
+ default:
+ break
+ }
+ }
+ return undefined
+ },
+ subscribe: listener => feed.subscribe(listener),
+ }
+}
diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts
index 02897a18c6..3c247d322e 100644
--- a/packages/client/ui-conversation/src/client/input/hub.ts
+++ b/packages/client/ui-conversation/src/client/input/hub.ts
@@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {
ISessions, SessionBinding, SessionFace,
} from '@deepseek-ai/dsh-api-session-controller/client'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ModelSelectionProjection } from '@deepseek-ai/dsh-agent/types'
@@ -25,6 +25,7 @@ import type { ComposerBlocks } from '../contract/composer-blocks.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
+import { createContinuationSource } from './continuation.ts'
import { reportMessageSubmission } from './submission-analytics.ts'
import { readConversationDraft } from '../stores.ts'
@@ -57,6 +58,7 @@ interface ConversationAttachmentFace {
/** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */
export class InputHub implements SessionInputResolver {
private readonly shells = new WeakMap()
+ private readonly continuations = new WeakMap>()
/**
* @param ctx - client root context (services resolved lazily per call — boot order stays free).
@@ -207,6 +209,44 @@ export class InputHub implements SessionInputResolver {
return this.shell(id)
}
+ /**
+ * Read manual-continuation eligibility from the retained Session history.
+ * @param id - retained ordinary Session identity.
+ * @returns the identity-stable eligibility source.
+ */
+ continuation(id: SessionId): ObservableSnapshot {
+ const binding = this.sessions().binding(id)
+ if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
+ let source = this.continuations.get(binding)
+ if (source === undefined) {
+ source = createContinuationSource(binding.eventSource)
+ this.continuations.set(binding, source)
+ }
+ return source
+ }
+
+ /**
+ * Submit localized continuation text through the ordinary composer. Stale
+ * failures, active work, and drafts keep their current input unchanged.
+ * @param id - retained ordinary Session identity.
+ * @param ending - failure the displayed button addresses.
+ */
+ continueGeneration(id: SessionId, ending: SessionSeq): void {
+ const binding = this.sessions().binding(id)
+ if (binding === undefined || this.continuation(id).getSnapshot() !== ending) return
+ const session = binding.session.getSnapshot()
+ const shell = this.shellFor(binding)
+ const input = shell.snapshot
+ const inbox = binding.session.projections.faceOf('inbox').getSnapshot() as InboxState | undefined
+ if (session.running || session.removed || session.openState !== 'open' || session.subagent !== null
+ || session.pendingSubmissions.length > 0 || input.phase !== 'plain'
+ || input.draft.trim() !== '' || input.attachmentIds.length > 0
+ || (inbox?.['next-turn'].length ?? 0) > 0 || (inbox?.['next-step'].length ?? 0) > 0
+ || this.conversation().blocks.storeFor(id).getSnapshot() !== undefined) return
+ shell.setDraft(this.t('input.continueMessage'))
+ shell.submit('queue', 'click')
+ }
+
/**
* Query file intake without creating a Session input.
* @param id - target Session.
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 5606842886..c9dc074e1c 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -28,6 +28,8 @@ export const zh = {
'input.file': '文件',
'input.stop': '停止生成',
'input.send': '发送消息',
+ 'input.continue': '继续生成',
+ 'input.continueMessage': '继续',
'input.sending': '正在发送消息',
'input.send.queue': '排队发送',
'input.send.steer': '插话发送',
@@ -412,6 +414,8 @@ export const en = {
'input.file': 'File',
'input.stop': 'Stop generating',
'input.send': 'Send message',
+ 'input.continue': 'Continue generating',
+ 'input.continueMessage': 'continue',
'input.sending': 'Sending message',
'input.send.queue': 'Queue message',
'input.send.steer': 'Steer message',
@@ -795,6 +799,8 @@ export const ja = {
'input.file': 'ファイル',
'input.stop': '生成を停止',
'input.send': 'メッセージを送信',
+ 'input.continue': '生成を続ける',
+ 'input.continueMessage': '続けて',
'input.sending': 'メッセージを送信中',
'input.send.queue': 'メッセージをキューに追加',
'input.send.steer': '実行中の指示にメッセージを追加',
@@ -1176,6 +1182,8 @@ export const es = {
'input.file': 'Archivo',
'input.stop': 'Detener generación',
'input.send': 'Enviar mensaje',
+ 'input.continue': 'Continuar generando',
+ 'input.continueMessage': 'continúa',
'input.sending': 'Enviando mensaje',
'input.send.queue': 'mensaje de cola',
'input.send.steer': 'Redirigir mensaje',
@@ -1559,6 +1567,8 @@ export const fr = {
'input.file': 'Fichier',
'input.stop': 'Arrêter la génération',
'input.send': 'Envoyer le message',
+ 'input.continue': 'Continuer la génération',
+ 'input.continueMessage': 'continue',
'input.sending': 'Envoi du message',
'input.send.queue': "Message de file d'attente",
'input.send.steer': 'Guider avec ce message',
@@ -1942,6 +1952,8 @@ export const de = {
'input.file': 'Datei',
'input.stop': 'Generierung stoppen',
'input.send': 'Nachricht senden',
+ 'input.continue': 'Generierung fortsetzen',
+ 'input.continueMessage': 'weiter',
'input.sending': 'Nachricht wird gesendet',
'input.send.queue': 'Warteschlangennachricht',
'input.send.steer': 'Mit Nachricht lenken',
@@ -2325,6 +2337,8 @@ export const ru = {
'input.file': 'Файл',
'input.stop': 'Остановить генерацию',
'input.send': 'Отправить сообщение',
+ 'input.continue': 'Продолжить генерацию',
+ 'input.continueMessage': 'продолжай',
'input.sending': 'Отправка сообщения',
'input.send.queue': 'Сообщение в очереди',
'input.send.steer': 'Направить сообщение',
@@ -2708,6 +2722,8 @@ export const pt = {
'input.file': 'Arquivo',
'input.stop': 'Parar geração',
'input.send': 'Enviar mensagem',
+ 'input.continue': 'Continuar gerando',
+ 'input.continueMessage': 'continue',
'input.sending': 'Enviando mensagem',
'input.send.queue': 'Mensagem de fila',
'input.send.steer': 'Orientar com mensagem',
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
index 7d6ae29adb..26203b9d11 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
@@ -407,6 +407,11 @@
transform: translateY(-2px);
}
+/* The triangle fills half its canvas; match the 14px arrow without widening the grid track. */
+.continueIcon {
+ transform: scale(1.75);
+}
+
@media (hover: hover) {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index 09a43ec6fc..9d46a571d3 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'
import clsx from 'clsx'
import {
- focusWithoutRing, IconPlusOutlineMedium, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip,
+ focusWithoutRing, IconPlusOutlineMedium, IconTriangleRightFillRegular, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
@@ -46,9 +46,9 @@ export type InputBarProps = ComposerBarProps
export const InputBar = memo(function InputBar({
useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments,
retryFileUpload,
- toggleCommandMenu, stop, t,
+ toggleCommandMenu, stop, continueGeneration, t,
renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut,
- useAccess,
+ useAccess, useContinuation,
useProjection, sessionId, variant, disabled: inert = false, blocked,
workspacePickerOpen = false, onRequestWorkspace,
placeholder, accessory,
@@ -70,6 +70,9 @@ export const InputBar = memo(function InputBar({
useEffect(() => { setActivity(false) }, [sessionId])
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
+ const continuation = useContinuation(value => value)
+ const historyOpen = useSession(s => s.openState === 'open') ?? false
+ const submissionPending = useSession(s => s.pendingSubmissions.length > 0) ?? false
const subagent = useSession(s => s.subagent) ?? null
const removed = useSession(s => s.removed) ?? false
// Plan mode swaps the composer placeholder (the projection is the folded
@@ -362,8 +365,12 @@ export const InputBar = memo(function InputBar({
// exposes Stop independently, and so does a running session while a
// message waits for the Host.
const primaryStops = running && subagent === null && !sending && (empty || blocked !== undefined)
+ const primaryContinues = !running && subagent === null && empty && continuation !== undefined
+ && historyOpen && !submissionPending && (input?.queue.length ?? 0) === 0
+ && !disabled && input.phase === 'plain' && continueGeneration !== undefined
// Disabled native buttons may omit mouseleave; their tooltip must close from state.
- const primaryDisabled = primaryStops ? stopAction === undefined : empty || disabled || machineBusy || uploadsPending
+ const primaryDisabled = primaryStops ? stopAction === undefined
+ : (!primaryContinues && empty) || disabled || machineBusy || uploadsPending
const interruptible = running && (continuable || (sending && subagent === null))
const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)
const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
@@ -371,14 +378,20 @@ export const InputBar = memo(function InputBar({
? t('input.stop')
: sending
? t('input.sending')
- : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
- ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
- : t('input.send')
+ : primaryContinues
+ ? t('input.continue')
+ : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
+ ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
+ : t('input.send')
const onPrimary = (): void => {
if (primaryStops) {
stopAction?.()
return
}
+ if (primaryContinues) {
+ if (!primaryDisabled) continueGeneration(continuation)
+ return
+ }
if (keyboard === undefined) return // absent machine: the button is disabled
/* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */
if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode, 'click')
@@ -560,6 +573,8 @@ export const InputBar = memo(function InputBar({
+ ) : primaryContinues ? (
+
) : (
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
index 523fcffaae..dc69dad5bc 100644
--- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
@@ -23,6 +23,8 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { createConversationStore } from '../src/client/stores.ts'
import type { SessionInputShell } from '../src/client/input/facade.ts'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import { continuationTurn } from './continuation-fixture.client.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -158,6 +160,50 @@ async function bench(renderQuote = false) {
}
describe('Conversation inject API', () => {
+ it.each([['zh', '继续'], ['en', 'continue']])('submits one continuation in the current %s locale', async (locale, message) => {
+ const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
+ await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'SERVER', message: 'busy' } }))
+ const composer = b.composerApi(ROOT)
+ expect(b.composerApi(ROOT).hooks.continuation).toBe(composer.hooks.continuation)
+ expect(composer.hooks.continuation.getSnapshot()).toBe(5)
+ b.runtime.ctx.locale.setLocale(locale)
+ const pending = Promise.withResolvers>>()
+ onTestFinished(() => { pending.resolve({ ok: true, value: { accepted: true } }) })
+ b.sessionFake.prompt.mockImplementationOnce(() => pending.promise)
+ composer.continueGeneration!(SessionSeq(5))
+ composer.continueGeneration!(SessionSeq(5))
+ expect(composer.keyboard!.snapshot.phase).toBe('sending')
+ await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() })
+ expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: message }])
+ expect(b.sessionFake.prompt.mock.calls[0]?.[1]).toBe('queue')
+ pending.resolve({ ok: true, value: { accepted: true } })
+ await vi.waitFor(() => { expect(composer.keyboard!.snapshot.phase).toBe('plain') })
+ expect(composer.keyboard!.snapshot.draft).toBe('')
+ })
+
+ it('rechecks the addressed failure, draft, and live activity before continuing', async () => {
+ const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
+ await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'TIMEOUT', message: 'timeout' } }))
+ const composer = b.composerApi(ROOT)
+ const input = b.inputApi(ROOT)
+ composer.continueGeneration!(SessionSeq(1))
+ input.actions.setDraft('Keep my new instruction')
+ composer.continueGeneration!(SessionSeq(5))
+ expect(input.state.getSnapshot().draft).toBe('Keep my new instruction')
+ input.actions.setDraft('')
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.running = true })
+ composer.continueGeneration!(SessionSeq(5))
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.running = false })
+ await b.runtime.sessions.appendEvent(ROOT, {
+ type: 'event', event: { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } },
+ })
+ composer.continueGeneration!(SessionSeq(5))
+ expect(input.state.getSnapshot().draft).toBe('')
+ expect(b.sessionFake.prompt).not.toHaveBeenCalled()
+ })
+
it('renders literal quote text through the actual Factory and keyed node registration', async () => {
const b = await bench(true)
onTestFinished(() => b.runtime.dispose())
diff --git a/packages/client/ui-conversation/tests/continuation-fixture.client.ts b/packages/client/ui-conversation/tests/continuation-fixture.client.ts
new file mode 100644
index 0000000000..20dc42fdc9
--- /dev/null
+++ b/packages/client/ui-conversation/tests/continuation-fixture.client.ts
@@ -0,0 +1,28 @@
+/** Admitted input and a settled turn for manual-continuation tests. */
+import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import type { TurnEndReason } from '@deepseek-ai/dsh-session/types'
+
+/**
+ * Build one settled turn with ordinary user input.
+ * @param reason - recorded turn ending.
+ * @param number - turn number and corresponding event offset.
+ * @returns ordered durable entries.
+ */
+export function continuationTurn(reason: TurnEndReason, number = 1): SessionLiveEventEntry[] {
+ const offset = (number - 1) * 6
+ return [
+ { type: 'event', event: { type: 'turn/start', seq: SessionSeq(offset), time: 0, data: { turn: number } } },
+ { type: 'event', event: { type: 'step/start', seq: SessionSeq(offset + 1), time: 0, data: { turn: number, step: 1 } } },
+ { type: 'event', event: {
+ type: 'user/message', seq: SessionSeq(offset + 2), time: 0, surfaceOp: 'append',
+ data: createUserMessage({ content: [{ type: 'text', text: 'task' }], source: { kind: 'user' } }),
+ } },
+ { type: 'event', event: {
+ type: 'assistant/attempt', seq: SessionSeq(offset + 3), time: 0, data: { turn: number, step: 1, stream: [] },
+ } },
+ { type: 'event', event: { type: 'step/end', seq: SessionSeq(offset + 4), time: 0, data: { turn: number, step: 1 } } },
+ { type: 'event', event: { type: 'turn/end', seq: SessionSeq(offset + 5), time: 0, data: { turn: number, reason } } },
+ ]
+}
diff --git a/packages/client/ui-conversation/tests/continuation.client.spec.ts b/packages/client/ui-conversation/tests/continuation.client.spec.ts
new file mode 100644
index 0000000000..ba101e7737
--- /dev/null
+++ b/packages/client/ui-conversation/tests/continuation.client.spec.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi } from 'vitest'
+import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import type { TurnEndReason } from '@deepseek-ai/dsh-session/types'
+import { createContinuationSource } from '../src/client/input/continuation.ts'
+import { continuationTurn as turn } from './continuation-fixture.client.ts'
+
+const failed = (code: string): TurnEndReason => ({ kind: 'error', error: { code, message: code } })
+
+describe('manual generation continuation', () => {
+ it.each(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED'])(
+ 'offers continuation for an admitted request ending with %s', (code) => {
+ const feed = new MutableSessionEventSource()
+ feed.replace(turn(failed(code)), false)
+ expect(createContinuationSource(feed).getSnapshot()).toBe(5)
+ },
+ )
+
+ it.each(['AUTH', 'QUOTA', 'ACCOUNT_QUOTA', 'MISSING_CREDENTIAL', 'INVALID_CREDENTIAL', 'UNKNOWN_MODEL',
+ 'INVALID_REQUEST', 'CONTEXT_WINDOW_EXCEEDED', 'TOOL_ACTIVATION_FAILED', 'MALFORMED_RESPONSE', 'PI_AI_ERROR', 'UNKNOWN'])(
+ 'leaves %s to its own recovery action', (code) => {
+ const feed = new MutableSessionEventSource()
+ feed.replace(turn(failed(code)), false)
+ expect(createContinuationSource(feed).getSnapshot()).toBeUndefined()
+ },
+ )
+
+ it.each([
+ { kind: 'completed' }, { kind: 'max-tokens' }, { kind: 'blocked' },
+ { kind: 'interrupted' }, { kind: 'forked' }, { kind: 'aborted', reason: { kind: 'user' } },
+ ])('leaves a $kind turn unchanged', (reason) => {
+ const feed = new MutableSessionEventSource()
+ feed.replace(turn(reason), false)
+ expect(createContinuationSource(feed).getSnapshot()).toBeUndefined()
+ })
+
+ it('requires user input from the failed turn to have entered model history', () => {
+ const feed = new MutableSessionEventSource()
+ const source = createContinuationSource(feed)
+ const entries = turn(failed('TIMEOUT'), 2)
+ feed.replace([...turn({ kind: 'completed' }), ...entries.filter(entry => entry.event.type !== 'user/message')], false)
+ expect(source.getSnapshot()).toBeUndefined()
+ const user = entries[2]!
+ if (user.event.type !== 'user/message') throw new Error('expected user input')
+ const replacement: SessionLiveEventEntry = { type: 'event', event: {
+ ...user.event, surfaceOp: { op: 'replace', startSeq: SessionSeq(0), endSeq: SessionSeq(1) },
+ } }
+ feed.replace(entries.map(entry => entry === user ? replacement : entry), false)
+ expect(source.getSnapshot()).toBeUndefined()
+ })
+
+ it('keeps failures before request dispatch in their preparation flow', () => {
+ const feed = new MutableSessionEventSource()
+ const source = createContinuationSource(feed)
+ feed.replace(turn(failed('TIMEOUT')).filter(entry => entry.event.type !== 'assistant/attempt'), false)
+ expect(source.getSnapshot()).toBeUndefined()
+ })
+
+ it('restores eligibility from paging and replacement, and retires it when a later turn starts', () => {
+ const feed = new MutableSessionEventSource()
+ const source = createContinuationSource(feed)
+ const entries = turn(failed('SERVER'))
+ feed.replace(entries.slice(1), true)
+ expect(source.getSnapshot()).toBeUndefined()
+ feed.prepend(entries.slice(0, 1), false)
+ expect(source.getSnapshot()).toBe(5)
+ expect(createContinuationSource(feed).getSnapshot()).toBe(5)
+ const listener = vi.fn()
+ const unsubscribe = source.subscribe(listener)
+ feed.append({ type: 'event', event: { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } } })
+ expect(listener).toHaveBeenCalledOnce()
+ expect(source.getSnapshot()).toBeUndefined()
+ unsubscribe()
+ feed.replace(turn({ kind: 'completed' }), false)
+ expect(listener).toHaveBeenCalledOnce()
+ expect(source.getSnapshot()).toBeUndefined()
+ feed.replace([], false)
+ expect(source.getSnapshot()).toBeUndefined()
+ })
+})
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index a657abf806..0ba4d5f288 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -25,7 +25,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses
import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { Context } from '@deepseek-ai/cordis'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SubmitOutcome } from '../src/client/contract/input.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts'
@@ -81,6 +81,7 @@ interface BenchOptions {
}
draft?: string
running?: boolean
+ continuation?: SessionSeq
subagent?: Exclude
disabled?: boolean
inert?: boolean
@@ -161,6 +162,8 @@ function bench(over?: BenchOptions) {
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id))
const stop = vi.fn()
+ const continueGeneration = vi.fn()
+ const continuation = createSnapshotStore(over?.continuation)
const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) })
const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null)
const busyEnter = createSnapshotStore<'queue' | 'steer'>(over?.busyEnter ?? 'queue')
@@ -215,6 +218,8 @@ function bench(over?: BenchOptions) {
}),
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
useBusyEnter: bindSnapshotSelector(busyEnter),
+ continueGeneration,
+ useContinuation: bindSnapshotSelector(continuation),
useStopShortcut: bindSnapshotSelector(stopShortcut),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
@@ -252,20 +257,45 @@ function bench(over?: BenchOptions) {
const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false)
const primaryLabel = primaryStops
? '停止生成'
- : over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft
- ? (over.busyEnter === 'steer' ? '插话发送' : '排队发送')
- : '发送消息'
+ : over?.continuation !== undefined && !sendableDraft && over.running !== true && over.subagent === undefined && !composerLocked
+ ? '继续生成'
+ : over?.running === true && steeringAvailable && !composerLocked && plainMessageDraft
+ ? (over.busyEnter === 'steer' ? '插话发送' : '排队发送')
+ : '发送消息'
const button = view.container.querySelector(`button[aria-label="${primaryLabel}"]`)!
const interruptButton = view.container.querySelector('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls,
- menuLauncher, busyEnter, stopShortcut,
+ menuLauncher, busyEnter, stopShortcut, continuation, continueGeneration,
steerQueue: over?.steerQueue,
get placeholder() { return placeholderOf(view.container) },
get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
}
}
+it('offers the play action only for an empty, idle composer with an eligible failure', async () => {
+ const b = bench({ continuation: 2 as SessionSeq })
+ expect(b.button.disabled).toBe(false)
+ expect(b.button.querySelector('svg')).not.toBeNull()
+ fireEvent.click(b.button)
+ expect(b.continueGeneration).toHaveBeenCalledExactlyOnceWith(2)
+ expect(b.sink).not.toHaveBeenCalled()
+ await act(async () => { b.shell.setDraft('A new instruction') })
+ const send = b.view.getByRole('button', { name: '发送消息' })
+ fireEvent.click(send)
+ await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledOnce() })
+ expect(b.sink.mock.calls[0]?.[0]).toBe('A new instruction')
+ expect(b.continueGeneration).toHaveBeenCalledOnce()
+})
+
+it.each([
+ { running: true }, { attachments: [{ kind: 'image' as const, id: 'image' as DraftAttachmentId, file: new File([], 'x.png'), previewUrl: 'blob:test' }] },
+ { blocked: { reason: 'Select a model' } }, { subagent: CONTINUABLE_CHILD }, { deniedEndpoints: ['session/prompt'] },
+])('keeps the continue action unavailable with %j', (options) => {
+ const b = bench({ ...options, continuation: 2 as SessionSeq })
+ expect(b.view.queryByRole('button', { name: '继续生成' })).toBeNull()
+})
+
function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
for (let i = slotCalls.length - 1; i >= 0; i -= 1) {
const call = slotCalls[i]
diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
index 6a18cede9a..524a9af703 100644
--- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
@@ -84,6 +84,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
})),
toggleCommandMenu: vi.fn(),
useBusyEnter: bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue')),
+ continueGeneration: undefined,
+ useContinuation: bindSnapshotSelector(createSnapshotStore(undefined)),
useStopShortcut: bindSnapshotSelector(createSnapshotStore([])),
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
index 380097b5d1..ab9c460af8 100644
--- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
@@ -200,6 +200,8 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
useMenuLauncher: bindSnapshotSelector(controller.launcher),
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
+ continueGeneration: undefined,
+ useContinuation: bindSnapshotSelector(createSnapshotStore(undefined)),
useStopShortcut: bindSnapshotSelector(createSnapshotStore([])),
t: makeTranslate(zh, commonZh),
variant: 'composer',
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
index c061935f89..421da563ff 100644
--- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
@@ -309,6 +309,8 @@ function mount(
resolveDraftAttachments={() => []}
toggleCommandMenu={vi.fn()}
useBusyEnter={bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue'))}
+ continueGeneration={undefined}
+ useContinuation={bindSnapshotSelector(createSnapshotStore(undefined))}
useStopShortcut={bindSnapshotSelector(createSnapshotStore([]))}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
diff --git a/snapshots/web/live-interactions/continued.expected.md b/snapshots/web/live-interactions/continued.expected.md
new file mode 100644
index 0000000000..ab51db50fc
--- /dev/null
+++ b/snapshots/web/live-interactions/continued.expected.md
@@ -0,0 +1,39 @@
+- banner:
+ - navigation "Session hierarchy": Reply with a one-sentence description
+ - text: Standard mode
+ - button "More actions"
+ - button "Open right sidebar"
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+ - button "More conversation views"
+- navigation "Turn navigation":
+ - button "Jump to turn 1"
+ - button "Jump to turn 2"
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
+- button "Copy"
+- status: Failed
+- button "Failed" [disabled] [expanded]
+- group:
+ - status: Retried model request (2/2) · {{duration}}
+- status:
+ - text: This turn failedupstream 503
+ - code: SERVER
+- text: continue {{clock}}
+- button "Copy"
+- status: Completed
+- button "Completed in {{duration}}"
+- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
+- button "Copy"
+- button "Good response"
+- button "Bad response"
+- button "Branch into a new conversation"
+- text: {{clock}}
+- textbox "Message or run a task, / commands, @ files or sessions"
+- button "Add files or run commands"
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash
+- button "Send message" [disabled]
+- button "2 turns 2 steps · {{throughput}} tok/s": 2 turns 2 steps{{throughput}} tok/s
+- button "7.9K tok · Cache hit 99%": 7.9K tokCache hit 99%
+- button "6% of context used": 6%
diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md
index 91ca93cf5e..a99b5a3cbb 100644
--- a/snapshots/web/live-interactions/retry-exhausted.expected.md
+++ b/snapshots/web/live-interactions/retry-exhausted.expected.md
@@ -20,5 +20,5 @@
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash
-- button "Send message" [disabled]
+- button "Continue generating"
- text: 1 turns 1 steps
From a8b1a9b4a8ccac657aa77c08cab5ad9c7d9a18ee Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 18:14:51 +0800
Subject: [PATCH 007/196] fix(web): balance continue icon with composer
controls
---
apps/web/tests/live-interactions.e2e.ts | 8 +++++---
.../src/client/skeleton/InputBar.module.css | 4 ++--
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index 0a6c78c8fc..0e8d7a04f0 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -454,9 +454,11 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await input.fill('')
await resume.waitFor()
await page.setViewportSize({ width: 390, height: 844 })
- const bounds = await resume.boundingBox()
- expect(bounds).not.toBeNull()
- expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390)
+ // ResizeObserver collapses the model label after the viewport changes.
+ await expect.poll(async () => {
+ const bounds = await resume.boundingBox()
+ return bounds !== null && bounds.x + bounds.width <= 390
+ }).toBe(true)
expect(await resume.isEnabled()).toBe(true)
const continued = scaffold!.whenTurnSettled()
await resume.click()
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
index 26203b9d11..a2dcaca5cc 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
@@ -407,9 +407,9 @@
transform: translateY(-2px);
}
-/* The triangle fills half its canvas; match the 14px arrow without widening the grid track. */
+/* Solid artwork uses a smaller footprint than the thin send arrow. */
.continueIcon {
- transform: scale(1.75);
+ transform: scale(1.5);
}
@media (hover: hover) {
From 38e5684d5c4b61502ca7f90fd3e08a7e0e3577ca Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 19:59:54 +0800
Subject: [PATCH 008/196] fix(web): project continuation eligibility from
complete session history
---
...6-manual-generation-continuation.i18n.yaml | 22 +++
...26-10-26-manual-generation-continuation.md | 33 +++++
...10-26-manual-generation-continuation.zh.md | 33 +++++
apps/web/tests/live-interactions.e2e.ts | 11 ++
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
.../guide.i18n.yaml | 16 ++
.../guide.md | 25 ++++
.../guide.zh.md | 25 ++++
.../client/ui-conversation/README.i18n.yaml | 27 ++--
packages/client/ui-conversation/README.md | 17 ++-
packages/client/ui-conversation/README.zh.md | 17 ++-
packages/client/ui-conversation/package.json | 9 +-
.../src/client/contract/slots.ts | 2 +-
.../ui-conversation/src/client/index.ts | 1 +
.../src/client/input/continuation.ts | 55 -------
.../ui-conversation/src/client/input/hub.ts | 21 ++-
.../ui-conversation/src/client/locales.ts | 1 +
.../src/client/skeleton/InputBar.tsx | 6 +-
.../ui-conversation/src/continuation-types.ts | 9 ++
.../ui-conversation/src/host/continuation.ts | 55 +++++++
packages/client/ui-conversation/src/index.ts | 13 +-
.../tests/apply-inject.client.spec.tsx | 140 +++++++++++++++++-
.../ui-conversation/tests/config.host.spec.ts | 32 +++-
...ture.client.ts => continuation-fixture.ts} | 4 +-
.../tests/continuation.client.spec.ts | 81 ----------
.../tests/continuation.host.spec.ts | 139 +++++++++++++++++
.../ui-conversation/tsconfig.client.json | 3 +-
.../client/ui-conversation/tsconfig.host.json | 13 +-
pnpm-lock.yaml | 15 +-
.../verify-package-readme-model-experience.ts | 1 -
.../live-interactions/continued.expected.md | 2 +-
32 files changed, 646 insertions(+), 186 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
create mode 100644 .agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml
create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md
delete mode 100644 packages/client/ui-conversation/src/client/input/continuation.ts
create mode 100644 packages/client/ui-conversation/src/continuation-types.ts
create mode 100644 packages/client/ui-conversation/src/host/continuation.ts
rename packages/client/ui-conversation/tests/{continuation-fixture.client.ts => continuation-fixture.ts} (92%)
delete mode 100644 packages/client/ui-conversation/tests/continuation.client.spec.ts
create mode 100644 packages/client/ui-conversation/tests/continuation.host.spec.ts
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
new file mode 100644
index 0000000000..320476d704
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-10-26-manual-generation-continuation.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
+/agent-note-manual-generation-continuation:
+ en: 327aaa1cdc63b1f5
+ zh: 1147b4a23e51772c
+/agent-note-manual-generation-continuation/problem:
+ en: 027e01e4586da32f
+ zh: dce2c6858ef709c1
+/agent-note-manual-generation-continuation/decision:
+ en: dc5f066e9c0bbb23
+ zh: 6e54f59a34d8fca5
+/agent-note-manual-generation-continuation/alternatives-considered:
+ en: b32c1050bc0ca9de
+ zh: 5e950f842f944818
+/agent-note-manual-generation-continuation/consequences:
+ en: c825acfaae8f049d
+ zh: e4768dab9d8edde3
+/agent-note-manual-generation-continuation/verification:
+ en: bd59f8845a358dec
+ zh: cfcd30ca521e0111
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
new file mode 100644
index 0000000000..8060f1bde4
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
@@ -0,0 +1,33 @@
+# Agent Note: Manual generation continuation
+
+Status: implemented
+
+English | [中文](2026-10-26-manual-generation-continuation.zh.md)
+
+## Problem
+
+Users need another turn after a model request fails. A browser page can omit the failed turn's start; streaming must not repeatedly copy loaded history.
+
+## Decision
+
+The play action submits localized ordinary user text through the existing composer, retaining admission, logging, failure drafts, and telemetry. No resume RPC, AgentLoop change, or Session event format is introduced.
+
+The fixed-size Host projection `conversationContinuation` recovers from complete Session history. Eligibility requires admitted ordinary input and a failed attempt, independently of the browser's 500-message page.
+
+The six-code manual allowlist remains independent of [automatic retry policy](../../../../packages/llm/llm-retry/README.md). Pending [injected context](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) in `next-step` accompanies the new waking message; pending ordinary user submissions prevent duplicates.
+
+## Alternatives considered
+
+**Window scanning or entries caching.** Each revision can materialize a fresh window; caching cannot recover unloaded facts.
+
+**Use provider retryableCodes.** Deployment retry policies could expose authentication or other unsuitable errors as UI actions.
+
+**Add a resume RPC.** Ordinary submission already owns wakeup, admission, error reporting, and history.
+
+## Consequences
+
+Host activation requires `sessionProjections`; custom compositions follow the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md). Fixed-size state avoids Client history scans without introducing a second submission lifecycle.
+
+## Verification
+
+Tests cover checkpoint recovery, long-turn paging, injected context, refusal and retained drafts, and zero Client event-window reads. The recorded Web scenario exercises failure followed by continuation.
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
new file mode 100644
index 0000000000..c7c0d9ee13
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 手动继续生成
+
+Status: implemented
+
+[English](2026-10-26-manual-generation-continuation.md) | 中文
+
+## 问题
+
+模型请求失败后,用户需要发起下一轮。浏览器分页可能不含失败轮次的起点;流式输出也不能反复复制已加载历史。
+
+## 决策
+
+播放按钮通过现有 composer 提交本地化的普通用户文本,沿用准入、日志、失败草稿和遥测语义。不引入 resume RPC,不修改 AgentLoop 或 Session 事件格式。
+
+固定大小的 Host 投影 `conversationContinuation` 从完整 Session 历史恢复。资格要求已准入的普通用户输入和失败尝试,不依赖浏览器的 500 条消息分页。
+
+六类手动继续白名单独立于[自动重试策略](../../../../packages/llm/llm-retry/README.zh.md)。`next-step` 中待处理的[注入上下文](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md) 随新的唤醒消息进入请求;待提交的普通用户消息仍阻止重复提交。
+
+## 考虑过的替代方案
+
+**窗口扫描或 entries 缓存。** 每个 revision 都可能物化新窗口;缓存无法恢复尚未加载的事实。
+
+**采用提供方的 retryableCodes。** 部署重试策略可能把认证等不适合的错误也变成界面动作。
+
+**新增 resume RPC。** 普通提交已经拥有唤醒、准入、错误报告和历史。
+
+## 影响
+
+Host 激活需要 `sessionProjections`;自定义组合按[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md) 调整。固定大小的状态避免 Client 历史扫描,也无需第二套提交生命周期。
+
+## 验证
+
+测试覆盖检查点恢复、长轮次分页、注入上下文、拒绝与草稿保留,以及不读取 Client 事件窗口。已录制的 Web 场景验证失败后的继续流程。
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index 0e8d7a04f0..d1e90d0080 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -448,6 +448,16 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const resume = page.getByRole('button', { name: 'Continue generating', exact: true })
await resume.waitFor()
const input = page.locator('[data-composer-input]').first()
+ await input.fill('/plan ')
+ await input.press('Enter')
+ const plan = page.getByRole('button', { name: 'Plan mode on, press to turn off', exact: true })
+ await plan.waitFor()
+ await expect.poll(() => input.textContent()).toBe('')
+ await plan.click()
+ await plan.waitFor({ state: 'hidden' })
+ await resume.waitFor()
+ expect(await resume.isEnabled()).toBe(true)
+ expect(turnEndReasons(sessionEvents)).toEqual(['error'])
await input.fill('A different instruction')
expect(await resume.count()).toBe(0)
expect(await page.getByRole('button', { name: 'Send message', exact: true }).isEnabled()).toBe(true)
@@ -464,6 +474,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await resume.click()
await continued
expect(turnEndReasons(sessionEvents)).toEqual(['error', 'completed'])
+ expect(sessionEvents.some(event => event.type === 'user/message' && event.data.source.kind === 'plan-mode')).toBe(true)
const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? [event.data.content] : [])
expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }], [{ type: 'text', text: 'continue' }]])
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index be0d89caf2..43726adfb5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -4713,7 +4713,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) |
-| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
+| `@deepseek-ai/dsh-client-ui-conversation` | `sessionProjections` | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 23165f5f85..470c40d5b0 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -4715,7 +4715,7 @@ export interface Config {
| `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) |
-| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
+| `@deepseek-ai/dsh-client-ui-conversation` | `sessionProjections` | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml
new file mode 100644
index 0000000000..50094c2b2c
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
+/:
+ en: cd293f657bb1ab7a
+ zh: 7d942448b488e22c
+/provide-session-projections-for-conversation:
+ en: 6ec06cc57348d333
+ zh: 86665b791e3b2e02
+/provide-session-projections-for-conversation/change:
+ en: b0048aafcb91d8a5
+ zh: c913215a68c5ff5b
+/provide-session-projections-for-conversation/migration:
+ en: e06d674bafc37abe
+ zh: b68409bfbc936d19
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
new file mode 100644
index 0000000000..52fa806c20
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
@@ -0,0 +1,25 @@
+---
+kind: upgrade-guide
+description: "Custom Conversation compositions must provide sessionProjections to activate the Host plugin."
+---
+# Provide Session projections for Conversation
+
+English | [中文](guide.zh.md)
+
+## Change
+
+The Host entry of `@deepseek-ai/dsh-client-ui-conversation` requires the `sessionProjections` service to publish manual continuation eligibility. Custom compositions that mounted Conversation without a projection provider must add one. The shipped Web and Desktop compositions already provide it. Session logs do not require migration.
+
+## Migration
+
+1. Check the custom profile's bundles and `cordis.patch.yml` for an existing `@deepseek-ai/dsh-session-projection` entry. Keep that provider when present.
+2. If the composition has none, add this row to the profile's `cordis.patch.yml`:
+
+ ```yaml
+ - id: session-projection
+ name: '@deepseek-ai/dsh-session-projection'
+ ```
+
+3. Restart the profile and verify that the Conversation Host entry activates. After a supported model-request failure, an empty, idle ordinary composer must offer the play action.
+
+See the [Conversation reference](../../../../packages/client/ui-conversation/README.md#shell-and-standard-props) for the supported failures and submission behavior.
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md
new file mode 100644
index 0000000000..1277f31bf5
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md
@@ -0,0 +1,25 @@
+---
+kind: upgrade-guide
+description: "自定义 Conversation 组合必须提供 sessionProjections 才能激活 Host 插件。"
+---
+# 为 Conversation 提供 Session 投影
+
+[English](guide.md) | 中文
+
+## 变更
+
+`@deepseek-ai/dsh-client-ui-conversation` 的 Host 入口需要 `sessionProjections` 服务来发布手动继续资格。挂载 Conversation 却没有投影提供方的自定义组合必须补上提供方。随产品提供的 Web 和 Desktop 组合已经提供它。Session 日志不需要迁移。
+
+## 迁移
+
+1. 检查自定义 profile 的 bundle 和 `cordis.patch.yml` 中是否已有 `@deepseek-ai/dsh-session-projection` entry;存在时保留该提供方。
+2. 如果组合中没有该提供方,在 profile 的 `cordis.patch.yml` 中添加以下条目:
+
+ ```yaml
+ - id: session-projection
+ name: '@deepseek-ai/dsh-session-projection'
+ ```
+
+3. 重启 profile 并确认 Conversation Host 入口已激活。支持的模型请求失败后,空草稿且空闲的普通 composer 应提供播放按钮。
+
+支持的失败类型和提交行为见 [Conversation 参考](../../../../packages/client/ui-conversation/README.zh.md#shell-and-standard-props)。
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index ac94283c97..2b4fd0c5db 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -21,20 +21,29 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
- en: 483f64e0a313ffaa
- zh: 21a243552ac05038
+ en: ac170c9895e17840
+ zh: c7603d365134ce89
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
/deepseek-ai-dsh-client-ui-conversation/model-experience:
- en: e0a05b73ba3e4318
- zh: dc85ab8433bb4456
-/deepseek-ai-dsh-client-ui-conversation/model-experience/kv-cache-effect:
- en: 9f4e0da7c8addf88
- zh: 1a33d9981af75643
+ en: 215e7ba838619b7b
+ zh: a311da3843709f90
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message:
+ en: 30f3836663c881ca
+ zh: ee3e1d1df8603fad
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/what-the-model-sees:
+ en: d566958ff2f23584
+ zh: f6ebea015ba61872
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/token-effect:
+ en: 0ad06a5163a62c50
+ zh: d25b93fcb2783566
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/kv-cache-effect:
+ en: 326e23f5913e3fc0
+ zh: 865969505814e0af
/deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work:
- en: 38d243630d0e4c38
- zh: a58cdc35b664020b
+ en: d76daa90b6d6f600
+ zh: 37a9f7d402b2552b
/deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index cfe8144cec..eadcdf2467 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -76,7 +76,9 @@ The composer measures its expanded control groups after size, content, visibilit
The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale).
-An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted user input, a settled model attempt, and a loaded start. Clicking submits a normal user message in the current interface language (`继续` in Chinese, `continue` in English); drafts and attachments keep the Send action. Continued requests retain committed history and use the existing submission lifecycle.
+An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted ordinary user input and a settled failed model attempt. Clicking submits a normal user message in the current interface language; drafts and attachments keep the Send action. Continued requests retain committed history and use the existing submission lifecycle.
+
+The Host requires `sessionProjections` and publishes `conversationContinuation`; eligibility is restored from Session history independently of the browser's loaded history pages.
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
@@ -187,11 +189,19 @@ The selector must be a pure function of the owner currency. Its non-null return
## Model Experience
-None, as this package renders browser state and sends user-admitted inputs through Session Controller APIs without constructing model requests.
+### Manual continuation message
+
+#### What the model sees
+
+The play action submits one ordinary user message using the interface language selected at the click: Chinese `继续`, English/French/Portuguese `continue`, Japanese `続けて`, Spanish `continúa`, German `weiter`, or Russian `продолжай`. The admitted message is stored in the Session log and included in subsequent model history; the action does not reconstruct text from the failed attempt.
+
+#### Token effect
+
+Each continuation admitted into model history adds its short localized text and the ordinary user-message overhead. The exact token count depends on the model tokenizer. A submission refused or failed before admission adds no continuation text to model history.
#### KV Cache effect
-None; Conversation assembly and browser input state do not alter provider-side prompt caching.
+The continuation message extends retained history without replacing earlier messages or prompt sections. It preserves an already-reusable prefix; provider cache availability, reuse policy, and eviction remain outside this package's control.
## Known Limitations and Deferred Work
@@ -199,6 +209,7 @@ None; Conversation assembly and browser input state do not alter provider-side p
- **Only registered targets can render** — the shell deliberately has no implicit fallback target beyond the registered `chat` preference.
- **Factory occurrences inherit their render-position Session** — `conversation.content` does not accept an independently addressed Session; that requires a separate Session-provider capability.
+- **Manual continuation uses a fixed product allowlist** — the supported failure codes are independent of the provider's configurable `retryableCodes`; changing automatic retry policy does not expand or reduce this action. `STREAM_CLOSED` permits an explicit new user turn even when the default policy does not retry it. That code can also identify a protocol violation, so another request is not guaranteed to recover.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index f8a1872c79..3573e9c7b4 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -76,7 +76,9 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
-普通 Session 的输入框在空草稿且空闲时,若最新一轮以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束,会提供播放图标按钮。失败轮次必须包含已提交的用户输入和已落盘的模型请求尝试,且起始事件已加载。点击后按当前界面语言提交一条普通用户消息(中文为“继续”,英文为“continue”);存在草稿或附件时保留发送按钮。继续请求保留已提交历史,并沿用现有提交流程。
+普通 Session 的输入框在空草稿且空闲时,若最新一轮以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束,会提供播放图标按钮。失败轮次必须包含已进入模型历史的普通用户输入和已落盘的失败模型请求尝试。点击后按当前界面语言提交一条普通用户消息;存在草稿或附件时保留发送按钮。继续请求保留已提交历史,并沿用现有提交流程。
+
+Host 需要 `sessionProjections`,并发布 `conversationContinuation`;继续资格从 Session 历史恢复,不受浏览器已加载历史分页的影响。
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。
@@ -187,11 +189,19 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
## 模型体验
-无,因为本包渲染浏览器状态,并通过 Session Controller API 发送用户确认提交的输入,而不构造模型请求。
+### 手动继续消息
+
+#### 模型看到什么
+
+播放按钮按点击时选中的界面语言提交一条普通用户消息:中文为 `继续`,英语、法语和葡萄牙语为 `continue`,日语为 `続けて`,西班牙语为 `continúa`,德语为 `weiter`,俄语为 `продолжай`。已准入的消息写入 Session 日志并进入后续模型历史;该操作不从失败尝试中重建文本。
+
+#### Token 影响
+
+每条进入模型历史的继续消息增加其本地化短文本和普通用户消息的封装开销。准确 token 数取决于模型 tokenizer。在准入前被拒绝或失败的提交不会向模型历史添加继续文本。
#### KV Cache 影响
-无;Conversation 组装和浏览器输入状态不会改变提供方侧的 prompt cache。
+继续消息在保留历史后追加,不替换更早的消息或 prompt section。它保留原本可复用的前缀;提供方缓存的可用性、复用策略和淘汰规则不由本包控制。
## 已知限制与暂缓事项
@@ -199,6 +209,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
- **只有已注册 target 可以渲染**——除已注册的 `chat` 偏好外,shell 刻意不提供隐式 fallback target。
- **Factory occurrence 继承渲染位置的 Session**——`conversation.content` 不接受独立寻址的 Session;该能力需要单独的 Session provider。
+- **手动继续使用固定产品白名单**——支持的失败码独立于提供方可配置的 `retryableCodes`;修改自动重试策略不会扩展或缩小此操作的范围。即使默认策略不重试 `STREAM_CLOSED`,用户仍可明确发起新轮次。该错误码也可能表示协议违规,因此再次请求不保证能够恢复。
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index 050319168e..c4b2317d94 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -47,7 +47,9 @@
},
"license": "MIT",
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:~"
+ "@deepseek-ai/schemastery": "workspace:~",
+ "zod": "4.4.3",
+ "@deepseek-ai/dsh-brand": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
@@ -58,7 +60,6 @@
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:*",
"@deepseek-ai/dsh-attachment": "workspace:*",
- "@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-file-upload": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
@@ -86,7 +87,6 @@
"react": "^18.2.0",
"@lexical/headless": "^0.49.0",
"react-dom": "^18.2.0",
- "zod": "4.4.3",
"lexical": "^0.49.0",
"clsx": "^2.0.0",
"@lexical/utils": "^0.49.0",
@@ -97,7 +97,8 @@
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
- "@deepseek-ai/dsh-message-quotes": "workspace:*"
+ "@deepseek-ai/dsh-message-quotes": "workspace:*",
+ "@deepseek-ai/dsh-session-projection": "workspace:*"
},
"files": [
"locale/*.json",
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index c190443fb7..068fb0026e 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -535,7 +535,7 @@ export interface ComposerBarInjected {
*/
continueGeneration: ((ending: SessionSeq) => void) | undefined
hooks: {
- /** Latest transient failure with admitted user input and a loaded turn start. */
+ /** Host-projected latest eligible failure, excluding pending human steering. */
continuation: ObservableSnapshot
/** Readable sequence while the fixed Stop command is registered. */
stopShortcut: ObservableSnapshot
diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts
index d4f06accc5..65d7edc6b2 100644
--- a/packages/client/ui-conversation/src/client/index.ts
+++ b/packages/client/ui-conversation/src/client/index.ts
@@ -1,5 +1,6 @@
/** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */
export type {} from './user-text/session-reference-rule.ts'
+export type {} from '../continuation-types.ts'
/** Declaration-merged node payloads; IDs also identify the corresponding keyed renderer. */
export interface UserTextDataMap {
diff --git a/packages/client/ui-conversation/src/client/input/continuation.ts b/packages/client/ui-conversation/src/client/input/continuation.ts
deleted file mode 100644
index 261629c084..0000000000
--- a/packages/client/ui-conversation/src/client/input/continuation.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/** Manual continuation of settled transient model-request failures. */
-import type { SessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
-import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
-
-const CONTINUABLE_FAILURES: ReadonlySet = new Set([
- 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED',
-])
-
-/**
- * Derive the latest eligible failure from the loaded history. A loaded turn
- * start, admitted user message, and settled failed request are required;
- * preparation failures may have consumed input without admitting it.
- * @param feed - existing Session event source, including replacement and paging.
- * @returns a stable source of the eligible turn/end sequence, absent otherwise.
- */
-export function createContinuationSource(feed: SessionEventSource): ObservableSnapshot {
- return {
- getSnapshot: () => {
- const { entries } = feed.getSnapshot()
- let ending: SessionSeq | undefined
- let admitted = false
- let attempted = false
- for (let index = entries.length - 1; index >= 0; index--) {
- // oxlint-disable-next-line typescript/no-non-null-assertion -- index is bounded by this immutable window's length.
- const entry = entries[index]!
- if (entry.type === 'transient') return undefined
- const event = entry.event
- switch (event.type) {
- case 'turn/end':
- if (ending !== undefined || event.data.reason.kind !== 'error'
- || !CONTINUABLE_FAILURES.has(event.data.reason.error.code)) return undefined
- ending = event.seq
- break
- case 'turn/start':
- return admitted && attempted ? ending : undefined
- case 'assistant/attempt':
- attempted = true
- break
- case 'step/start':
- case 'assistant/message':
- if (!attempted) return undefined
- break
- case 'user/message':
- if (ending !== undefined && event.surfaceOp === 'append' && event.data.source.kind === 'user') admitted = true
- break
- default:
- break
- }
- }
- return undefined
- },
- subscribe: listener => feed.subscribe(listener),
- }
-}
diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts
index 3c247d322e..36ea1c6b4b 100644
--- a/packages/client/ui-conversation/src/client/input/hub.ts
+++ b/packages/client/ui-conversation/src/client/input/hub.ts
@@ -25,7 +25,7 @@ import type { ComposerBlocks } from '../contract/composer-blocks.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
-import { createContinuationSource } from './continuation.ts'
+import type {} from '../../continuation-types.ts'
import { reportMessageSubmission } from './submission-analytics.ts'
import { readConversationDraft } from '../stores.ts'
@@ -210,7 +210,7 @@ export class InputHub implements SessionInputResolver {
}
/**
- * Read manual-continuation eligibility from the retained Session history.
+ * Read Host-projected eligibility while excluding pending human steering.
* @param id - retained ordinary Session identity.
* @returns the identity-stable eligibility source.
*/
@@ -219,7 +219,20 @@ export class InputHub implements SessionInputResolver {
if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
let source = this.continuations.get(binding)
if (source === undefined) {
- source = createContinuationSource(binding.eventSource)
+ const ending = binding.session.projections.faceOf('conversationContinuation')
+ const inbox = binding.session.projections.faceOf('inbox')
+ source = {
+ getSnapshot: () => {
+ const pending = inbox.getSnapshot() as InboxState | undefined
+ return pending?.['next-step'].some(message => message.source.kind === 'user')
+ ? undefined : (ending.getSnapshot() as SessionSeq | null | undefined) ?? undefined
+ },
+ subscribe: (listener) => {
+ const unsubscribeEnding = ending.subscribe(listener)
+ const unsubscribeInbox = inbox.subscribe(listener)
+ return () => { unsubscribeInbox(); unsubscribeEnding() }
+ },
+ }
this.continuations.set(binding, source)
}
return source
@@ -241,7 +254,7 @@ export class InputHub implements SessionInputResolver {
if (session.running || session.removed || session.openState !== 'open' || session.subagent !== null
|| session.pendingSubmissions.length > 0 || input.phase !== 'plain'
|| input.draft.trim() !== '' || input.attachmentIds.length > 0
- || (inbox?.['next-turn'].length ?? 0) > 0 || (inbox?.['next-step'].length ?? 0) > 0
+ || (inbox?.['next-turn'].length ?? 0) > 0
|| this.conversation().blocks.storeFor(id).getSnapshot() !== undefined) return
shell.setDraft(this.t('input.continueMessage'))
shell.submit('queue', 'click')
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index c9dc074e1c..0ace610330 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -29,6 +29,7 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.continue': '继续生成',
+ // Model-visible text, durably logged when admitted as an ordinary user message.
'input.continueMessage': '继续',
'input.sending': '正在发送消息',
'input.send.queue': '排队发送',
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index 9d46a571d3..962edd854f 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -366,8 +366,8 @@ export const InputBar = memo(function InputBar({
// message waits for the Host.
const primaryStops = running && subagent === null && !sending && (empty || blocked !== undefined)
const primaryContinues = !running && subagent === null && empty && continuation !== undefined
- && historyOpen && !submissionPending && (input?.queue.length ?? 0) === 0
- && !disabled && input.phase === 'plain' && continueGeneration !== undefined
+ && historyOpen && !submissionPending && !disabled && input.queue.length === 0
+ && input.phase === 'plain' && continueGeneration !== undefined
// Disabled native buttons may omit mouseleave; their tooltip must close from state.
const primaryDisabled = primaryStops ? stopAction === undefined
: (!primaryContinues && empty) || disabled || machineBusy || uploadsPending
@@ -389,7 +389,7 @@ export const InputBar = memo(function InputBar({
return
}
if (primaryContinues) {
- if (!primaryDisabled) continueGeneration(continuation)
+ continueGeneration(continuation)
return
}
if (keyboard === undefined) return // absent machine: the button is disabled
diff --git a/packages/client/ui-conversation/src/continuation-types.ts b/packages/client/ui-conversation/src/continuation-types.ts
new file mode 100644
index 0000000000..9acece7469
--- /dev/null
+++ b/packages/client/ui-conversation/src/continuation-types.ts
@@ -0,0 +1,9 @@
+/** Host-derived eligibility for an explicit generation-continuation message. */
+import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionMap {
+ /** Eligible latest turn/end, or null while no settled request can be continued. */
+ conversationContinuation: SessionSeq | null
+ }
+}
diff --git a/packages/client/ui-conversation/src/host/continuation.ts b/packages/client/ui-conversation/src/host/continuation.ts
new file mode 100644
index 0000000000..ef2239338e
--- /dev/null
+++ b/packages/client/ui-conversation/src/host/continuation.ts
@@ -0,0 +1,55 @@
+/** Constant-size continuation eligibility over the complete durable Session. */
+import type {} from '../continuation-types.ts'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import { brandNumber } from '@deepseek-ai/dsh-brand'
+import { z } from 'zod'
+
+// Explicit manual requests use a fixed product policy, independent of automatic retry configuration.
+const CONTINUABLE_FAILURES: ReadonlySet = new Set([
+ 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED',
+])
+
+const endingSchema = z.number().int().nonnegative().transform(brandNumber).nullable()
+const stateSchema = z.object({
+ admitted: z.boolean(),
+ failedAttempt: z.boolean(),
+ ending: endingSchema,
+})
+
+type ContinuationState = z.infer
+
+const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null }
+
+/** Eligibility requires admitted human input and a failed settlement in the last request step. */
+export const continuationProjectionDefinition = {
+ key: 'conversationContinuation',
+ stateVersion: 1,
+ stateSchema,
+ init: (): ContinuationState => EMPTY,
+ apply: (state, event) => {
+ switch (event.type) {
+ case 'turn/start':
+ return EMPTY
+ case 'step/start':
+ case 'assistant/message':
+ return state.failedAttempt ? { ...state, failedAttempt: false } : state
+ case 'user/message':
+ return !state.admitted && event.surfaceOp === 'append' && event.data.source.kind === 'user'
+ ? { ...state, admitted: true } : state
+ case 'assistant/attempt':
+ return state.failedAttempt ? state : { ...state, failedAttempt: true }
+ case 'turn/end': {
+ const ending = event.data.reason.kind === 'error' && CONTINUABLE_FAILURES.has(event.data.reason.error.code)
+ && state.admitted && state.failedAttempt ? event.seq : null
+ return ending === null ? EMPTY : { ...EMPTY, ending }
+ }
+ default:
+ return state
+ }
+ },
+ wire: {
+ viewSchema: endingSchema,
+ view: (state: ContinuationState) => state.ending,
+ },
+} satisfies ProjectionDefinition<'conversationContinuation', ContinuationState>
diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts
index afb38b5fe0..6c7515a448 100644
--- a/packages/client/ui-conversation/src/index.ts
+++ b/packages/client/ui-conversation/src/index.ts
@@ -1,5 +1,6 @@
-/** Host registration for browser conversation preferences. */
+/** Host continuation eligibility and browser conversation preferences. */
import type {} from '@deepseek-ai/dsh-settings'
+export type {} from './continuation-types.ts'
import type { Volatile, Context } from '@deepseek-ai/cordis'
import type { BusyEnterBehavior } from './submission-settings.ts'
@@ -7,6 +8,7 @@ import z from '@deepseek-ai/schemastery'
import { BUSY_ENTER_FIELD } from './submission-settings.ts'
import { ConversationSettingsFields } from './submission-settings.ts'
+import { continuationProjectionDefinition } from './host/continuation.ts'
export {
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
@@ -24,9 +26,14 @@ export const Config = z.object({
[BUSY_ENTER_FIELD]: ConversationSettingsFields[BUSY_ENTER_FIELD].volatile(),
})
-/** Host preferences are consumed through the configuration form projection.
+/** Required registry for the durable generation-continuation projection. */
+export const inject = ['sessionProjections']
+
+/** Register generation-continuation eligibility and browser preferences.
* @param ctx Plugin context used for optional settings presentation.
+ * @returns when continuation state is registered and available to Session carriers.
*/
-export function apply(ctx: Context): void {
+export async function apply(ctx: Context): Promise {
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
+ await ctx.sessionProjections.register(continuationProjectionDefinition)
}
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
index dc69dad5bc..8f2b967e06 100644
--- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
@@ -6,6 +6,7 @@ import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
+import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import * as messageQuotes from '@deepseek-ai/dsh-message-quotes/client'
@@ -24,7 +25,11 @@ import { createConversationStore } from '../src/client/stores.ts'
import type { SessionInputShell } from '../src/client/input/facade.ts'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
-import { continuationTurn } from './continuation-fixture.client.ts'
+import { createUserMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm'
+import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionEventWindow } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { InboxWireState } from '@deepseek-ai/dsh-agent/types'
+import { continuationTurn } from './continuation-fixture.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -164,6 +169,7 @@ describe('Conversation inject API', () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'SERVER', message: 'busy' } }))
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
const composer = b.composerApi(ROOT)
expect(b.composerApi(ROOT).hooks.continuation).toBe(composer.hooks.continuation)
expect(composer.hooks.continuation.getSnapshot()).toBe(5)
@@ -186,6 +192,7 @@ describe('Conversation inject API', () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'TIMEOUT', message: 'timeout' } }))
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
const composer = b.composerApi(ROOT)
const input = b.inputApi(ROOT)
composer.continueGeneration!(SessionSeq(1))
@@ -199,11 +206,142 @@ describe('Conversation inject API', () => {
await b.runtime.sessions.appendEvent(ROOT, {
type: 'event', event: { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } },
})
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', null)
composer.continueGeneration!(SessionSeq(5))
expect(input.state.getSnapshot().draft).toBe('')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
})
+ it('keeps continuation available with injected mode context but excludes pending human steering', async () => {
+ const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
+ const composer = b.composerApi(ROOT)
+ const source = composer.hooks.continuation
+ const notified = vi.fn()
+ const unsubscribe = source.subscribe(notified)
+ onTestFinished(unsubscribe)
+ const context = { id: 'context', role: 'user', content: [{ type: 'text', text: 'approval policy context' }], source: { kind: 'user-approval' } }
+ const inbox: InboxWireState = { 'next-turn': [], 'next-step': [context] }
+ await b.runtime.sessions.setProjection(ROOT, 'inbox', inbox)
+ expect(source.getSnapshot()).toBe(5)
+ const user = createUserMessage({ content: [{ type: 'text', text: 'new instruction' }], source: { kind: 'user' } })
+ await b.runtime.sessions.setProjection(ROOT, 'inbox', { ...inbox, 'next-step': [context, user] })
+ expect(source.getSnapshot()).toBeUndefined()
+ composer.continueGeneration!(SessionSeq(5))
+ expect(b.sessionFake.prompt).not.toHaveBeenCalled()
+ await b.runtime.sessions.setProjection(ROOT, 'inbox', inbox)
+ expect(source.getSnapshot()).toBe(5)
+ composer.continueGeneration!(SessionSeq(5))
+ await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() })
+ expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: '继续' }])
+ expect(notified).toHaveBeenCalledTimes(3)
+ })
+
+ it('uses the Host projection on partial pages and does no history materialization for streaming chunks', async () => {
+ const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
+ await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'SERVER', message: 'busy' } }).slice(1), true)
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
+ const source = b.composerApi(ROOT).hooks.continuation
+ expect(source.getSnapshot()).toBe(5)
+ const history = Array.from({ length: 1666 }, (_, index) => continuationTurn({ kind: 'completed' }, index + 1)).flat()
+ history.push(...continuationTurn({ kind: 'completed' }, 1667).slice(0, 4))
+ await b.runtime.sessions.replaceEvents(ROOT, history)
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', null)
+ const feed = b.runtime.sessions.binding(ROOT)!.eventSource
+ if (!(feed instanceof MutableSessionEventSource)) throw new Error('expected mutable fixture feed')
+ const rawSnapshot = feed.getSnapshot.bind(feed)
+ const windows = new WeakMap()
+ let materializations = 0
+ const read = vi.spyOn(feed, 'getSnapshot').mockImplementation(() => {
+ const window = rawSnapshot()
+ let observed = windows.get(window)
+ if (observed === undefined) {
+ observed = {
+ get entries() { materializations++; return window.entries },
+ hasMore: window.hasMore, revision: window.revision, change: window.change,
+ }
+ windows.set(window, observed)
+ }
+ return observed
+ })
+ onTestFinished(() => { read.mockRestore() })
+ const notified = vi.fn()
+ const unsubscribe = source.subscribe(notified)
+ onTestFinished(unsubscribe)
+ for (let index = 0; index < 2000; index++) {
+ feed.append({ type: 'transient', event: { type: 'assistant/live-chunk', seq: 9999.5, time: index,
+ data: { attemptId: LlmAttemptId('streaming'), turn: 1667, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } },
+ } })
+ expect(source.getSnapshot()).toBeUndefined()
+ }
+ expect(materializations).toBe(0)
+ expect(notified).not.toHaveBeenCalled()
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(10001))
+ expect(notified).toHaveBeenCalledOnce()
+ expect(source.getSnapshot()).toBe(10001)
+ unsubscribe()
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', null)
+ expect(notified).toHaveBeenCalledOnce()
+ })
+
+ it.each(['next-turn', 'pending', 'block', 'attachment', 'subagent', 'removed', 'closed'] as const)(
+ 'rejects a stale continuation click after %s changes', async (condition) => {
+ const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
+ const composer = b.composerApi(ROOT)
+ switch (condition) {
+ case 'pending':
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.pendingSubmissions = [{ requestId: 'pending' as SessionRequestId, placement: 'transcript', time: 0, text: 'newer message', attachments: [] }] })
+ break
+ case 'next-turn':
+ await b.runtime.sessions.setProjection(ROOT, 'inbox', { 'next-turn': [createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })], 'next-step': [] })
+ break
+ case 'block':
+ b.runtime.ctx.conversation.blocks.set(ROOT, { reason: 'Choose a model' })
+ break
+ case 'attachment':
+ expect(composer.addFiles?.([new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' })])).toBeNull()
+ break
+ case 'subagent':
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.subagent = { address: { parentSessionId: ROOT, childSessionId: ROOT, mode: 'continuable' }, parentAvailable: true } })
+ break
+ case 'removed':
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.removed = true })
+ break
+ case 'closed':
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.openState = 'error' })
+ break
+ }
+ const before = b.inputApi(ROOT).state.getSnapshot().draft
+ composer.continueGeneration!(SessionSeq(5))
+ expect(b.inputApi(ROOT).state.getSnapshot().draft).toBe(before)
+ expect(b.sessionFake.prompt).not.toHaveBeenCalled()
+ },
+ )
+
+ it.each(['refused', 'failed'] as const)('retains a visible editable continuation draft when submission is %s', async (outcome) => {
+ const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
+ await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
+ const composer = b.composerApi(ROOT)
+ if (outcome === 'refused') b.sessionFake.prompt.mockImplementationOnce(async () => {
+ const error = new RemoteError('gateway/internal', 'request refused', {})
+ await b.runtime.sessions.updateSessionSnapshot(ROOT, (state) => { state.promptError = { op: 'send', error } })
+ return { ok: false, error }
+ })
+ else b.sessionFake.prompt.mockRejectedValueOnce(new Error('connection lost'))
+ composer.continueGeneration!(SessionSeq(5))
+ await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce(); expect(composer.keyboard!.snapshot.phase).toBe('plain') })
+ expect(composer.keyboard!.snapshot.draft).toBe('继续')
+ if (outcome === 'refused') expect(b.runtime.sessions.binding(ROOT)!.session.getSnapshot().promptError?.error.message).toBe('request refused')
+ else expect(composer.hooks.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'connection lost' })
+ composer.continueGeneration!(SessionSeq(5))
+ expect(b.sessionFake.prompt).toHaveBeenCalledOnce()
+ })
+
it('renders literal quote text through the actual Factory and keyed node registration', async () => {
const b = await bench(true)
onTestFinished(() => b.runtime.dispose())
diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts
index 7b87d23d59..402063cedc 100644
--- a/packages/client/ui-conversation/tests/config.host.spec.ts
+++ b/packages/client/ui-conversation/tests/config.host.spec.ts
@@ -1,17 +1,22 @@
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it } from 'vitest'
+import SessionStore from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { Context, FiberState } from '@deepseek-ai/cordis'
+import { describe, expect, it, onTestFinished } from 'vitest'
import * as HostPlugin from '../src/index.ts'
import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts'
import { plainConfig } from '../../../settings/settings/src/schema.ts'
import {
- DEFAULT_BUSY_ENTER_BEHAVIOR, Config, apply,
+ DEFAULT_BUSY_ENTER_BEHAVIOR,
} from '@deepseek-ai/dsh-client-ui-conversation'
describe('ui-conversation host', () => {
it('registers, validates, and disposes the durable busy-Enter preference', async () => {
const ctx = new Context()
- const configuration = await liveConfig(ctx, { Config, apply })
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ const configuration = await liveConfig(ctx, HostPlugin)
const { fiber } = configuration
expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR })
await configuration.update({ busyEnter: 'steer' })
@@ -21,4 +26,21 @@ describe('ui-conversation host', () => {
})
})
-it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(ctx => ctx.plugin(HostPlugin)))
+it('keeps its own instance off the generated Settings pages', () => omitsGeneratedPage(async (ctx) => {
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ return ctx.plugin(HostPlugin)
+}))
+
+it('waits for the required projection registry before activation', async () => {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionStore)
+ const fiber = ctx.plugin(HostPlugin)
+ await ctx.fiber.await()
+ expect(fiber.state).toBe(FiberState.PENDING)
+ await ctx.plugin(SessionProjectionRegistry)
+ await fiber
+ expect(fiber.state).toBe(FiberState.ACTIVE)
+})
diff --git a/packages/client/ui-conversation/tests/continuation-fixture.client.ts b/packages/client/ui-conversation/tests/continuation-fixture.ts
similarity index 92%
rename from packages/client/ui-conversation/tests/continuation-fixture.client.ts
rename to packages/client/ui-conversation/tests/continuation-fixture.ts
index 20dc42fdc9..392f01d39a 100644
--- a/packages/client/ui-conversation/tests/continuation-fixture.client.ts
+++ b/packages/client/ui-conversation/tests/continuation-fixture.ts
@@ -1,5 +1,5 @@
/** Admitted input and a settled turn for manual-continuation tests. */
-import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { TurnEndReason } from '@deepseek-ai/dsh-session/types'
@@ -10,7 +10,7 @@ import type { TurnEndReason } from '@deepseek-ai/dsh-session/types'
* @param number - turn number and corresponding event offset.
* @returns ordered durable entries.
*/
-export function continuationTurn(reason: TurnEndReason, number = 1): SessionLiveEventEntry[] {
+export function continuationTurn(reason: TurnEndReason, number = 1): { type: 'event'; event: SessionEvent }[] {
const offset = (number - 1) * 6
return [
{ type: 'event', event: { type: 'turn/start', seq: SessionSeq(offset), time: 0, data: { turn: number } } },
diff --git a/packages/client/ui-conversation/tests/continuation.client.spec.ts b/packages/client/ui-conversation/tests/continuation.client.spec.ts
deleted file mode 100644
index ba101e7737..0000000000
--- a/packages/client/ui-conversation/tests/continuation.client.spec.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
-import { SessionSeq } from '@deepseek-ai/dsh-session/types'
-import type { TurnEndReason } from '@deepseek-ai/dsh-session/types'
-import { createContinuationSource } from '../src/client/input/continuation.ts'
-import { continuationTurn as turn } from './continuation-fixture.client.ts'
-
-const failed = (code: string): TurnEndReason => ({ kind: 'error', error: { code, message: code } })
-
-describe('manual generation continuation', () => {
- it.each(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED'])(
- 'offers continuation for an admitted request ending with %s', (code) => {
- const feed = new MutableSessionEventSource()
- feed.replace(turn(failed(code)), false)
- expect(createContinuationSource(feed).getSnapshot()).toBe(5)
- },
- )
-
- it.each(['AUTH', 'QUOTA', 'ACCOUNT_QUOTA', 'MISSING_CREDENTIAL', 'INVALID_CREDENTIAL', 'UNKNOWN_MODEL',
- 'INVALID_REQUEST', 'CONTEXT_WINDOW_EXCEEDED', 'TOOL_ACTIVATION_FAILED', 'MALFORMED_RESPONSE', 'PI_AI_ERROR', 'UNKNOWN'])(
- 'leaves %s to its own recovery action', (code) => {
- const feed = new MutableSessionEventSource()
- feed.replace(turn(failed(code)), false)
- expect(createContinuationSource(feed).getSnapshot()).toBeUndefined()
- },
- )
-
- it.each([
- { kind: 'completed' }, { kind: 'max-tokens' }, { kind: 'blocked' },
- { kind: 'interrupted' }, { kind: 'forked' }, { kind: 'aborted', reason: { kind: 'user' } },
- ])('leaves a $kind turn unchanged', (reason) => {
- const feed = new MutableSessionEventSource()
- feed.replace(turn(reason), false)
- expect(createContinuationSource(feed).getSnapshot()).toBeUndefined()
- })
-
- it('requires user input from the failed turn to have entered model history', () => {
- const feed = new MutableSessionEventSource()
- const source = createContinuationSource(feed)
- const entries = turn(failed('TIMEOUT'), 2)
- feed.replace([...turn({ kind: 'completed' }), ...entries.filter(entry => entry.event.type !== 'user/message')], false)
- expect(source.getSnapshot()).toBeUndefined()
- const user = entries[2]!
- if (user.event.type !== 'user/message') throw new Error('expected user input')
- const replacement: SessionLiveEventEntry = { type: 'event', event: {
- ...user.event, surfaceOp: { op: 'replace', startSeq: SessionSeq(0), endSeq: SessionSeq(1) },
- } }
- feed.replace(entries.map(entry => entry === user ? replacement : entry), false)
- expect(source.getSnapshot()).toBeUndefined()
- })
-
- it('keeps failures before request dispatch in their preparation flow', () => {
- const feed = new MutableSessionEventSource()
- const source = createContinuationSource(feed)
- feed.replace(turn(failed('TIMEOUT')).filter(entry => entry.event.type !== 'assistant/attempt'), false)
- expect(source.getSnapshot()).toBeUndefined()
- })
-
- it('restores eligibility from paging and replacement, and retires it when a later turn starts', () => {
- const feed = new MutableSessionEventSource()
- const source = createContinuationSource(feed)
- const entries = turn(failed('SERVER'))
- feed.replace(entries.slice(1), true)
- expect(source.getSnapshot()).toBeUndefined()
- feed.prepend(entries.slice(0, 1), false)
- expect(source.getSnapshot()).toBe(5)
- expect(createContinuationSource(feed).getSnapshot()).toBe(5)
- const listener = vi.fn()
- const unsubscribe = source.subscribe(listener)
- feed.append({ type: 'event', event: { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } } })
- expect(listener).toHaveBeenCalledOnce()
- expect(source.getSnapshot()).toBeUndefined()
- unsubscribe()
- feed.replace(turn({ kind: 'completed' }), false)
- expect(listener).toHaveBeenCalledOnce()
- expect(source.getSnapshot()).toBeUndefined()
- feed.replace([], false)
- expect(source.getSnapshot()).toBeUndefined()
- })
-})
diff --git a/packages/client/ui-conversation/tests/continuation.host.spec.ts b/packages/client/ui-conversation/tests/continuation.host.spec.ts
new file mode 100644
index 0000000000..1b0dd1c7b3
--- /dev/null
+++ b/packages/client/ui-conversation/tests/continuation.host.spec.ts
@@ -0,0 +1,139 @@
+/** Complete-log continuation projection, including recovery and publication. */
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore, { SessionId, SessionSeq, SessionLogOffset } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session/types'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import * as plugin from '../src/index.ts'
+import { continuationProjectionDefinition as definition } from '../src/host/continuation.ts'
+import { paginate } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
+import { continuationTurn as turn } from './continuation-fixture.ts'
+
+const failed = (code: string): TurnEndReason => ({ kind: 'error', error: { code, message: code } })
+function fold(events: readonly SessionEvent[]) {
+ return events.reduce((state, event) => definition.apply(state, event), definition.init())
+}
+const events = (reason: TurnEndReason) => turn(reason).map(entry => entry.event)
+
+it.each(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'STREAM_CLOSED'])(
+ 'offers a manual new turn after %s independently of automatic retries', (code) => {
+ expect(definition.wire.view(fold(events(failed(code))))).toBe(5)
+ },
+)
+it.each(['AUTH', 'QUOTA', 'ACCOUNT_QUOTA', 'MISSING_CREDENTIAL', 'INVALID_CREDENTIAL', 'UNKNOWN_MODEL',
+ 'INVALID_REQUEST', 'CONTEXT_WINDOW_EXCEEDED', 'TOOL_ACTIVATION_FAILED', 'MALFORMED_RESPONSE', 'PI_AI_ERROR', 'UNKNOWN'])(
+ 'leaves %s to its own recovery action', (code) => {
+ expect(definition.wire.view(fold(events(failed(code))))).toBeNull()
+ },
+)
+it.each([
+ { kind: 'completed' }, { kind: 'max-tokens' }, { kind: 'blocked' },
+ { kind: 'interrupted' }, { kind: 'forked' }, { kind: 'aborted', reason: { kind: 'user' } },
+])('leaves a $kind turn unchanged', (reason) => {
+ expect(definition.wire.view(fold(events(reason)))).toBeNull()
+})
+
+it('requires admitted human input and a failed settlement in the last step', () => {
+ const base = events(failed('TIMEOUT'))
+ expect(fold(base.filter(event => event.type !== 'user/message')).ending).toBeNull()
+ expect(fold(base.filter(event => event.type !== 'assistant/attempt')).ending).toBeNull()
+ const user = base.find(event => event.type === 'user/message')!
+ const replacement: SessionEvent<'user/message'> = { ...user,
+ surfaceOp: { op: 'replace', startSeq: SessionSeq(0), endSeq: SessionSeq(1) },
+ }
+ expect(fold(base.map(event => event === user ? replacement : event)).ending).toBeNull()
+ const injected: SessionEvent<'user/message'> = { ...user, data: createUserMessage({
+ content: [{ type: 'text', text: 'mode context' }], source: { kind: 'user-approval' },
+ }) }
+ expect(fold(base.map(event => event === user ? injected : event)).ending).toBeNull()
+ const prefix = base.slice(0, -1)
+ const step: SessionEvent<'step/start'> = { type: 'step/start', seq: SessionSeq(5), time: 0, data: { turn: 1, step: 2 } }
+ expect(fold([...prefix, step, base.at(-1)!]).ending).toBeNull()
+ const success: SessionEvent<'assistant/message'> = { type: 'assistant/message', seq: SessionSeq(5), time: 0,
+ surfaceOp: 'append', data: { turn: 1, step: 1, stream: [], message: createAssistantMessage({
+ content: [{ type: 'text', text: 'done' }], source: { provider: 'test', model: 'test' },
+ }) },
+ }
+ expect(fold([...prefix, success, base.at(-1)!]).ending).toBeNull()
+})
+
+it('keeps unrelated events and repeated settlements reference-stable', () => {
+ const base = events(failed('SERVER'))
+ const state = fold(base.slice(0, -1))
+ expect(definition.apply(state, base[2]!)).toBe(state)
+ expect(definition.apply(state, base[3]!)).toBe(state)
+ expect(definition.apply(state, base[4]!)).toBe(state)
+ expect(definition.apply(definition.init(), base[1]!)).toBe(definition.init())
+ const reset = definition.apply(state, base[1]!)
+ expect(reset.failedAttempt).toBe(false)
+})
+
+it('restores a checkpoint plus suffix identically to full replay and retires on a new turn', () => {
+ const base = events(failed('SERVER'))
+ const prefix = fold(base.slice(0, 3))
+ const restored = definition.stateSchema.parse(JSON.parse(JSON.stringify(prefix)))
+ expect(base.slice(3).reduce((state, event) => definition.apply(state, event), restored)).toEqual(fold(base))
+ const ended = fold(base)
+ expect(definition.apply(ended, { type: 'turn/start', seq: SessionSeq(6), time: 1, data: { turn: 2 } }).ending).toBeNull()
+ expect(definition.wire.viewSchema.parse(ended.ending)).toBe(5)
+ expect(() => definition.stateSchema.parse({ ...ended, ending: -1 })).toThrow()
+ expect(() => definition.wire.viewSchema.parse('5')).toThrow()
+})
+
+it('publishes a complete-log result independently of the visible page and releases registration', async () => {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ const fiber = await ctx.plugin(plugin)
+ const session = ctx.sessions.create(SessionId('continued'))
+ expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull()
+ const history: SessionEvent[] = []
+ ctx.on('session/event', (_session, event) => { history.push(event) })
+ const changed = vi.fn[0]>()
+ ctx.sessionProjections.onChanged(changed)
+ session.append('turn/start', { turn: 1 })
+ expect(changed).not.toHaveBeenCalled()
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'task' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
+ // A long failed turn exceeds the browser's 500-message page budget.
+ for (let index = 0; index < 600; index++) {
+ session.append('assistant/message', { turn: 1, step: index + 1, stream: [], message: createAssistantMessage({ content: [{ type: 'text', text: 'progress' }], source: { provider: 'test', model: 'test' } }) }, { surfaceOp: 'append' })
+ session.append('step/end', { turn: 1, step: index + 1 })
+ session.append('step/start', { turn: 1, step: index + 2 })
+ }
+ session.append('assistant/attempt', { turn: 1, step: 601, stream: [] })
+ const ending = session.append('turn/end', { turn: 1, reason: failed('SERVER') }).seq
+ const page = paginate(history, undefined, 500, ending, { minMessages: 50, minTurns: 2 })
+ expect(page.hasMore).toBe(true)
+ expect(page.events.some(event => event.type === 'turn/start')).toBe(false)
+ expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBe(ending)
+ expect(changed.mock.calls.map(([, key, value]) => [key, value])).toEqual([['conversationContinuation', null], ['conversationContinuation', ending]])
+ expect(ctx.sessionProjections.checkpoint(session).conversationContinuation)
+ .toEqual({ ver: 1, seq: ending, val: { admitted: false, failedAttempt: false, ending } })
+ session.append('turn/start', { turn: 2 })
+ expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull()
+ expect(changed).toHaveBeenCalledTimes(3)
+ await fiber.dispose()
+ expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('conversationContinuation')
+})
+
+it('restores valid, absent and outdated checkpoints through the registry', async () => {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(plugin)
+ const session = ctx.sessions.create(SessionId('restored'))
+ const log = events(failed('SERVER'))
+ const checkpoint = { conversationContinuation: { ver: 1, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } }
+ const restore = (rows: typeof checkpoint | Record, tail: readonly SessionEvent[], base: number) =>
+ ctx.sessionProjections.restore(rows, tail, SessionLogOffset(base), session.header, session.inheritedEventCount)
+ expect(restore(checkpoint, log.slice(3), 3).snapshot.values.conversationContinuation).toBe(5)
+ expect(restore({}, log, 0).snapshot.values.conversationContinuation).toBe(5)
+ const outdated = { conversationContinuation: { ...checkpoint.conversationContinuation, ver: 0 } }
+ expect(restore(outdated, log, 0).snapshot.values.conversationContinuation).toBe(5)
+ expect(() => restore(outdated, log.slice(3), 3)).toThrow('re-read from seq 0')
+ expect(() => restore({}, log.slice(3), 3)).toThrow('re-read from seq 0')
+})
diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json
index 0339eebbe6..cc0d628ef0 100644
--- a/packages/client/ui-conversation/tsconfig.client.json
+++ b/packages/client/ui-conversation/tsconfig.client.json
@@ -107,6 +107,7 @@
}
],
"exclude": [
- "src/index.ts"
+ "src/index.ts",
+ "src/host"
]
}
diff --git a/packages/client/ui-conversation/tsconfig.host.json b/packages/client/ui-conversation/tsconfig.host.json
index ec30b21bfe..cc278423fc 100644
--- a/packages/client/ui-conversation/tsconfig.host.json
+++ b/packages/client/ui-conversation/tsconfig.host.json
@@ -7,7 +7,9 @@
},
"files": [
"src/submission-settings.ts",
- "src/index.ts"
+ "src/index.ts",
+ "src/continuation-types.ts",
+ "src/host/continuation.ts"
],
"references": [
{
@@ -18,6 +20,15 @@
},
{
"path": "../../settings/settings"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../session/session-projection"
+ },
+ {
+ "path": "../../util/brand"
}
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 281ea5d111..489c15f56e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4001,9 +4001,15 @@ importers:
packages/client/ui-conversation:
dependencies:
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:*
+ version: link:../../util/brand
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
+ zod:
+ specifier: 4.4.3
+ version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -4023,9 +4029,6 @@ importers:
'@deepseek-ai/dsh-attachment':
specifier: workspace:*
version: link:../../attachment/attachment
- '@deepseek-ai/dsh-brand':
- specifier: workspace:*
- version: link:../../util/brand
'@deepseek-ai/dsh-client-file-upload':
specifier: workspace:*
version: link:../file-upload
@@ -4089,6 +4092,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:*
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-settings':
specifier: workspace:*
version: link:../../settings/settings
@@ -4137,9 +4143,6 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
- zod:
- specifier: 4.4.3
- version: 4.4.3
packages/client/ui-deepseek-model-label-1017:
devDependencies:
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index aed0e5a121..e236f4e02b 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -138,7 +138,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/client/ui-sidebar-right': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' },
- 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/ui-approval': { kind: 'none', reason: 'Browser-side approval presentation; registers nothing model-facing.' },
'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' },
'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over log-only message feedback; ratings and notes never enter model context or change model-input tokens.' },
diff --git a/snapshots/web/live-interactions/continued.expected.md b/snapshots/web/live-interactions/continued.expected.md
index ab51db50fc..1eb97847c2 100644
--- a/snapshots/web/live-interactions/continued.expected.md
+++ b/snapshots/web/live-interactions/continued.expected.md
@@ -19,7 +19,7 @@
- status:
- text: This turn failedupstream 503
- code: SERVER
-- text: continue {{clock}}
+- text: plan Plan mode on. Use /plan off to leave. plan Plan mode off. continue {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}"
From d8254627feb3d4d1017819cc9e3bc0da31926b80 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 22:12:13 +0800
Subject: [PATCH 009/196] feat(core): continue failed turns through empty
prompt input
---
...6-manual-generation-continuation.i18n.yaml | 20 ++---
...26-10-26-manual-generation-continuation.md | 18 ++--
...10-26-manual-generation-continuation.zh.md | 20 ++---
apps/web/tests/live-interactions.e2e.ts | 6 +-
docs/architecture.i18n.yaml | 4 +-
docs/architecture.md | 2 +
docs/architecture.zh.md | 2 +
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
.../guide.i18n.yaml | 16 ----
.../guide.md | 25 ------
.../guide.zh.md | 25 ------
.../empty-prompt-continuation/guide.i18n.yaml | 16 ++++
.../empty-prompt-continuation/guide.md | 19 +++++
.../empty-prompt-continuation/guide.zh.md | 19 +++++
.../api/session-controller/README.i18n.yaml | 4 +-
packages/api/session-controller/README.md | 2 +
packages/api/session-controller/README.zh.md | 2 +
.../api/session-controller/src/commands.ts | 29 +++++++
.../session-controller/src}/continuation.ts | 22 +++--
packages/api/session-controller/src/index.ts | 4 +-
packages/api/session-controller/src/types.ts | 8 +-
.../tests/continuation-fixture.ts | 0
.../tests/continuation.host.spec.ts | 43 ++++++++--
.../tests/empty-prompt.host.spec.ts | 82 +++++++++++++++++++
.../tests/session-cold.host.spec.ts | 1 -
.../api/session-controller/tsconfig.host.json | 6 +-
.../client/ui-conversation/README.i18n.yaml | 28 +++----
packages/client/ui-conversation/README.md | 12 +--
packages/client/ui-conversation/README.zh.md | 12 +--
packages/client/ui-conversation/package.json | 8 +-
.../src/client/contract/input.ts | 2 +
.../src/client/contract/slots.ts | 2 +-
.../ui-conversation/src/client/index.ts | 1 -
.../src/client/input/facade.ts | 7 +-
.../ui-conversation/src/client/input/hub.ts | 10 +--
.../src/client/input/machine.ts | 2 +-
.../ui-conversation/src/client/locales.ts | 9 --
.../ui-conversation/src/continuation-types.ts | 9 --
packages/client/ui-conversation/src/index.ts | 14 ++--
.../tests/apply-inject.client.spec.tsx | 12 +--
.../ui-conversation/tests/config.host.spec.ts | 14 +---
.../client/ui-conversation/tsconfig.host.json | 13 +--
packages/core/agent-loop/README.i18n.yaml | 4 +-
packages/core/agent-loop/README.md | 2 +
packages/core/agent-loop/README.zh.md | 2 +
packages/core/agent-loop/src/agent.ts | 2 +-
.../agent-loop/tests/request-error.spec.ts | 27 +++++-
packages/core/agent/README.i18n.yaml | 4 +-
packages/core/agent/README.md | 2 +
packages/core/agent/README.zh.md | 2 +
packages/core/agent/src/runtime-types.ts | 4 +-
pnpm-lock.yaml | 12 +--
scripts/smoke-python-runtime.py | 9 +-
.../scheduler-recovery/requests.json | 4 -
.../scheduler-recovery/result.json | 2 -
.../scheduler-recovery/session.v4.jsonl | 3 +-
snapshots/sdk/sdk.snapshot.ts | 19 ++++-
.../notifications.expected.jsonl | 9 +-
.../tool-scheduler-recovery/session.v4.jsonl | 3 +-
.../live-interactions/continued.expected.md | 2 +-
61 files changed, 411 insertions(+), 255 deletions(-)
delete mode 100644 docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml
delete mode 100644 docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
delete mode 100644 docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml
create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md
rename packages/{client/ui-conversation/src/host => api/session-controller/src}/continuation.ts (65%)
rename packages/{client/ui-conversation => api/session-controller}/tests/continuation-fixture.ts (100%)
rename packages/{client/ui-conversation => api/session-controller}/tests/continuation.host.spec.ts (78%)
create mode 100644 packages/api/session-controller/tests/empty-prompt.host.spec.ts
delete mode 100644 packages/client/ui-conversation/src/continuation-types.ts
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
index 320476d704..0b66ec2f1d 100644
--- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
@@ -6,17 +6,17 @@
en: 327aaa1cdc63b1f5
zh: 1147b4a23e51772c
/agent-note-manual-generation-continuation/problem:
- en: 027e01e4586da32f
- zh: dce2c6858ef709c1
+ en: f118844946afdbdc
+ zh: f1fe37d042f88b24
/agent-note-manual-generation-continuation/decision:
- en: dc5f066e9c0bbb23
- zh: 6e54f59a34d8fca5
+ en: 32e75f0c2a35bfec
+ zh: 343fedcd8f53a6a8
/agent-note-manual-generation-continuation/alternatives-considered:
- en: b32c1050bc0ca9de
- zh: 5e950f842f944818
+ en: 58bb948d210d42ea
+ zh: 5dc920d222d71218
/agent-note-manual-generation-continuation/consequences:
- en: c825acfaae8f049d
- zh: e4768dab9d8edde3
+ en: e86aee3a7da7649f
+ zh: d58e8cc94e5008bf
/agent-note-manual-generation-continuation/verification:
- en: bd59f8845a358dec
- zh: cfcd30ca521e0111
+ en: e31a5bd5826c689f
+ zh: 02ac700146246664
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
index 8060f1bde4..ab91eb5f7b 100644
--- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
@@ -6,28 +6,28 @@ English | [中文](2026-10-26-manual-generation-continuation.zh.md)
## Problem
-Users need another turn after a model request fails. A browser page can omit the failed turn's start; streaming must not repeatedly copy loaded history.
+Users need another request after a model failure without inserting an artificial instruction. Browser paging can omit the failed turn's start, and streaming must not repeatedly copy history.
## Decision
-The play action submits localized ordinary user text through the existing composer, retaining admission, logging, failure drafts, and telemetry. No resume RPC, AgentLoop change, or Session event format is introduced.
+The play action submits `content: []` through the existing Session prompt API. AgentLoop retains the identified inbox input and starts a new balanced turn, but omits the empty ordinary input from model-visible `user/message` events. The original failed turn remains closed.
-The fixed-size Host projection `conversationContinuation` recovers from complete Session history. Eligibility requires admitted ordinary input and a failed attempt, independently of the browser's 500-message page.
+The Session API owns the fixed-size `conversationContinuation` projection and shares its value with the browser. Admission requires an eligible failure, true idle ownership, and no pending human input. An accepted empty input carries the admitted work into its next turn, preserving eligibility if that request also fails.
-The six-code manual allowlist remains independent of [automatic retry policy](../../../../packages/llm/llm-retry/README.md). Pending [injected context](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) in `next-step` accompanies the new waking message; pending ordinary user submissions prevent duplicates.
+The six-code manual allowlist stays independent of provider retry policy. Non-waking injected context accompanies the request. Durable inbox receipts retain request-id deduplication after the empty input is claimed; no new Remote operation or Session event format is required.
## Alternatives considered
-**Window scanning or entries caching.** Each revision can materialize a fresh window; caching cannot recover unloaded facts.
+**Localized or hidden continuation text.** Adds model-visible instructions and changes retained history.
-**Use provider retryableCodes.** Deployment retry policies could expose authentication or other unsuitable errors as UI actions.
+**A separate resume operation.** The existing prompt and inbox already carry identified control input and own wakeup.
-**Add a resume RPC.** Ordinary submission already owns wakeup, admission, error reporting, and history.
+**Keep the failed turn waiting for a click.** Changes failure settlement, idle status, cancellation, and model-selection semantics.
## Consequences
-Host activation requires `sessionProjections`; custom compositions follow the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md). Fixed-size state avoids Client history scans without introducing a second submission lifecycle.
+Ordinary empty-draft Enter remains inert. The explicit action reuses submission state and error reporting without an echo or generated draft. Pre-step rejection and cleared batches still prevent model calls.
## Verification
-Tests cover checkpoint recovery, long-turn paging, injected context, refusal and retained drafts, and zero Client event-window reads. The recorded Web scenario exercises failure followed by continuation.
+Unit and assembled scenarios cover repeated failure, request deduplication, injected context, balanced turns, retained tools, one original user message, and zero Client event-window reads.
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
index c7c0d9ee13..21ff2cf50a 100644
--- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
@@ -6,28 +6,28 @@ Status: implemented
## 问题
-模型请求失败后,用户需要发起下一轮。浏览器分页可能不含失败轮次的起点;流式输出也不能反复复制已加载历史。
+模型请求失败后,用户需要再次请求,但不应插入人为构造的指令。浏览器分页可能缺少失败轮次起点,流式输出也不能反复复制历史。
## 决策
-播放按钮通过现有 composer 提交本地化的普通用户文本,沿用准入、日志、失败草稿和遥测语义。不引入 resume RPC,不修改 AgentLoop 或 Session 事件格式。
+播放按钮通过现有 Session prompt API 提交 `content: []`。AgentLoop 保留带身份的 inbox 输入并启动正常闭合的新轮次,但不将空普通输入写成模型可见的 `user/message`。原失败轮次保持闭合。
-固定大小的 Host 投影 `conversationContinuation` 从完整 Session 历史恢复。资格要求已准入的普通用户输入和失败尝试,不依赖浏览器的 500 条消息分页。
+Session API 拥有固定大小的 `conversationContinuation` 投影,向浏览器发布相同的值。准入要求失败符合条件、持有真正的空闲状态且没有待消费的用户输入。已接受的空输入将原先准入的工作带入新轮次,因此请求再次失败后仍保留继续资格。
-六类手动继续白名单独立于[自动重试策略](../../../../packages/llm/llm-retry/README.zh.md)。`next-step` 中待处理的[注入上下文](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md) 随新的唤醒消息进入请求;待提交的普通用户消息仍阻止重复提交。
+六码手动白名单独立于提供方重试策略。非唤醒的注入上下文随请求进入。空输入被领取后,持久 inbox 回执仍支持 request-id 去重;不需要新增 Remote 操作或 Session 事件格式。
## 考虑过的替代方案
-**窗口扫描或 entries 缓存。** 每个 revision 都可能物化新窗口;缓存无法恢复尚未加载的事实。
+**本地化或隐藏的继续文本。** 增加模型可见指令并改变保留历史。
-**采用提供方的 retryableCodes。** 部署重试策略可能把认证等不适合的错误也变成界面动作。
+**单独的 resume 操作。** 现有 prompt 和 inbox 已可承载带身份的控制输入并拥有唤醒流程。
-**新增 resume RPC。** 普通提交已经拥有唤醒、准入、错误报告和历史。
+**保持失败轮次等待点击。** 改变失败结算、空闲状态、取消和模型选择语义。
-## 影响
+## 后果
-Host 激活需要 `sessionProjections`;自定义组合按[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md) 调整。固定大小的状态避免 Client 历史扫描,也无需第二套提交生命周期。
+普通空草稿回车仍不执行。显式继续沿用提交状态与错误报告,不创建消息回显或生成草稿。pre-step 拒绝和被清空的批次仍阻止模型调用。
## 验证
-测试覆盖检查点恢复、长轮次分页、注入上下文、拒绝与草稿保留,以及不读取 Client 事件窗口。已录制的 Web 场景验证失败后的继续流程。
+单元和组装场景覆盖重复失败、请求去重、注入上下文、轮次闭合、工具结果保留、仅一条原始用户消息,以及客户端不读取完整事件窗口。
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index d1e90d0080..eda193847c 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -414,7 +414,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(tripwire.warnings).toEqual([])
}, 120_000)
- it.skipIf(MODE === 'record')('continues after transient retries exhaust through a localized user message', async () => {
+ it.skipIf(MODE === 'record')('continues after transient retries exhaust without another user message', async () => {
// A whole-script replacement: three throw entries cover the first request
// plus both budgeted retries (patches cannot reach past the one-call
// derived script). The scenario-owned policy keeps exhaustion fast and
@@ -477,7 +477,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(sessionEvents.some(event => event.type === 'user/message' && event.data.source.kind === 'plan-mode')).toBe(true)
const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? [event.data.content] : [])
- expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }], [{ type: 'text', text: 'continue' }]])
+ expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }]])
+ expect(sessionEvents.filter(event => event.type === 'turn/start')).toHaveLength(2)
+ expect(sessionEvents.filter(event => event.type === 'turn/end')).toHaveLength(2)
expect(await resume.count()).toBe(0)
await page.setViewportSize({ width: 1440, height: 900 })
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'continued.expected.md'),
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index c8543cdc36..d1e20ef6b6 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -19,12 +19,12 @@
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: 20ef882e44b06a10
- zh: c10c3934ed5570e9
+ zh: 0d69c1500650868f
/deepseek-harness-architecture/events:
en: 693407efb233f961
zh: 37b9dd0677d4405c
/deepseek-harness-architecture/turn-flow:
- en: 1a79a70b62c04cdb
+ en: 22964b8327adc3f6
zh: c86d765e21430405
/deepseek-harness-architecture/session-log:
en: 8a7f19b95d426c99
diff --git a/docs/architecture.md b/docs/architecture.md
index e6bede8d0e..ed35ab94af 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -119,6 +119,8 @@ Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-ex
[`ctx.tools.execute()`](subsystems/tools.md) receives raw batches and concurrency limits.
+An identified empty ordinary input starts a new turn from committed history without creating a model-visible user message. It remains logged in the inbox, passes normal pre-step admission, and closes through the same turn and step lifecycle.
+
## Session log
The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)).
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 8863c8047c..64c3be1ddd 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -66,6 +66,8 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
+带身份的空普通输入基于已提交历史启动新轮次,不生成模型可见的用户消息。它仍记录在 inbox 中,经过正常 pre-step 准入,并通过相同的轮次和步骤生命周期闭合。
+
## 事件
事件就是扩展点,而选对事件域是大多数改动的第一个决定。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 43726adfb5..be0d89caf2 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -4713,7 +4713,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) |
-| `@deepseek-ai/dsh-client-ui-conversation` | `sessionProjections` | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
+| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 470c40d5b0..23165f5f85 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -4715,7 +4715,7 @@ export interface Config {
| `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) |
-| `@deepseek-ai/dsh-client-ui-conversation` | `sessionProjections` | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
+| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml
deleted file mode 100644
index 50094c2b2c..0000000000
--- a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
-# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
-# After editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
-/:
- en: cd293f657bb1ab7a
- zh: 7d942448b488e22c
-/provide-session-projections-for-conversation:
- en: 6ec06cc57348d333
- zh: 86665b791e3b2e02
-/provide-session-projections-for-conversation/change:
- en: b0048aafcb91d8a5
- zh: c913215a68c5ff5b
-/provide-session-projections-for-conversation/migration:
- en: e06d674bafc37abe
- zh: b68409bfbc936d19
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
deleted file mode 100644
index 52fa806c20..0000000000
--- a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-kind: upgrade-guide
-description: "Custom Conversation compositions must provide sessionProjections to activate the Host plugin."
----
-# Provide Session projections for Conversation
-
-English | [中文](guide.zh.md)
-
-## Change
-
-The Host entry of `@deepseek-ai/dsh-client-ui-conversation` requires the `sessionProjections` service to publish manual continuation eligibility. Custom compositions that mounted Conversation without a projection provider must add one. The shipped Web and Desktop compositions already provide it. Session logs do not require migration.
-
-## Migration
-
-1. Check the custom profile's bundles and `cordis.patch.yml` for an existing `@deepseek-ai/dsh-session-projection` entry. Keep that provider when present.
-2. If the composition has none, add this row to the profile's `cordis.patch.yml`:
-
- ```yaml
- - id: session-projection
- name: '@deepseek-ai/dsh-session-projection'
- ```
-
-3. Restart the profile and verify that the Conversation Host entry activates. After a supported model-request failure, an empty, idle ordinary composer must offer the play action.
-
-See the [Conversation reference](../../../../packages/client/ui-conversation/README.md#shell-and-standard-props) for the supported failures and submission behavior.
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md
deleted file mode 100644
index 1277f31bf5..0000000000
--- a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-kind: upgrade-guide
-description: "自定义 Conversation 组合必须提供 sessionProjections 才能激活 Host 插件。"
----
-# 为 Conversation 提供 Session 投影
-
-[English](guide.md) | 中文
-
-## 变更
-
-`@deepseek-ai/dsh-client-ui-conversation` 的 Host 入口需要 `sessionProjections` 服务来发布手动继续资格。挂载 Conversation 却没有投影提供方的自定义组合必须补上提供方。随产品提供的 Web 和 Desktop 组合已经提供它。Session 日志不需要迁移。
-
-## 迁移
-
-1. 检查自定义 profile 的 bundle 和 `cordis.patch.yml` 中是否已有 `@deepseek-ai/dsh-session-projection` entry;存在时保留该提供方。
-2. 如果组合中没有该提供方,在 profile 的 `cordis.patch.yml` 中添加以下条目:
-
- ```yaml
- - id: session-projection
- name: '@deepseek-ai/dsh-session-projection'
- ```
-
-3. 重启 profile 并确认 Conversation Host 入口已激活。支持的模型请求失败后,空草稿且空闲的普通 composer 应提供播放按钮。
-
-支持的失败类型和提交行为见 [Conversation 参考](../../../../packages/client/ui-conversation/README.zh.md#shell-and-standard-props)。
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml
new file mode 100644
index 0000000000..cbda06d0c8
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
+/:
+ en: 7a194a68f49ac09a
+ zh: 4d7bdff82c571c62
+/observe-empty-prompt-admission-through-the-inbox:
+ en: 450277479989809e
+ zh: 4b01f252faeae19c
+/observe-empty-prompt-admission-through-the-inbox/change:
+ en: e1c081d5321f3256
+ zh: a5dd5585fe4300b3
+/observe-empty-prompt-admission-through-the-inbox/migration:
+ en: 36e80226df9dcf54
+ zh: 81a9c0bd5c667470
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
new file mode 100644
index 0000000000..16876dd26c
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
@@ -0,0 +1,19 @@
+---
+kind: upgrade-guide
+description: "Empty ordinary Agent inputs start requests without producing user/message events."
+---
+# Observe empty prompt admission through the inbox
+
+English | [中文](guide.zh.md)
+
+## Change
+
+An ordinary Agent input with `content: []` starts a new turn from committed history without appending an empty `user/message`. The identified input remains in durable inbox events. Custom consumers that wait for every accepted input to produce a user message must observe its inbox receipt instead. Session logs need no migration; existing recorded empty user messages keep their historical interpretation.
+
+The Web Session prompt endpoint accepts an empty array only for an eligible failed, idle ordinary Session in queue mode. Whitespace-only text parts remain invalid.
+
+## Migration
+
+1. Keep ordinary text and attachment inputs unchanged. Use an empty array only when requesting another model call without new user text.
+2. Match acceptance by the input identity in `agent/inbox/spliced`, then observe the normal turn/status lifecycle. Do not wait for an empty `user/message`.
+3. Verify that continuation adds a balanced turn without adding a user message. The shipped TypeScript and Python SDK clients already observe inbox receipts.
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md
new file mode 100644
index 0000000000..9c879cc2aa
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md
@@ -0,0 +1,19 @@
+---
+kind: upgrade-guide
+description: "空的普通 Agent 输入发起请求,但不产生 user/message 事件。"
+---
+# 通过 inbox 观察空 prompt 准入
+
+[English](guide.md) | 中文
+
+## 变更
+
+普通 Agent 输入的 `content: []` 基于已提交历史启动新轮次,不再追加空的 `user/message`。带身份的输入仍保留在持久 inbox 事件中。若自定义消费者等待每条已接受输入产生用户消息,应改为观察对应 inbox 回执。Session 日志无需迁移,历史空用户消息的解释保持不变。
+
+Web Session prompt 端点仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时接受空数组。仅含空白的文本块仍无效。
+
+## 迁移
+
+1. 普通文本和附件输入保持不变。仅在需要再次请求模型而不添加用户文本时使用空数组。
+2. 通过 `agent/inbox/spliced` 中的输入身份匹配准入,然后观察正常轮次与状态生命周期。不要等待空的 `user/message`。
+3. 验证继续操作增加正常闭合的轮次,但不增加用户消息。内置 TypeScript 和 Python SDK 客户端已观察 inbox 回执。
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 010c41bf94..78f64d3acc 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 954923654eebe2dc
zh: 6bd9a7d2ea965aed
/session-controller/configuration:
- en: c92590fbaf75e45e
- zh: fcac67017bb0e3ab
+ en: 2a91d9f904e90756
+ zh: 8affb15ccc571829
/session-controller/model-experience:
en: 30b917bf822bb4df
zh: b0b023b5b526c317
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 6fd7b24561..1b11d04106 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -111,6 +111,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation.
+An empty `prompt.content` array requests a new turn only for an eligible failed, idle ordinary Session in queue mode. Pending human input prevents admission; non-waking injected context is retained. `conversationContinuation` supplies the shared six-code manual policy, independently of provider retry configuration. The input is logged through the inbox for request-id deduplication, but creates no model-visible user message. Whitespace-only text parts remain invalid.
+
## Model Experience
None, as invoked Agent commands own any model-visible effect.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 8729f555ba..cb8a1ba818 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -111,6 +111,8 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不
Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。
+空的 `prompt.content` 数组仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时请求新轮次。待消费的用户输入阻止准入,非唤醒的注入上下文保留。`conversationContinuation` 提供共用的六码手动策略,独立于提供方重试配置。输入通过 inbox 持久记录,用于 request-id 去重,但不生成模型可见的用户消息。仅含空白的文本块仍无效。
+
## 模型体验
无;任何模型可见效果都由被调用的 Agent 命令负责。
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index 5309872331..0b62ff724a 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -1,5 +1,8 @@
/** Session commands whose activation policy is explicit at each Remote method. */
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
+import { continuationProjectionDefinition, type ContinuationState } from './continuation.ts'
+
import { modelAvailable } from './catalog.ts'
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
@@ -83,6 +86,13 @@ function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): Session
/** Implements Session business commands delegated by the Session Controller Remote service. */
export class SessionCommandController {
+ private continuation!: ProjectionHandle
+
+ /** Register complete-history empty-prompt eligibility. @returns when command admission is ready. */
+ async initialize(): Promise {
+ this.continuation = await this.ctx.sessionProjections.register(continuationProjectionDefinition)
+ }
+
/**
* @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
* @param agents - sole owner of create, resume, and Agent composition.
@@ -304,6 +314,22 @@ export class SessionCommandController {
async prompt(request: SessionPromptRequest, source: MessageSource): Promise {
const agent = await this.resolveAgent(request.sessionId)
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
+ if (request.content.length === 0) {
+ try {
+ return await agent.runMaintenance(async () => {
+ const inbox = agent.inbox
+ if (request.mode !== 'queue' || this.continuation.stateOf(agent.session).ending === null
+ || inbox.nextTurn.length > 0 || inbox.nextStep.some(message => message.source.kind === 'user')) {
+ throw new RemoteError('session/agent-busy', 'generation continuation is unavailable', {})
+ }
+ agent.followup(createUserMessage({ content: [], source }))
+ return { accepted: true as const }
+ })
+ } catch (error: unknown) {
+ if (remoteErrorOf(error) !== undefined) throw error
+ throw new RemoteError('session/agent-busy', 'generation continuation was not accepted', { reason: String(error) })
+ }
+ }
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
@@ -547,6 +573,9 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
return agent.session.snapshotEvents().some((event) => {
+ if (event.type === 'agent/inbox/spliced') {
+ return event.data.inserted.some(message => message.content.length === 0 && matches(message))
+ }
if (event.type !== 'user/message') return false
const source = event.data.source
return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
diff --git a/packages/client/ui-conversation/src/host/continuation.ts b/packages/api/session-controller/src/continuation.ts
similarity index 65%
rename from packages/client/ui-conversation/src/host/continuation.ts
rename to packages/api/session-controller/src/continuation.ts
index ef2239338e..8673cfc731 100644
--- a/packages/client/ui-conversation/src/host/continuation.ts
+++ b/packages/api/session-controller/src/continuation.ts
@@ -1,5 +1,5 @@
/** Constant-size continuation eligibility over the complete durable Session. */
-import type {} from '../continuation-types.ts'
+import type {} from './types.ts'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
import { brandNumber } from '@deepseek-ai/dsh-brand'
@@ -15,27 +15,35 @@ const stateSchema = z.object({
admitted: z.boolean(),
failedAttempt: z.boolean(),
ending: endingSchema,
+ continuing: z.boolean(),
})
-type ContinuationState = z.infer
+/** Recoverable manual-continuation facts, including an accepted empty input awaiting its turn. */
+export type ContinuationState = z.infer
-const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null }
+const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null, continuing: false }
-/** Eligibility requires admitted human input and a failed settlement in the last request step. */
+/** Eligibility requires admitted human work and a failed settlement in the last request step. */
export const continuationProjectionDefinition = {
key: 'conversationContinuation',
- stateVersion: 1,
+ stateVersion: 2,
stateSchema,
init: (): ContinuationState => EMPTY,
apply: (state, event) => {
switch (event.type) {
+ case 'agent/inbox/spliced':
+ if (state.continuing && event.data.target === 'next-turn' && event.data.outcome === 'canceled'
+ && event.data.start === 0 && (event.data.removedCount ?? 0) > 0) return { ...state, continuing: false }
+ return state.ending !== null && !state.continuing && event.data.target === 'next-turn'
+ && event.data.inserted.some(message => message.source.kind === 'user' && message.content.length === 0)
+ ? { ...state, continuing: true } : state
case 'turn/start':
- return EMPTY
+ return state.continuing ? { ...EMPTY, admitted: true } : EMPTY
case 'step/start':
case 'assistant/message':
return state.failedAttempt ? { ...state, failedAttempt: false } : state
case 'user/message':
- return !state.admitted && event.surfaceOp === 'append' && event.data.source.kind === 'user'
+ return !state.admitted && event.surfaceOp === 'append' && event.data.source.kind === 'user' && event.data.content.length > 0
? { ...state, admitted: true } : state
case 'assistant/attempt':
return state.failedAttempt ? state : { ...state, failedAttempt: true }
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index 94d08c77e3..f87c4f0b0f 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -192,6 +192,8 @@ export class SessionController extends TypertRemoteService {
this.agents = new ApiSessionAgentController(ctx)
this.agents.initialize()
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
+ await this.commands.initialize()
+ ctx.fiber.assertActive()
ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
const result = await this.agents.resolveAgent(sessionId)
if ('error' in result) throw result.error
@@ -464,7 +466,7 @@ export class SessionController extends TypertRemoteService {
@Remote('prompt')
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise {
signal.throwIfAborted()
- if (!hasPromptContent(request.content)) {
+ if (request.content.length > 0 && !hasPromptContent(request.content)) {
throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {})
}
const clientTimeZone = request.clientTimeZone === undefined
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index e3e2ea375a..798ca0e04c 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -8,7 +8,7 @@ import type {
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { TextBlock } from '@deepseek-ai/dsh-llm'
-import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
+import type { SessionId, SessionSeq, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types'
import type { Principal } from '@deepseek-ai/dsh-typert-protocol'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -16,6 +16,8 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
+ /** Eligible latest failed turn/end for an empty prompt, or null. */
+ conversationContinuation: SessionSeq | null
/** Persisted facts used to summarize a Session without activating it. */
sessionListMetadata: SessionListMetadata
/**
@@ -317,11 +319,11 @@ export interface SessionForkValue {
/** Session prompt request. */
export interface SessionPromptRequest {
- /** Client-minted identity persisted on the exact accepted user message. */
+ /** Client-minted identity persisted on the accepted inbox input, including empty continuation. */
readonly requestId: SessionRequestId
readonly sessionId: SessionId
readonly mode: 'queue' | 'steer'
- /** At least one non-whitespace text part or attachment. */
+ /** Non-whitespace text or attachments; an empty array continues an eligible failed idle Session. */
readonly content: readonly PromptContentPart[]
readonly clientTimeZone?: string
}
diff --git a/packages/client/ui-conversation/tests/continuation-fixture.ts b/packages/api/session-controller/tests/continuation-fixture.ts
similarity index 100%
rename from packages/client/ui-conversation/tests/continuation-fixture.ts
rename to packages/api/session-controller/tests/continuation-fixture.ts
diff --git a/packages/client/ui-conversation/tests/continuation.host.spec.ts b/packages/api/session-controller/tests/continuation.host.spec.ts
similarity index 78%
rename from packages/client/ui-conversation/tests/continuation.host.spec.ts
rename to packages/api/session-controller/tests/continuation.host.spec.ts
index 1b0dd1c7b3..f87b29809f 100644
--- a/packages/client/ui-conversation/tests/continuation.host.spec.ts
+++ b/packages/api/session-controller/tests/continuation.host.spec.ts
@@ -5,9 +5,9 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session/types'
import { expect, it, onTestFinished, vi } from 'vitest'
-import * as plugin from '../src/index.ts'
-import { continuationProjectionDefinition as definition } from '../src/host/continuation.ts'
-import { paginate } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
+
+import { continuationProjectionDefinition as definition } from '../src/continuation.ts'
+import { paginate } from '../src/history.ts'
import { continuationTurn as turn } from './continuation-fixture.ts'
const failed = (code: string): TurnEndReason => ({ kind: 'error', error: { code, message: code } })
@@ -86,7 +86,7 @@ it('publishes a complete-log result independently of the visible page and releas
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- const fiber = await ctx.plugin(plugin)
+ const fiber = await ctx.sessionProjections.register(definition)
const session = ctx.sessions.create(SessionId('continued'))
expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull()
const history: SessionEvent[] = []
@@ -111,7 +111,7 @@ it('publishes a complete-log result independently of the visible page and releas
expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBe(ending)
expect(changed.mock.calls.map(([, key, value]) => [key, value])).toEqual([['conversationContinuation', null], ['conversationContinuation', ending]])
expect(ctx.sessionProjections.checkpoint(session).conversationContinuation)
- .toEqual({ ver: 1, seq: ending, val: { admitted: false, failedAttempt: false, ending } })
+ .toEqual({ ver: 2, seq: ending, val: { admitted: false, failedAttempt: false, ending, continuing: false } })
session.append('turn/start', { turn: 2 })
expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull()
expect(changed).toHaveBeenCalledTimes(3)
@@ -124,10 +124,10 @@ it('restores valid, absent and outdated checkpoints through the registry', async
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(plugin)
+ await ctx.sessionProjections.register(definition)
const session = ctx.sessions.create(SessionId('restored'))
const log = events(failed('SERVER'))
- const checkpoint = { conversationContinuation: { ver: 1, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } }
+ const checkpoint = { conversationContinuation: { ver: 2, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } }
const restore = (rows: typeof checkpoint | Record, tail: readonly SessionEvent[], base: number) =>
ctx.sessionProjections.restore(rows, tail, SessionLogOffset(base), session.header, session.inheritedEventCount)
expect(restore(checkpoint, log.slice(3), 3).snapshot.values.conversationContinuation).toBe(5)
@@ -137,3 +137,32 @@ it('restores valid, absent and outdated checkpoints through the registry', async
expect(() => restore(outdated, log.slice(3), 3)).toThrow('re-read from seq 0')
expect(() => restore({}, log.slice(3), 3)).toThrow('re-read from seq 0')
})
+
+it('carries admitted work through an empty continuation and retires a canceled queued control', async () => {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ const handle = await ctx.sessionProjections.register(definition)
+ const session = ctx.sessions.create(SessionId('empty-repeat'))
+ for (const event of events(failed('SERVER'))) {
+ if (event.type === 'user/message') session.append(event.type, event.data, { surfaceOp: 'append' })
+ else if (event.type === 'turn/start') session.append(event.type, event.data)
+ else if (event.type === 'step/start' || event.type === 'step/end') session.append(event.type, event.data)
+ else if (event.type === 'assistant/attempt') session.append(event.type, event.data)
+ else if (event.type === 'turn/end') session.append(event.type, event.data)
+ }
+ const input = createUserMessage({ content: [], source: { kind: 'user' } })
+ session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] })
+ expect(handle.stateOf(session).continuing).toBe(true)
+ session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' })
+ expect(handle.stateOf(session).continuing).toBe(false)
+ session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] })
+ session.append('turn/start', { turn: 2 })
+ expect(handle.stateOf(session).admitted).toBe(true)
+ session.append('step/start', { turn: 2, step: 1 })
+ session.append('assistant/attempt', { turn: 2, step: 1, stream: [] })
+ session.append('step/end', { turn: 2, step: 1 })
+ const ending = session.append('turn/end', { turn: 2, reason: failed('SERVER') }).seq
+ expect(handle.stateOf(session).ending).toBe(ending)
+})
diff --git a/packages/api/session-controller/tests/empty-prompt.host.spec.ts b/packages/api/session-controller/tests/empty-prompt.host.spec.ts
new file mode 100644
index 0000000000..5f8d913f40
--- /dev/null
+++ b/packages/api/session-controller/tests/empty-prompt.host.spec.ts
@@ -0,0 +1,82 @@
+/** Empty prompt admission over the real AgentLoop and the Session Remote owner. */
+import { Context } from '@deepseek-ai/cordis'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { expect, it, onTestFinished } from 'vitest'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
+import { createSessionTestRemote } from './test-remote.ts'
+
+async function setup() {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ const fail = (): never => { throw new LlmError('busy', 'SERVER') }
+ const adapter = new MockAdapter([fail, fail, textResponse('done')])
+ ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/workspace' })
+ const agent = await ctx.agentLoop.create(SessionId('empty-prompt'), { provider: 'mock', model: 'mock' })
+ const request = (id: string, mode: SessionPromptRequest['mode'] = 'queue'): SessionPromptRequest => ({
+ sessionId: agent.id, requestId: id as SessionRequestId, content: [], mode,
+ })
+ return { ctx, remote, agent, adapter, request }
+}
+
+it('admits repeated empty prompts after failures without extra user messages or duplicate requests', async () => {
+ const { remote, agent, adapter, request } = await setup()
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } }))
+ await agent.whenIdle()
+ const first = request('continue-1')
+ expect((await remote.prompt(first)).ok).toBe(true)
+ await agent.whenIdle()
+ expect(adapter.requests).toHaveLength(2)
+ expect((await remote.prompt(first)).ok).toBe(true)
+ await agent.whenIdle()
+ expect(adapter.requests).toHaveLength(2)
+ expect((await remote.prompt(request('continue-2'))).ok).toBe(true)
+ await agent.whenIdle()
+ expect(adapter.requests).toHaveLength(3)
+ const events = agent.session.snapshotEvents()
+ expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
+ expect(events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])).toEqual(['error', 'error', 'completed'])
+ expect(events.filter(event => event.type === 'turn/start')).toHaveLength(3)
+ expect((await remote.prompt(request('after-completion'))).ok).toBe(false)
+})
+
+it('refuses empty fresh sessions, steering, queued human input and maintenance without waking work', async () => {
+ const { remote, agent, adapter, request } = await setup()
+ expect((await remote.prompt(request('empty-fresh'))).ok).toBe(false)
+ expect(adapter.requests).toHaveLength(0)
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } }))
+ await agent.whenIdle()
+ expect((await remote.prompt(request('empty-steer', 'steer'))).ok).toBe(false)
+ const pending = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
+ agent.inbox.append('next-turn', pending)
+ expect((await remote.prompt(request('empty-queued'))).ok).toBe(false)
+ agent.inbox.clear()
+ agent.inject(pending)
+ expect((await remote.prompt(request('empty-steering'))).ok).toBe(false)
+ agent.inbox.clear()
+ await agent.runMaintenance(async () => {
+ expect((await remote.prompt(request('empty-maintenance'))).ok).toBe(false)
+ })
+ expect(adapter.requests).toHaveLength(1)
+})
+
+it('consumes injected context with the empty prompt', async () => {
+ const { ctx, remote, agent, adapter, request } = await setup()
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } }))
+ await agent.whenIdle()
+ agent.inject(createUserMessage({ content: [{ type: 'text', text: 'approval context' }], source: { kind: 'user-approval' } }))
+ agent.model.select({ provider: 'mock', model: 'changed' })
+ expect((await remote.prompt(request('empty-context'))).ok).toBe(true)
+ await agent.whenIdle()
+ expect(adapter.requests).toHaveLength(2)
+ expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('approval context')
+ expect(adapter.requests[1]!.model).toBe('changed')
+ expect(agent.inbox.nextStep).toHaveLength(0)
+ expect(ctx.sessionProjections.snapshot(agent.session).values.conversationContinuation).not.toBeNull()
+})
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index 560c05514f..5139baf644 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -924,7 +924,6 @@ describe('sessions.prompt synchronous rejection', () => {
})
const initialEvents = session.snapshotEvents()
const rejectedContent: readonly SessionPromptRequest['content'][] = [
- [],
[{ type: 'text', text: '' }],
[{ type: 'text', text: ' \t\n' }, { type: 'text', text: '' }],
]
diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json
index acadf82099..168fd5a0c7 100644
--- a/packages/api/session-controller/tsconfig.host.json
+++ b/packages/api/session-controller/tsconfig.host.json
@@ -25,7 +25,8 @@
"src/list.ts",
"src/media-references.ts",
"src/archived-session-gate.ts",
- "src/skill-catalog.ts"
+ "src/skill-catalog.ts",
+ "src/continuation.ts"
],
"references": [
{
@@ -126,6 +127,9 @@
},
{
"path": "../../session/working-directory"
+ },
+ {
+ "path": "../../util/brand"
}
]
}
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 2b4fd0c5db..6ee1b33613 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -21,26 +21,26 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
- en: ac170c9895e17840
- zh: c7603d365134ce89
+ en: 8f3eb5f46b48df30
+ zh: a1a1da12767f4e92
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
/deepseek-ai-dsh-client-ui-conversation/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
-/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message:
- en: 30f3836663c881ca
- zh: ee3e1d1df8603fad
-/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/what-the-model-sees:
- en: d566958ff2f23584
- zh: f6ebea015ba61872
-/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/token-effect:
- en: 0ad06a5163a62c50
- zh: d25b93fcb2783566
-/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/kv-cache-effect:
- en: 326e23f5913e3fc0
- zh: 865969505814e0af
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request:
+ en: 6a7ae99570c9c007
+ zh: 26e86063e97d82c7
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/what-the-model-sees:
+ en: b1376c710fa71ce3
+ zh: e751e1a490c36bd8
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/token-effect:
+ en: a5bbf55d50568fa2
+ zh: 6c43af211055db93
+/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/kv-cache-effect:
+ en: bbb4b8186c85f62d
+ zh: 222397b869a60df2
/deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work:
en: d76daa90b6d6f600
zh: 37a9f7d402b2552b
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index eadcdf2467..4a408c5950 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -76,9 +76,9 @@ The composer measures its expanded control groups after size, content, visibilit
The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale).
-An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted ordinary user input and a settled failed model attempt. Clicking submits a normal user message in the current interface language; drafts and attachments keep the Send action. Continued requests retain committed history and use the existing submission lifecycle.
+An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted ordinary user input and a settled failed model attempt. Clicking submits `content: []` through the existing Session prompt API; drafts and attachments keep the Send action. A new balanced turn uses committed history without adding a user message. Refusal or transport failure leaves the draft empty and reports the error through the ordinary submission lifecycle.
-The Host requires `sessionProjections` and publishes `conversationContinuation`; eligibility is restored from Session history independently of the browser's loaded history pages.
+The Session API owns `conversationContinuation`, shared by Host admission and browser presentation. It restores eligibility from complete Session history and preserves it across repeated failed empty continuations, independently of loaded browser pages.
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
@@ -189,19 +189,19 @@ The selector must be a pure function of the owner currency. Its non-null return
## Model Experience
-### Manual continuation message
+### Manual continuation request
#### What the model sees
-The play action submits one ordinary user message using the interface language selected at the click: Chinese `继续`, English/French/Portuguese `continue`, Japanese `続けて`, Spanish `continúa`, German `weiter`, or Russian `продолжай`. The admitted message is stored in the Session log and included in subsequent model history; the action does not reconstruct text from the failed attempt.
+The play action starts a new request from committed Session history. Its empty input is a durable inbox command, not a model-visible user message; it adds no localized or hidden continuation instruction. Pending injected context follows ordinary admission.
#### Token effect
-Each continuation admitted into model history adds its short localized text and the ordinary user-message overhead. The exact token count depends on the model tokenizer. A submission refused or failed before admission adds no continuation text to model history.
+The action adds no continuation text or empty user-message overhead. The request still incurs the ordinary cost of retained history, current prompt sections, tool declarations, and any newly admitted context.
#### KV Cache effect
-The continuation message extends retained history without replacing earlier messages or prompt sections. It preserves an already-reusable prefix; provider cache availability, reuse policy, and eviction remain outside this package's control.
+The control input does not alter model history. Prefix reuse still depends on the current model, prompt, tools, injected context, and provider cache policy.
## Known Limitations and Deferred Work
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 3573e9c7b4..85df62d84b 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -76,9 +76,9 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
-普通 Session 的输入框在空草稿且空闲时,若最新一轮以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束,会提供播放图标按钮。失败轮次必须包含已进入模型历史的普通用户输入和已落盘的失败模型请求尝试。点击后按当前界面语言提交一条普通用户消息;存在草稿或附件时保留发送按钮。继续请求保留已提交历史,并沿用现有提交流程。
+空闲且草稿为空的普通 Session,在最新轮次以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束后显示播放按钮。失败工作必须有已准入的普通用户输入和失败请求尝试。点击通过现有 Session prompt API 提交 `content: []`;已有草稿或附件仍使用发送按钮。新轮次正常闭合,使用已提交历史而不新增用户消息。请求被拒绝或传输失败时,草稿保持为空,错误沿用普通提交流程展示。
-Host 需要 `sessionProjections`,并发布 `conversationContinuation`;继续资格从 Session 历史恢复,不受浏览器已加载历史分页的影响。
+Session API 拥有 `conversationContinuation`,Host 准入和浏览器展示共用该投影。它从完整 Session 历史恢复资格,并在空输入继续再次失败时保留资格,不依赖浏览器已加载的历史分页。
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。
@@ -189,19 +189,19 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
## 模型体验
-### 手动继续消息
+### 手动继续请求
#### 模型看到什么
-播放按钮按点击时选中的界面语言提交一条普通用户消息:中文为 `继续`,英语、法语和葡萄牙语为 `continue`,日语为 `続けて`,西班牙语为 `continúa`,德语为 `weiter`,俄语为 `продолжай`。已准入的消息写入 Session 日志并进入后续模型历史;该操作不从失败尝试中重建文本。
+播放按钮基于已提交的 Session 历史发起新请求。空输入作为持久 inbox 控制记录,不成为模型可见的用户消息;不会添加本地化或隐藏的继续指令。待注入上下文遵循普通准入流程。
#### Token 影响
-每条进入模型历史的继续消息增加其本地化短文本和普通用户消息的封装开销。准确 token 数取决于模型 tokenizer。在准入前被拒绝或失败的提交不会向模型历史添加继续文本。
+该操作不增加继续文本或空用户消息的封装开销。请求仍承担保留历史、当前提示词、工具声明以及新准入上下文的正常输入成本。
#### KV Cache 影响
-继续消息在保留历史后追加,不替换更早的消息或 prompt section。它保留原本可复用的前缀;提供方缓存的可用性、复用策略和淘汰规则不由本包控制。
+控制输入不改变模型历史。前缀复用仍取决于当前模型、提示词、工具、注入上下文及提供方缓存策略。
## 已知限制与暂缓事项
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index c4b2317d94..d38d3d7ebe 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -47,9 +47,7 @@
},
"license": "MIT",
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:~",
- "zod": "4.4.3",
- "@deepseek-ai/dsh-brand": "workspace:*"
+ "@deepseek-ai/schemastery": "workspace:~"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
@@ -98,7 +96,9 @@
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-message-quotes": "workspace:*",
- "@deepseek-ai/dsh-session-projection": "workspace:*"
+ "@deepseek-ai/dsh-session-projection": "workspace:*",
+ "@deepseek-ai/dsh-brand": "workspace:*",
+ "zod": "4.4.3"
},
"files": [
"locale/*.json",
diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts
index 82f030c775..9cc7e4b566 100644
--- a/packages/client/ui-conversation/src/client/contract/input.ts
+++ b/packages/client/ui-conversation/src/client/contract/input.ts
@@ -352,6 +352,8 @@ export type InputEvent =
/** Enter submission with the current clipboard projection, its reference chips, its quotes, and its attachments. */
| {
readonly type: 'enter'
+ /** Explicit continuation may submit an empty draft through the default sink. */
+ readonly allowEmpty?: boolean
readonly mode: InputSubmitMode
readonly draft: string
readonly occurrences: readonly Occurrence[]
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 068fb0026e..4d701b2af8 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -530,7 +530,7 @@ export interface ComposerBarInjected {
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
stop: (() => void) | undefined
/**
- * Submit a localized continuation message for the displayed failure.
+ * Submit empty continuation input for the displayed failure.
* @param ending - sequence of the displayed failed turn/end.
*/
continueGeneration: ((ending: SessionSeq) => void) | undefined
diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts
index 65d7edc6b2..d4f06accc5 100644
--- a/packages/client/ui-conversation/src/client/index.ts
+++ b/packages/client/ui-conversation/src/client/index.ts
@@ -1,6 +1,5 @@
/** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */
export type {} from './user-text/session-reference-rule.ts'
-export type {} from '../continuation-types.ts'
/** Declaration-merged node payloads; IDs also identify the corresponding keyed renderer. */
export interface UserTextDataMap {
diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts
index 6306216b1c..e82acfdd35 100644
--- a/packages/client/ui-conversation/src/client/input/facade.ts
+++ b/packages/client/ui-conversation/src/client/input/facade.ts
@@ -388,7 +388,7 @@ export class SessionInputShell implements SessionInput {
* (adjudicating/submitting/sending) force-closes the transient layers: the
* popup dismisses and the menu tracks frozen.
*/
- submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
+ submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter', allowEmpty = false): void {
if (this.disposed) return
const timestamp = Date.now()
let state: MessageSubmissionState | undefined
@@ -409,6 +409,7 @@ export class SessionInputShell implements SessionInput {
}
this.dispatchRun({
type: 'enter',
+ ...allowEmpty ? { allowEmpty: true } : {},
mode,
draft: this.projection.clipboardText,
occurrences: [...this.projection.occurrences],
@@ -726,6 +727,10 @@ export class SessionInputShell implements SessionInput {
})
const send = (text: string): void => {
if (this.dead(attempt)) return
+ if (text === '' && draft !== '' && attachmentIds.length === 0) {
+ this.settleSink(attempt, Promise.resolve({ kind: 'success' }))
+ return
+ }
this.settleSink(attempt, this.deps.defaultSink(text, attachmentIds, mode, attempt.signal, () => { this.acceptSink(attempt) }))
}
if (!(prompt instanceof Promise)) { send(prompt); return }
diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts
index 36ea1c6b4b..d1454d29d7 100644
--- a/packages/client/ui-conversation/src/client/input/hub.ts
+++ b/packages/client/ui-conversation/src/client/input/hub.ts
@@ -25,7 +25,6 @@ import type { ComposerBlocks } from '../contract/composer-blocks.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
-import type {} from '../../continuation-types.ts'
import { reportMessageSubmission } from './submission-analytics.ts'
import { readConversationDraft } from '../stores.ts'
@@ -239,7 +238,7 @@ export class InputHub implements SessionInputResolver {
}
/**
- * Submit localized continuation text through the ordinary composer. Stale
+ * Submit empty continuation input through the ordinary composer. Stale
* failures, active work, and drafts keep their current input unchanged.
* @param id - retained ordinary Session identity.
* @param ending - failure the displayed button addresses.
@@ -256,8 +255,7 @@ export class InputHub implements SessionInputResolver {
|| input.draft.trim() !== '' || input.attachmentIds.length > 0
|| (inbox?.['next-turn'].length ?? 0) > 0
|| this.conversation().blocks.storeFor(id).getSnapshot() !== undefined) return
- shell.setDraft(this.t('input.continueMessage'))
- shell.submit('queue', 'click')
+ shell.submit('queue', 'click', true)
}
/**
@@ -304,7 +302,9 @@ export class InputHub implements SessionInputResolver {
signal: AbortSignal,
accept: () => void,
): Promise {
- if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' })
+ if (text === '' && attachmentIds.length === 0) {
+ return session.prompt([], mode, signal).then(result => result.ok ? { kind: 'success' } : { kind: 'error' })
+ }
return this.conversation().sendSession(session, text, attachmentIds, mode, signal, accept)
}
diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts
index 16fe7dd034..8545f964e5 100644
--- a/packages/client/ui-conversation/src/client/input/machine.ts
+++ b/packages/client/ui-conversation/src/client/input/machine.ts
@@ -144,7 +144,7 @@ export class SubmitMachine {
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
}
const trimmed = draft.trim()
- if (trimmed === '' && attachmentIds.length === 0) return []
+ if (trimmed === '' && attachmentIds.length === 0 && ev.allowEmpty !== true) return []
const attempt = this.beginAttempt(mode, draft, occurrences, quotes, attachmentIds, submission)
if (trimmed.startsWith('/')) {
this.phase = 'adjudicating'
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 0ace610330..0598cee7d9 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -29,8 +29,6 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.continue': '继续生成',
- // Model-visible text, durably logged when admitted as an ordinary user message.
- 'input.continueMessage': '继续',
'input.sending': '正在发送消息',
'input.send.queue': '排队发送',
'input.send.steer': '插话发送',
@@ -416,7 +414,6 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.continue': 'Continue generating',
- 'input.continueMessage': 'continue',
'input.sending': 'Sending message',
'input.send.queue': 'Queue message',
'input.send.steer': 'Steer message',
@@ -801,7 +798,6 @@ export const ja = {
'input.stop': '生成を停止',
'input.send': 'メッセージを送信',
'input.continue': '生成を続ける',
- 'input.continueMessage': '続けて',
'input.sending': 'メッセージを送信中',
'input.send.queue': 'メッセージをキューに追加',
'input.send.steer': '実行中の指示にメッセージを追加',
@@ -1184,7 +1180,6 @@ export const es = {
'input.stop': 'Detener generación',
'input.send': 'Enviar mensaje',
'input.continue': 'Continuar generando',
- 'input.continueMessage': 'continúa',
'input.sending': 'Enviando mensaje',
'input.send.queue': 'mensaje de cola',
'input.send.steer': 'Redirigir mensaje',
@@ -1569,7 +1564,6 @@ export const fr = {
'input.stop': 'Arrêter la génération',
'input.send': 'Envoyer le message',
'input.continue': 'Continuer la génération',
- 'input.continueMessage': 'continue',
'input.sending': 'Envoi du message',
'input.send.queue': "Message de file d'attente",
'input.send.steer': 'Guider avec ce message',
@@ -1954,7 +1948,6 @@ export const de = {
'input.stop': 'Generierung stoppen',
'input.send': 'Nachricht senden',
'input.continue': 'Generierung fortsetzen',
- 'input.continueMessage': 'weiter',
'input.sending': 'Nachricht wird gesendet',
'input.send.queue': 'Warteschlangennachricht',
'input.send.steer': 'Mit Nachricht lenken',
@@ -2339,7 +2332,6 @@ export const ru = {
'input.stop': 'Остановить генерацию',
'input.send': 'Отправить сообщение',
'input.continue': 'Продолжить генерацию',
- 'input.continueMessage': 'продолжай',
'input.sending': 'Отправка сообщения',
'input.send.queue': 'Сообщение в очереди',
'input.send.steer': 'Направить сообщение',
@@ -2724,7 +2716,6 @@ export const pt = {
'input.stop': 'Parar geração',
'input.send': 'Enviar mensagem',
'input.continue': 'Continuar gerando',
- 'input.continueMessage': 'continue',
'input.sending': 'Enviando mensagem',
'input.send.queue': 'Mensagem de fila',
'input.send.steer': 'Orientar com mensagem',
diff --git a/packages/client/ui-conversation/src/continuation-types.ts b/packages/client/ui-conversation/src/continuation-types.ts
deleted file mode 100644
index 9acece7469..0000000000
--- a/packages/client/ui-conversation/src/continuation-types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/** Host-derived eligibility for an explicit generation-continuation message. */
-import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
-
-declare module '@deepseek-ai/dsh-session-projection/types' {
- interface SessionProjectionMap {
- /** Eligible latest turn/end, or null while no settled request can be continued. */
- conversationContinuation: SessionSeq | null
- }
-}
diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts
index 6c7515a448..6ef779d42b 100644
--- a/packages/client/ui-conversation/src/index.ts
+++ b/packages/client/ui-conversation/src/index.ts
@@ -1,6 +1,5 @@
-/** Host continuation eligibility and browser conversation preferences. */
+/** Browser conversation preferences. */
import type {} from '@deepseek-ai/dsh-settings'
-export type {} from './continuation-types.ts'
import type { Volatile, Context } from '@deepseek-ai/cordis'
import type { BusyEnterBehavior } from './submission-settings.ts'
@@ -8,7 +7,6 @@ import z from '@deepseek-ai/schemastery'
import { BUSY_ENTER_FIELD } from './submission-settings.ts'
import { ConversationSettingsFields } from './submission-settings.ts'
-import { continuationProjectionDefinition } from './host/continuation.ts'
export {
BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE,
@@ -26,14 +24,12 @@ export const Config = z.object({
[BUSY_ENTER_FIELD]: ConversationSettingsFields[BUSY_ENTER_FIELD].volatile(),
})
-/** Required registry for the durable generation-continuation projection. */
-export const inject = ['sessionProjections']
+/** Settings integration is optional and does not block activation. */
+export const inject = []
-/** Register generation-continuation eligibility and browser preferences.
+/** Register browser conversation preferences.
* @param ctx Plugin context used for optional settings presentation.
- * @returns when continuation state is registered and available to Session carriers.
*/
-export async function apply(ctx: Context): Promise {
+export function apply(ctx: Context): void {
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
- await ctx.sessionProjections.register(continuationProjectionDefinition)
}
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
index 8f2b967e06..a1cf51452a 100644
--- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
@@ -29,7 +29,7 @@ import { createUserMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm'
import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEventWindow } from '@deepseek-ai/dsh-api-session-controller/client'
import type { InboxWireState } from '@deepseek-ai/dsh-agent/types'
-import { continuationTurn } from './continuation-fixture.ts'
+import { continuationTurn } from '../../../api/session-controller/tests/continuation-fixture.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -165,7 +165,7 @@ async function bench(renderQuote = false) {
}
describe('Conversation inject API', () => {
- it.each([['zh', '继续'], ['en', 'continue']])('submits one continuation in the current %s locale', async (locale, message) => {
+ it.each(['zh', 'en'])('submits empty continuation input in the %s locale', async (locale) => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'SERVER', message: 'busy' } }))
@@ -181,7 +181,7 @@ describe('Conversation inject API', () => {
composer.continueGeneration!(SessionSeq(5))
expect(composer.keyboard!.snapshot.phase).toBe('sending')
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() })
- expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: message }])
+ expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([])
expect(b.sessionFake.prompt.mock.calls[0]?.[1]).toBe('queue')
pending.resolve({ ok: true, value: { accepted: true } })
await vi.waitFor(() => { expect(composer.keyboard!.snapshot.phase).toBe('plain') })
@@ -322,7 +322,7 @@ describe('Conversation inject API', () => {
},
)
- it.each(['refused', 'failed'] as const)('retains a visible editable continuation draft when submission is %s', async (outcome) => {
+ it.each(['refused', 'failed'] as const)('keeps continuation input empty and exposes submission failure when %s', async (outcome) => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5))
@@ -335,11 +335,11 @@ describe('Conversation inject API', () => {
else b.sessionFake.prompt.mockRejectedValueOnce(new Error('connection lost'))
composer.continueGeneration!(SessionSeq(5))
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce(); expect(composer.keyboard!.snapshot.phase).toBe('plain') })
- expect(composer.keyboard!.snapshot.draft).toBe('继续')
+ expect(composer.keyboard!.snapshot.draft).toBe('')
if (outcome === 'refused') expect(b.runtime.sessions.binding(ROOT)!.session.getSnapshot().promptError?.error.message).toBe('request refused')
else expect(composer.hooks.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'connection lost' })
composer.continueGeneration!(SessionSeq(5))
- expect(b.sessionFake.prompt).toHaveBeenCalledOnce()
+ await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) })
})
it('renders literal quote text through the actual Factory and keyed node registration', async () => {
diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts
index 402063cedc..25420eff00 100644
--- a/packages/client/ui-conversation/tests/config.host.spec.ts
+++ b/packages/client/ui-conversation/tests/config.host.spec.ts
@@ -1,6 +1,6 @@
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { Context, FiberState } from '@deepseek-ai/cordis'
+import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished } from 'vitest'
import * as HostPlugin from '../src/index.ts'
import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts'
@@ -32,15 +32,3 @@ it('keeps its own instance off the generated Settings pages', () => omitsGenerat
await ctx.plugin(SessionProjectionRegistry)
return ctx.plugin(HostPlugin)
}))
-
-it('waits for the required projection registry before activation', async () => {
- const ctx = new Context()
- onTestFinished(() => ctx.fiber.dispose())
- await ctx.plugin(SessionStore)
- const fiber = ctx.plugin(HostPlugin)
- await ctx.fiber.await()
- expect(fiber.state).toBe(FiberState.PENDING)
- await ctx.plugin(SessionProjectionRegistry)
- await fiber
- expect(fiber.state).toBe(FiberState.ACTIVE)
-})
diff --git a/packages/client/ui-conversation/tsconfig.host.json b/packages/client/ui-conversation/tsconfig.host.json
index cc278423fc..ec30b21bfe 100644
--- a/packages/client/ui-conversation/tsconfig.host.json
+++ b/packages/client/ui-conversation/tsconfig.host.json
@@ -7,9 +7,7 @@
},
"files": [
"src/submission-settings.ts",
- "src/index.ts",
- "src/continuation-types.ts",
- "src/host/continuation.ts"
+ "src/index.ts"
],
"references": [
{
@@ -20,15 +18,6 @@
},
{
"path": "../../settings/settings"
- },
- {
- "path": "../../core/session"
- },
- {
- "path": "../../session/session-projection"
- },
- {
- "path": "../../util/brand"
}
]
}
diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml
index 33c53afffa..2a3ecdf694 100644
--- a/packages/core/agent-loop/README.i18n.yaml
+++ b/packages/core/agent-loop/README.i18n.yaml
@@ -51,8 +51,8 @@
en: eea56903c4ff82f5
zh: c0d5abb5767780ed
/deepseek-ai-dsh-agent-loop/further-exploration:
- en: 7ac519f8e635acbc
- zh: 975e514915dc6c9f
+ en: 687e42aa91c15be5
+ zh: 0e2d860391a9ce89
/deepseek-ai-dsh-agent-loop/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index 28b862ff9d..9beebb6543 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -147,6 +147,8 @@ The package-level contract is enough for most consumers; read these when you nee
-----
+Empty ordinary input remains an identified inbox command, so it can wake a new turn while adding no `user/message` to the model surface. It passes normal pre-step admission; a rejected or removed input does not authorize a model request. Turn and step endings retain their normal success, failure, and cancellation handling.
+
## Model Experience
### Complete conversation request
diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md
index f872d4289e..8bbbd145c5 100644
--- a/packages/core/agent-loop/README.zh.md
+++ b/packages/core/agent-loop/README.zh.md
@@ -147,6 +147,8 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄
-----
+空的普通输入仍是带身份的 inbox 控制记录,可以唤醒新轮次,但不向模型消息面添加 `user/message`。它经过正常 pre-step 准入;被拒绝或移除的输入不授权模型请求。轮次和步骤结束沿用正常的成功、失败与取消处理。
+
## 模型体验
### 完整对话请求
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index 03c4c0c2bf..619cca96ba 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -460,7 +460,7 @@ export class ReactLoopAgent implements Agent {
this.session.append('user/message', context, { surfaceOp: 'append' })
contextAdmitted = true
}
- } else {
+ } else if (message.source.kind !== 'user' || message.content.length > 0) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
}
diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts
index 5287cb4deb..16c07fcf4e 100644
--- a/packages/core/agent-loop/tests/request-error.spec.ts
+++ b/packages/core/agent-loop/tests/request-error.spec.ts
@@ -1,5 +1,5 @@
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -32,6 +32,31 @@ function fail(message: string, code: string): () => never {
}
describe('agent/request-error', () => {
+ it('continues repeated failures through empty followups with balanced turns and one admitted user message', async () => {
+ const adapter = new MockAdapter([fail('busy', 'SERVER'), fail('still busy', 'SERVER'), textResponse('done')])
+ const ctx = await harness(adapter)
+ onTestFinished(() => ctx.fiber.dispose())
+ const agent = await ctx.agentLoop.create(SessionId('empty-continuation'), { provider: 'mock', model: 'mock' })
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } }))
+ await agent.whenIdle()
+ for (let index = 0; index < 2; index++) {
+ agent.followup(createUserMessage({ content: [], source: { kind: 'user' } }))
+ await agent.whenIdle()
+ }
+ expect(adapter.requests).toHaveLength(3)
+ for (const request of adapter.requests) {
+ expect(request.messages.filter(message => message.role === 'user').map(message => message.content))
+ .toEqual([[{ type: 'text', text: 'original task' }]])
+ }
+ const events = agent.session.snapshotEvents()
+ expect(events.filter(event => event.type === 'user/message')).toHaveLength(1)
+ expect(events.flatMap(event => event.type === 'turn/start' || event.type === 'turn/end'
+ ? [`${event.type}:${event.data.turn}`] : []))
+ .toEqual(['turn/start:1', 'turn/end:1', 'turn/start:2', 'turn/end:2', 'turn/start:3', 'turn/end:3'])
+ expect(events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []))
+ .toEqual(['error', 'error', 'completed'])
+ })
+
it('does not offer middleware failures to request recovery', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml
index 53a219df1a..3aeeb9a406 100644
--- a/packages/core/agent/README.i18n.yaml
+++ b/packages/core/agent/README.i18n.yaml
@@ -60,8 +60,8 @@
en: e48eae6c7de88cf8
zh: 1f5b37bdb0e53788
/deepseek-ai-dsh-agent/further-exploration:
- en: 4ef57cfafa344581
- zh: 73575876bfdc7a18
+ en: d9d9bda2357ef857
+ zh: 5fa1368d5f8912e2
/deepseek-ai-dsh-agent/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index a7433347f8..2f7c830c7c 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -151,6 +151,8 @@ The package-level contract is enough for most consumers; read these when you nee
-----
+An ordinary `followup` with `content: []` is an explicit request to start a new turn from committed history. It retains the identified inbox receipt and normal lifecycle without creating a model-visible user message. A pre-step rejection or a decision rewritten to an empty batch still prevents the request.
+
## Model Experience
### User, steering, and injected messages
diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md
index a92b8338e0..1eb86a7a5e 100644
--- a/packages/core/agent/README.zh.md
+++ b/packages/core/agent/README.zh.md
@@ -151,6 +151,8 @@ await handle.agent.whenIdle()
-----
+普通 `followup` 的 `content: []` 明确请求基于已提交历史启动新轮次。它保留带身份的 inbox 回执和正常生命周期,不生成模型可见的用户消息。pre-step 拒绝或改写为空批次的决策仍会阻止请求。
+
## 模型体验
### 用户、steering 与注入消息
diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts
index 2b1698e39c..58d883721c 100644
--- a/packages/core/agent/src/runtime-types.ts
+++ b/packages/core/agent/src/runtime-types.ts
@@ -232,6 +232,7 @@ declare module './types.ts' {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
+ * Empty ordinary input drives work without becoming a model-visible message.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
@@ -245,7 +246,8 @@ declare module './types.ts' {
/**
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
- * sole ordinary message of its own turn.
+ * sole ordinary message of its own turn. An empty ordinary input starts a
+ * turn from committed history without appending a model-visible user message.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 489c15f56e..a8056e3e2f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4001,15 +4001,9 @@ importers:
packages/client/ui-conversation:
dependencies:
- '@deepseek-ai/dsh-brand':
- specifier: workspace:*
- version: link:../../util/brand
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
- zod:
- specifier: 4.4.3
- version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -4029,6 +4023,9 @@ importers:
'@deepseek-ai/dsh-attachment':
specifier: workspace:*
version: link:../../attachment/attachment
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:*
+ version: link:../../util/brand
'@deepseek-ai/dsh-client-file-upload':
specifier: workspace:*
version: link:../file-upload
@@ -4143,6 +4140,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
+ zod:
+ specifier: 4.4.3
+ version: 4.4.3
packages/client/ui-deepseek-model-label-1017:
devDependencies:
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index 3141b7bb24..fe17f6f952 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -113,7 +113,6 @@ RESTART_SECOND_SESSION_ID = "process-two"
RESUME_PROMPT = "Continue the first Python SDK session after restarting the runtime."
RESUME_TEXT = "RESUMED_PROCESS_OK"
RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call."
-RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools."
RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK"
RECOVERY_SESSION_ID = "scheduler-recovery"
RECOVERY_CALL_IDS = ("scheduler-complete", "scheduler-fail", "scheduler-unstarted")
@@ -348,7 +347,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
block.get("text")
for block in latest.get("content", [])
if isinstance(block, dict) and block.get("type") == "text"
- and block.get("text") in {RECOVERY_PROMPT, RECOVERY_CONTINUE_PROMPT}
+ and block.get("text") == RECOVERY_PROMPT
), None)
if recovery_prompt == RECOVERY_PROMPT:
assert_advertised_tool(body, "todo_write")
@@ -358,7 +357,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
chunks.extend({**chunk, "index": index} for chunk in call[1:-2])
chunks.extend(call[-2:])
return chunks
- if recovery_prompt == RECOVERY_CONTINUE_PROMPT:
+ if any(block.get("tool_use_id") == RECOVERY_CALL_IDS[-1]
+ for message in messages for block in message.get("content", [])
+ if isinstance(block, dict) and block.get("type") == "tool_result"):
results = [
block for message in messages
for block in message.get("content", [])
@@ -1909,7 +1910,7 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho
with connect() as harness:
failed = harness.run(RECOVERY_PROMPT, session_id=RECOVERY_SESSION_ID)
- continued = harness.run(RECOVERY_CONTINUE_PROMPT, session_id=RECOVERY_SESSION_ID)
+ continued = harness.run([], session_id=RECOVERY_SESSION_ID)
results = [failed, continued]
if [result.finish_reason for result in results] != ["error", "completed"]:
outcomes = [
diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json
index f83fee366e..41cd02a726 100644
--- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json
+++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json
@@ -97,10 +97,6 @@
}
],
"is_error": true
- },
- {
- "type": "text",
- "text": "Continue after the scheduler failure without repeating any tools."
}
]
}
diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json
index 46704a4ab7..7172a0edd1 100644
--- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json
+++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json
@@ -57,7 +57,6 @@
"turn/start",
"agent/inbox/spliced",
"step/start",
- "user/message",
"assistant/message",
"step/end",
"turn/end"
@@ -71,7 +70,6 @@
"session.event",
"session.event",
"session.event",
- "session.event",
"session.status"
]
}
diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl
index c679ff36b7..37ccfaa185 100644
--- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl
+++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl
@@ -21,11 +21,10 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
-{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts
index 1a687411fc..ddd859bd38 100644
--- a/snapshots/sdk/sdk.snapshot.ts
+++ b/snapshots/sdk/sdk.snapshot.ts
@@ -485,11 +485,23 @@ function modelFromSession(log: string): { provider: string; model: string } {
function turnActions(log: string): TurnAction[] {
const actions: TurnAction[] = []
let current: TurnAction | undefined
+ let emptyQueued = false
for (const record of records(log)) {
+ if (record.type === 'agent/inbox/spliced') {
+ const data = record.data as JsonObject | undefined
+ if (data?.target === 'next-turn' && Array.isArray(data.inserted)) {
+ emptyQueued ||= data.inserted.some(value => {
+ const message = value as JsonObject
+ return (message.source as JsonObject | undefined)?.kind === 'user'
+ && Array.isArray(message.content) && message.content.length === 0
+ })
+ }
+ }
if (record.type === 'turn/start') {
const data = record.data as JsonObject | undefined
if (typeof data?.turn !== 'number') throw new Error('SDK snapshot turn/start has no turn')
- current = { turn: data.turn }
+ current = { turn: data.turn, ...emptyQueued ? { content: [] } : {} }
+ emptyQueued = false
continue
}
if (record.type === 'user/message' && current !== undefined && current.content === undefined) {
@@ -768,7 +780,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{
session = await harness.resumeSession(sessionId)
expect(session.id).toBe(sessionId)
}
- const result = await session.run(materializeInput(action.content, scenario, cwd, liveSessions), {
+ const input = materializeInput(action.content, scenario, cwd, liveSessions)
+ const result = await session.run(input, {
onNotification: (notification) => {
notifications.push(notification)
observe(notification)
@@ -1117,6 +1130,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
} } })
}
if (scenario.name === 'tool-scheduler-recovery') {
+ expect(records(ordered[0]!.content).filter(event => event.type === 'user/message'
+ && (event.data['source'] as { kind: string }).kind === 'user')).toHaveLength(1)
expect(results).toHaveLength(2)
const events = results.flatMap(result => result.events)
expect(results[1]?.finalResponse, JSON.stringify(events.filter(event => event.type === 'turn/end')))
diff --git a/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl b/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl
index 885df800f9..cfe4388d42 100644
--- a/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl
+++ b/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl
@@ -19,13 +19,12 @@
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":23,"time":0,"data":{"turn":2}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":2,"step":1}}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":1}}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":2,"step":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
diff --git a/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl b/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl
index 1eb28fe0c1..31eb0f7d38 100644
--- a/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl
+++ b/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl
@@ -21,11 +21,10 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
-{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/snapshots/web/live-interactions/continued.expected.md b/snapshots/web/live-interactions/continued.expected.md
index 1eb97847c2..0b4bcd0205 100644
--- a/snapshots/web/live-interactions/continued.expected.md
+++ b/snapshots/web/live-interactions/continued.expected.md
@@ -19,7 +19,7 @@
- status:
- text: This turn failedupstream 503
- code: SERVER
-- text: plan Plan mode on. Use /plan off to leave. plan Plan mode off. continue {{clock}}
+- text: plan Plan mode on. Use /plan off to leave. plan Plan mode off.
- button "Copy"
- status: Completed
- button "Completed in {{duration}}"
From 4a4aabaa57dd3203d1424f97d91fb7361d77dcea Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 22:16:24 +0800
Subject: [PATCH 010/196] fix(core): align empty continuation checks and
generated references
---
docs/architecture.i18n.yaml | 6 ++--
docs/architecture.md | 4 +--
docs/architecture.zh.md | 4 +--
docs/event-producer-consumer.md | 32 +++++++++----------
docs/event-producer-consumer.zh.md | 32 +++++++++----------
docs/persistence-catalog.i18n.yaml | 8 ++---
docs/persistence-catalog.md | 4 +--
docs/persistence-catalog.zh.md | 4 +--
docs/persistence-schema.json | 4 +--
docs/subsystems/core.md | 4 ++-
docs/subsystems/core.zh.md | 4 ++-
.../api/session-controller/README.i18n.yaml | 4 +--
packages/api/session-controller/README.md | 2 +-
packages/api/session-controller/README.zh.md | 2 +-
.../api/session-controller/src/commands.ts | 2 +-
.../client/ui-conversation/README.i18n.yaml | 4 +--
packages/client/ui-conversation/README.md | 2 +-
packages/client/ui-conversation/README.zh.md | 2 +-
.../src/client/input/facade.ts | 1 +
.../tests/apply-inject.client.spec.tsx | 2 +-
packages/core/agent-loop/README.i18n.yaml | 4 +--
packages/core/agent-loop/README.md | 2 +-
packages/core/agent-loop/README.zh.md | 2 +-
packages/core/agent/README.i18n.yaml | 4 +--
packages/core/agent/README.md | 2 +-
packages/core/agent/README.zh.md | 2 +-
26 files changed, 72 insertions(+), 71 deletions(-)
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index d1e20ef6b6..e9bb6328e2 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -19,13 +19,13 @@
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: 20ef882e44b06a10
- zh: 0d69c1500650868f
+ zh: c10c3934ed5570e9
/deepseek-harness-architecture/events:
en: 693407efb233f961
zh: 37b9dd0677d4405c
/deepseek-harness-architecture/turn-flow:
- en: 22964b8327adc3f6
- zh: c86d765e21430405
+ en: adffae46f8181328
+ zh: 1009d3a5934cd1ba
/deepseek-harness-architecture/session-log:
en: 8a7f19b95d426c99
zh: d5bb0538404e20ea
diff --git a/docs/architecture.md b/docs/architecture.md
index ed35ab94af..cd561ac353 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -107,7 +107,7 @@ turn/end
Factory setup follows SessionStore entry and `session/created`, preceding `agent/created`; closing events commit before removal.
-One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
+The durable `inbox` projection exposes pending input. Empty ordinary input starts a turn without a model-visible message; injected context waits for waking input.
Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
@@ -119,8 +119,6 @@ Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-ex
[`ctx.tools.execute()`](subsystems/tools.md) receives raw batches and concurrency limits.
-An identified empty ordinary input starts a new turn from committed history without creating a model-visible user message. It remains logged in the inbox, passes normal pre-step admission, and closes through the same turn and step lifecycle.
-
## Session log
The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)).
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 64c3be1ddd..02c7423e56 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -66,8 +66,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
-带身份的空普通输入基于已提交历史启动新轮次,不生成模型可见的用户消息。它仍记录在 inbox 中,经过正常 pre-step 准入,并通过相同的轮次和步骤生命周期闭合。
-
## 事件
事件就是扩展点,而选对事件域是大多数改动的第一个决定。
@@ -113,7 +111,7 @@ turn/end
工厂 setup 在 Session 进入 SessionStore 并完成 `session/created` 之后、`agent/created` 之前运行;收尾事件在移除之前提交。
-输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
+持久 `inbox` 投影公开待消费输入。空普通输入启动轮次而不新增模型可见消息;注入上下文等待唤醒输入。
循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 4ae036085f..c2f38b3416 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -12,22 +12,22 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
-| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:401`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:431`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:338`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:319`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:352`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:419`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:677`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:684`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index a385c1f64a..ee937cf3b9 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -14,22 +14,22 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
-| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:689`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:669`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:696`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:675`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:682`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:401`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:292`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:301`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:431`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:338`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:319`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:352`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:419`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:677`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:684`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 280eda500e..0774d434a7 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -1350,8 +1350,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
- en: 1e12d380c4988d40
- zh: 14a781104e00f360
+ en: 1e484bd12747db9f
+ zh: 1e6fc6aa9066a702
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1857,8 +1857,8 @@
en: 5a0bbf4c76266ac5
zh: 0b20854f90f9d74d
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
- en: e98b716228feb157
- zh: 2bb593ae9cd0ce0d
+ en: 3d3ae20cba45bae8
+ zh: 04ad200556cd8ced
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 97cfa4bd63..a498e62e2e 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -5601,7 +5601,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
-Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
+Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7657,7 +7657,7 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
-Sources: [`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts)
+Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index c1dc641ae7..d75ff6f2b1 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -5603,7 +5603,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
-来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7659,7 +7659,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
-来源:[`packages/api/session-controller/src/types.ts:395`](../packages/api/session-controller/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json
index c6c15a7c90..70f64325e4 100644
--- a/docs/persistence-schema.json
+++ b/docs/persistence-schema.json
@@ -251,7 +251,7 @@
{"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]},
{"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]},
{"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]},
- {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:386","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]},
+ {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:388","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]},
{"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]},
{"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]},
{"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]},
@@ -334,7 +334,7 @@
{"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]},
{"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]},
{"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]},
- {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:395"]},
+ {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:397"]},
{"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]},
{"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]},
{"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]},
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index 86df19aa94..d11e03488a 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -104,6 +104,7 @@ interface Agent {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
+ * Empty ordinary input drives work without becoming a model-visible message.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
@@ -117,7 +118,8 @@ interface Agent {
/**
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
- * sole ordinary message of its own turn.
+ * sole ordinary message of its own turn. An empty ordinary input starts a
+ * turn from committed history without appending a model-visible user message.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index db4bb206f5..7e4c682f69 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -108,6 +108,7 @@ interface Agent {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
+ * Empty ordinary input drives work without becoming a model-visible message.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
@@ -121,7 +122,8 @@ interface Agent {
/**
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
- * sole ordinary message of its own turn.
+ * sole ordinary message of its own turn. An empty ordinary input starts a
+ * turn from committed history without appending a model-visible user message.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 78f64d3acc..a1daccc8f0 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 954923654eebe2dc
zh: 6bd9a7d2ea965aed
/session-controller/configuration:
- en: 2a91d9f904e90756
- zh: 8affb15ccc571829
+ en: 94cb7b73b385b320
+ zh: 1f3bc1e6552a01a3
/session-controller/model-experience:
en: 30b917bf822bb4df
zh: b0b023b5b526c317
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 1b11d04106..371ee3c13e 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -110,9 +110,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation.
-
An empty `prompt.content` array requests a new turn only for an eligible failed, idle ordinary Session in queue mode. Pending human input prevents admission; non-waking injected context is retained. `conversationContinuation` supplies the shared six-code manual policy, independently of provider retry configuration. The input is logged through the inbox for request-id deduplication, but creates no model-visible user message. Whitespace-only text parts remain invalid.
+
## Model Experience
None, as invoked Agent commands own any model-visible effect.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index cb8a1ba818..9e698a26e7 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -110,9 +110,9 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不
Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。
-
空的 `prompt.content` 数组仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时请求新轮次。待消费的用户输入阻止准入,非唤醒的注入上下文保留。`conversationContinuation` 提供共用的六码手动策略,独立于提供方重试配置。输入通过 inbox 持久记录,用于 request-id 去重,但不生成模型可见的用户消息。仅含空白的文本块仍无效。
+
## 模型体验
无;任何模型可见效果都由被调用的 Agent 命令负责。
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index 0b62ff724a..b43333ebc0 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -320,7 +320,7 @@ export class SessionCommandController {
const inbox = agent.inbox
if (request.mode !== 'queue' || this.continuation.stateOf(agent.session).ending === null
|| inbox.nextTurn.length > 0 || inbox.nextStep.some(message => message.source.kind === 'user')) {
- throw new RemoteError('session/agent-busy', 'generation continuation is unavailable', {})
+ throw new RemoteError('session/agent-busy', 'generation continuation is unavailable', { reason: 'NO_CONTINUABLE_FAILURE_OR_PENDING_INPUT' })
}
agent.followup(createUserMessage({ content: [], source }))
return { accepted: true as const }
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 6ee1b33613..8507f1dd99 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -33,8 +33,8 @@
en: 6a7ae99570c9c007
zh: 26e86063e97d82c7
/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/what-the-model-sees:
- en: b1376c710fa71ce3
- zh: e751e1a490c36bd8
+ en: cfd91cdd0132732b
+ zh: 298d1ce1b973e7b0
/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/token-effect:
en: a5bbf55d50568fa2
zh: 6c43af211055db93
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 4a408c5950..8955b71b31 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -193,7 +193,7 @@ The selector must be a pure function of the owner currency. Its non-null return
#### What the model sees
-The play action starts a new request from committed Session history. Its empty input is a durable inbox command, not a model-visible user message; it adds no localized or hidden continuation instruction. Pending injected context follows ordinary admission.
+The play action starts a new request from committed Session history. Its `content: []` input is a durable inbox command, not a model-visible user message; it adds no localized or hidden continuation instruction. Pending injected context follows ordinary admission.
#### Token effect
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 85df62d84b..65c9feda93 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -193,7 +193,7 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
#### 模型看到什么
-播放按钮基于已提交的 Session 历史发起新请求。空输入作为持久 inbox 控制记录,不成为模型可见的用户消息;不会添加本地化或隐藏的继续指令。待注入上下文遵循普通准入流程。
+播放按钮基于已提交的 Session 历史发起新请求。`content: []` 作为持久 inbox 控制记录,不成为模型可见的用户消息;不会添加本地化或隐藏的继续指令。待注入上下文遵循普通准入流程。
#### Token 影响
diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts
index e82acfdd35..cb8472f746 100644
--- a/packages/client/ui-conversation/src/client/input/facade.ts
+++ b/packages/client/ui-conversation/src/client/input/facade.ts
@@ -387,6 +387,7 @@ export class SessionInputShell implements SessionInput {
* out from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting/sending) force-closes the transient layers: the
* popup dismisses and the menu tracks frozen.
+ * @param allowEmpty - explicitly admit empty continuation input through the default sink.
*/
submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter', allowEmpty = false): void {
if (this.disposed) return
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
index a1cf51452a..58dff85859 100644
--- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
@@ -234,7 +234,7 @@ describe('Conversation inject API', () => {
expect(source.getSnapshot()).toBe(5)
composer.continueGeneration!(SessionSeq(5))
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() })
- expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: '继续' }])
+ expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([])
expect(notified).toHaveBeenCalledTimes(3)
})
diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml
index 2a3ecdf694..5b88c245a3 100644
--- a/packages/core/agent-loop/README.i18n.yaml
+++ b/packages/core/agent-loop/README.i18n.yaml
@@ -51,8 +51,8 @@
en: eea56903c4ff82f5
zh: c0d5abb5767780ed
/deepseek-ai-dsh-agent-loop/further-exploration:
- en: 687e42aa91c15be5
- zh: 0e2d860391a9ce89
+ en: bd0b5d56ebef7e23
+ zh: 52911b993bc41afb
/deepseek-ai-dsh-agent-loop/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index 9beebb6543..b18484ad82 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -146,9 +146,9 @@ The package-level contract is enough for most consumers; read these when you nee
-----
-
Empty ordinary input remains an identified inbox command, so it can wake a new turn while adding no `user/message` to the model surface. It passes normal pre-step admission; a rejected or removed input does not authorize a model request. Turn and step endings retain their normal success, failure, and cancellation handling.
+
## Model Experience
### Complete conversation request
diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md
index 8bbbd145c5..54395d27dc 100644
--- a/packages/core/agent-loop/README.zh.md
+++ b/packages/core/agent-loop/README.zh.md
@@ -146,9 +146,9 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄
-----
-
空的普通输入仍是带身份的 inbox 控制记录,可以唤醒新轮次,但不向模型消息面添加 `user/message`。它经过正常 pre-step 准入;被拒绝或移除的输入不授权模型请求。轮次和步骤结束沿用正常的成功、失败与取消处理。
+
## 模型体验
### 完整对话请求
diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml
index 3aeeb9a406..a7bdc11ed0 100644
--- a/packages/core/agent/README.i18n.yaml
+++ b/packages/core/agent/README.i18n.yaml
@@ -60,8 +60,8 @@
en: e48eae6c7de88cf8
zh: 1f5b37bdb0e53788
/deepseek-ai-dsh-agent/further-exploration:
- en: d9d9bda2357ef857
- zh: 5fa1368d5f8912e2
+ en: faf384f89b18b671
+ zh: 42400f79f5ab84b0
/deepseek-ai-dsh-agent/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index 2f7c830c7c..aba28e301e 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -150,9 +150,9 @@ The package-level contract is enough for most consumers; read these when you nee
-----
-
An ordinary `followup` with `content: []` is an explicit request to start a new turn from committed history. It retains the identified inbox receipt and normal lifecycle without creating a model-visible user message. A pre-step rejection or a decision rewritten to an empty batch still prevents the request.
+
## Model Experience
### User, steering, and injected messages
diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md
index 1eb86a7a5e..c317176430 100644
--- a/packages/core/agent/README.zh.md
+++ b/packages/core/agent/README.zh.md
@@ -150,9 +150,9 @@ await handle.agent.whenIdle()
-----
-
普通 `followup` 的 `content: []` 明确请求基于已提交历史启动新轮次。它保留带身份的 inbox 回执和正常生命周期,不生成模型可见的用户消息。pre-step 拒绝或改写为空批次的决策仍会阻止请求。
+
## 模型体验
### 用户、steering 与注入消息
From 4daabfd629bbcdaeab1e2c21304f4b3ea6bcbf0f Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 22:20:41 +0800
Subject: [PATCH 011/196] fix(test): include shared continuation events in
client checks
---
tsconfig.client.json | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tsconfig.client.json b/tsconfig.client.json
index 514c1f9deb..36babf0686 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -29,6 +29,8 @@
"packages/experimental/*/src/css-modules.d.ts",
"packages/client/*/tests/**/*.ts",
"packages/client/*/tests/**/*.tsx",
+ // Shared Session event data has no Host Context augmentation.
+ "packages/api/session-controller/tests/continuation-fixture.ts",
"benchmarks/**/*.client.ts",
"benchmarks/**/*.client.tsx",
"benchmarks/support/**/*.ts",
From 1b907b26aacdf43d07d9046868badd80bbfae2a4 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 22:25:05 +0800
Subject: [PATCH 012/196] test(web): pin continuation without a user message
bubble
---
packages/api/session-controller/src/commands.ts | 2 +-
packages/api/session-controller/tests/session-cold.host.spec.ts | 2 +-
snapshots/web/live-interactions/continued.expected.md | 1 -
3 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index b43333ebc0..a258825c66 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -62,7 +62,7 @@ type PromptContentCandidate =
/**
* Whether prompt content carries non-whitespace text or an attachment.
* @param content - submitted or edited prompt parts.
- * @returns false for content the Agent must not receive.
+ * @returns whether any part supplies ordinary prompt content.
*/
export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
return content.some(part => part.type !== 'text' || part.text.trim().length > 0)
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index 5139baf644..7e58d39aca 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -874,7 +874,7 @@ async function modelFor(ctx: Context, session: Session): Promise {
}
describe('sessions.prompt synchronous rejection', () => {
- it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => {
+ it('rejects whitespace-only text parts before delivery or Session events', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
diff --git a/snapshots/web/live-interactions/continued.expected.md b/snapshots/web/live-interactions/continued.expected.md
index 0b4bcd0205..16c21a89f1 100644
--- a/snapshots/web/live-interactions/continued.expected.md
+++ b/snapshots/web/live-interactions/continued.expected.md
@@ -20,7 +20,6 @@
- text: This turn failedupstream 503
- code: SERVER
- text: plan Plan mode on. Use /plan off to leave. plan Plan mode off.
-- button "Copy"
- status: Completed
- button "Completed in {{duration}}"
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
From 53d4ed8f217ea157081a6ad8de7bf99e6e3a4c95 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 22:40:07 +0800
Subject: [PATCH 013/196] refactor(api): use canceled inbox splice semantics
---
packages/api/session-controller/src/continuation.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/api/session-controller/src/continuation.ts b/packages/api/session-controller/src/continuation.ts
index 8673cfc731..5d8e97553e 100644
--- a/packages/api/session-controller/src/continuation.ts
+++ b/packages/api/session-controller/src/continuation.ts
@@ -33,7 +33,7 @@ export const continuationProjectionDefinition = {
switch (event.type) {
case 'agent/inbox/spliced':
if (state.continuing && event.data.target === 'next-turn' && event.data.outcome === 'canceled'
- && event.data.start === 0 && (event.data.removedCount ?? 0) > 0) return { ...state, continuing: false }
+ && event.data.start === 0) return { ...state, continuing: false }
return state.ending !== null && !state.continuing && event.data.target === 'next-turn'
&& event.data.inserted.some(message => message.source.kind === 'user' && message.content.length === 0)
? { ...state, continuing: true } : state
From 04f1a699ee54d7210632a58252fa713e791ccd93 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Mon, 26 Oct 2026 22:49:03 +0800
Subject: [PATCH 014/196] fix(api): return the synchronous continuation
admission promise
---
packages/api/session-controller/src/commands.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index a258825c66..88d7486cbe 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -316,14 +316,14 @@ export class SessionCommandController {
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
if (request.content.length === 0) {
try {
- return await agent.runMaintenance(async () => {
+ return await agent.runMaintenance(() => {
const inbox = agent.inbox
if (request.mode !== 'queue' || this.continuation.stateOf(agent.session).ending === null
|| inbox.nextTurn.length > 0 || inbox.nextStep.some(message => message.source.kind === 'user')) {
throw new RemoteError('session/agent-busy', 'generation continuation is unavailable', { reason: 'NO_CONTINUABLE_FAILURE_OR_PENDING_INPUT' })
}
agent.followup(createUserMessage({ content: [], source }))
- return { accepted: true as const }
+ return Promise.resolve({ accepted: true as const })
})
} catch (error: unknown) {
if (remoteErrorOf(error) !== undefined) throw error
From 2e0c73a89599a50102448b8f67199565f04b45c6 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Tue, 27 Oct 2026 12:07:24 +0800
Subject: [PATCH 015/196] refactor(agent): record continuation as retry control
input
---
...6-manual-generation-continuation.i18n.yaml | 4 +-
...26-10-26-manual-generation-continuation.md | 6 +-
...10-26-manual-generation-continuation.zh.md | 6 +-
apps/web/tests/live-interactions.e2e.ts | 9 +-
docs/architecture.i18n.yaml | 4 +-
docs/architecture.md | 4 +-
docs/architecture.zh.md | 4 +-
docs/event-producer-consumer.md | 10 +-
docs/event-producer-consumer.zh.md | 10 +-
docs/persistence-catalog.i18n.yaml | 160 ++++++-----
docs/persistence-catalog.md | 199 ++++++++++----
docs/persistence-catalog.zh.md | 199 ++++++++++----
.../2026-10-27-retry-control-input.i18n.yaml | 28 ++
.../2026-10-27-retry-control-input.md | 64 +++++
...2026-10-27-retry-control-input.schema.json | 257 ++++++++++++++++++
.../2026-10-27-retry-control-input.zh.md | 64 +++++
docs/persistence-schema.json | 79 +++---
docs/subsystems/core.md | 8 +-
docs/subsystems/core.zh.md | 8 +-
.../empty-prompt-continuation/guide.i18n.yaml | 22 +-
.../empty-prompt-continuation/guide.md | 15 +-
.../empty-prompt-continuation/guide.zh.md | 15 +-
.../api/session-controller/README.i18n.yaml | 4 +-
packages/api/session-controller/README.md | 2 +-
packages/api/session-controller/README.zh.md | 2 +-
.../api/session-controller/src/commands.ts | 7 +-
.../session-controller/src/continuation.ts | 17 +-
packages/api/session-controller/src/index.ts | 2 +-
packages/api/session-controller/src/types.ts | 2 +
.../tests/continuation.host.spec.ts | 44 +--
.../tests/empty-prompt.host.spec.ts | 17 +-
.../client/ui-conversation/README.i18n.yaml | 12 +-
packages/client/ui-conversation/README.md | 6 +-
packages/client/ui-conversation/README.zh.md | 6 +-
packages/core/agent-loop/README.i18n.yaml | 8 +-
packages/core/agent-loop/README.md | 4 +-
packages/core/agent-loop/README.zh.md | 4 +-
packages/core/agent-loop/src/agent.ts | 7 +-
.../agent-loop/tests/request-error.spec.ts | 32 ++-
packages/core/agent/README.i18n.yaml | 8 +-
packages/core/agent/README.md | 4 +-
packages/core/agent/README.zh.md | 4 +-
packages/core/agent/src/runtime-types.ts | 8 +-
packages/core/agent/src/types.ts | 18 +-
.../core/session/src/known-event-types.ts | 1 +
packages/sdk/client/src/client.ts | 2 +-
packages/sdk/protocol/README.i18n.yaml | 4 +-
packages/sdk/protocol/README.md | 2 +-
packages/sdk/protocol/README.zh.md | 2 +-
packages/sdk/protocol/src/types.ts | 4 +-
packages/sdk/server/README.i18n.yaml | 8 +-
packages/sdk/server/README.md | 4 +-
packages/sdk/server/README.zh.md | 4 +-
packages/sdk/server/src/server.ts | 4 +-
scripts/smoke-python-runtime.py | 5 +
.../scheduler-recovery/result.json | 2 +
.../scheduler-recovery/session.v4.jsonl | 3 +-
snapshots/sdk/sdk.snapshot.ts | 19 +-
.../notifications.expected.jsonl | 9 +-
.../tool-scheduler-recovery/session.v4.jsonl | 3 +-
60 files changed, 1083 insertions(+), 386 deletions(-)
create mode 100644 docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml
create mode 100644 docs/persistence-changes/2026-10-27-retry-control-input.md
create mode 100644 docs/persistence-changes/2026-10-27-retry-control-input.schema.json
create mode 100644 docs/persistence-changes/2026-10-27-retry-control-input.zh.md
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
index 0b66ec2f1d..8c9fc03d74 100644
--- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml
@@ -9,8 +9,8 @@
en: f118844946afdbdc
zh: f1fe37d042f88b24
/agent-note-manual-generation-continuation/decision:
- en: 32e75f0c2a35bfec
- zh: 343fedcd8f53a6a8
+ en: d384d0ea6c502908
+ zh: 90e0cb41a5a0d125
/agent-note-manual-generation-continuation/alternatives-considered:
en: 58bb948d210d42ea
zh: 5dc920d222d71218
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
index ab91eb5f7b..ededd88ffb 100644
--- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md
@@ -10,11 +10,11 @@ Users need another request after a model failure without inserting an artificial
## Decision
-The play action submits `content: []` through the existing Session prompt API. AgentLoop retains the identified inbox input and starts a new balanced turn, but omits the empty ordinary input from model-visible `user/message` events. The original failed turn remains closed.
+The play action submits `content: []` through the existing Session prompt API. The API assigns `source.kind: 'retry'`. AgentLoop retains the identified inbox input and starts a new balanced turn; acceptance records `agent/retry` instead of a model-visible `user/message`. Ordinary empty user-message behavior is unchanged. The original failed turn remains closed.
-The Session API owns the fixed-size `conversationContinuation` projection and shares its value with the browser. Admission requires an eligible failure, true idle ownership, and no pending human input. An accepted empty input carries the admitted work into its next turn, preserving eligibility if that request also fails.
+The Session API owns the fixed-size `conversationContinuation` projection and shares its value with the browser. Admission requires an eligible failure, true idle ownership, and no pending human input. An accepted retry event admits the next turn, preserving eligibility if that request also fails.
-The six-code manual allowlist stays independent of provider retry policy. Non-waking injected context accompanies the request. Durable inbox receipts retain request-id deduplication after the empty input is claimed; no new Remote operation or Session event format is required.
+The six-code manual allowlist stays independent of provider retry policy. Non-waking injected context accompanies the request. The retry event retains request-id deduplication after the inbox input is claimed; no new Remote operation is required. An explicit control source prevents unrelated empty user inputs from changing behavior, and a separate event keeps chat rendering free of retry-specific filtering.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
index 21ff2cf50a..b3d659edf2 100644
--- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
+++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md
@@ -10,11 +10,11 @@ Status: implemented
## 决策
-播放按钮通过现有 Session prompt API 提交 `content: []`。AgentLoop 保留带身份的 inbox 输入并启动正常闭合的新轮次,但不将空普通输入写成模型可见的 `user/message`。原失败轮次保持闭合。
+播放按钮通过现有 Session prompt API 提交 `content: []`。API 指定 `source.kind: 'retry'`。AgentLoop 保留带身份的 inbox 输入并启动正常闭合的新轮次;接受后记录 `agent/retry`,而不写入模型可见的 `user/message`。普通空用户消息的行为保持不变。原失败轮次保持闭合。
-Session API 拥有固定大小的 `conversationContinuation` 投影,向浏览器发布相同的值。准入要求失败符合条件、持有真正的空闲状态且没有待消费的用户输入。已接受的空输入将原先准入的工作带入新轮次,因此请求再次失败后仍保留继续资格。
+Session API 拥有固定大小的 `conversationContinuation` 投影,向浏览器发布相同的值。准入要求失败符合条件、持有真正的空闲状态且没有待消费的用户输入。已接受的重试事件使新轮次获得准入,因此请求再次失败后仍保留继续资格。
-六码手动白名单独立于提供方重试策略。非唤醒的注入上下文随请求进入。空输入被领取后,持久 inbox 回执仍支持 request-id 去重;不需要新增 Remote 操作或 Session 事件格式。
+六码手动白名单独立于提供方重试策略。非唤醒的注入上下文随请求进入。inbox 输入被领取后,重试事件仍支持 request-id 去重;不需要新增 Remote 操作。显式控制来源避免改变无关空用户输入的行为,独立事件则使聊天渲染无需针对重试过滤。
## 考虑过的替代方案
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index eda193847c..bde84fe2d1 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -414,7 +414,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(tripwire.warnings).toEqual([])
}, 120_000)
- it.skipIf(MODE === 'record')('continues after transient retries exhaust without another user message', async () => {
+ it.skipIf(MODE === 'record')('continues after transient retries exhaust without another user bubble', async () => {
// A whole-script replacement: three throw entries cover the first request
// plus both budgeted retries (patches cannot reach past the one-call
// derived script). The scenario-owned policy keeps exhaustion fast and
@@ -476,8 +476,11 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
expect(turnEndReasons(sessionEvents)).toEqual(['error', 'completed'])
expect(sessionEvents.some(event => event.type === 'user/message' && event.data.source.kind === 'plan-mode')).toBe(true)
const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
- ? [event.data.content] : [])
- expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }]])
+ ? [event.data] : [])
+ expect(userMessages.map(message => message.content)).toEqual([[{ type: 'text', text: PROMPT }]])
+ expect(sessionEvents.filter(event => event.type === 'agent/retry')).toEqual([
+ expect.objectContaining({ data: expect.objectContaining({ turn: 2, step: 1, source: expect.objectContaining({ kind: 'retry' }) }) }),
+ ])
expect(sessionEvents.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(sessionEvents.filter(event => event.type === 'turn/end')).toHaveLength(2)
expect(await resume.count()).toBe(0)
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index e9bb6328e2..5dfa2c2995 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -24,8 +24,8 @@
en: 693407efb233f961
zh: 37b9dd0677d4405c
/deepseek-harness-architecture/turn-flow:
- en: adffae46f8181328
- zh: 1009d3a5934cd1ba
+ en: 3770eacd9c3bae28
+ zh: bd7b8a0374ba0ff8
/deepseek-harness-architecture/session-log:
en: 8a7f19b95d426c99
zh: d5bb0538404e20ea
diff --git a/docs/architecture.md b/docs/architecture.md
index cd561ac353..064026d4d2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -107,9 +107,9 @@ turn/end
Factory setup follows SessionStore entry and `session/created`, preceding `agent/created`; closing events commit before removal.
-The durable `inbox` projection exposes pending input. Empty ordinary input starts a turn without a model-visible message; injected context waits for waking input.
+One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
-Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
+Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Empty retry-source controls record `agent/retry` instead of entering model history; they retain ordinary inbox and turn lifecycle. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 02c7423e56..e836ab7452 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -111,9 +111,9 @@ turn/end
工厂 setup 在 Session 进入 SessionStore 并完成 `session/created` 之后、`agent/created` 之前运行;收尾事件在移除之前提交。
-持久 `inbox` 投影公开待消费输入。空普通输入启动轮次而不新增模型可见消息;注入上下文等待唤醒输入。
+输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
-循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
+循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 空的 retry 来源控制输入记录 `agent/retry`,不进入模型历史;inbox 与轮次生命周期仍按普通流程处理。
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 1c1bdb2d05..fbe751ca65 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -23,11 +23,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:419`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:677`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:684`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:673`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:679`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index f793641ff3..30163a2e5f 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -25,11 +25,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:311`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:419`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:691`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:671`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:698`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:677`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:684`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:693`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:673`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:700`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:679`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:686`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:55`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 0774d434a7..3214b08f10 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -9,8 +9,8 @@
en: 1387c4505dde2faf
zh: 48570fa1c13d6910
/session-persistence-event-catalog/persistence-type-fingerprints:
- en: 519d46d6078d3f68
- zh: ec9aec262312c573
+ en: 84b66494d8162dc6
+ zh: f89855f2a8e9a367
/session-persistence-event-catalog/event-envelope:
en: 56414ffd00cdc32a
zh: 6dc11eb013e95e41
@@ -21,8 +21,11 @@
en: 9188862058c6986b
zh: 9188862058c6986b
/session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only:
- en: 481727894895b43b
- zh: 098bc9788bf88968
+ en: 9ae614c041a27169
+ zh: 8d669dd582b88f79
+/session-persistence-event-catalog/events/agent/agent-retry-log-only:
+ en: 559e0484e8ae8904
+ zh: 8967a336e5aef50c
/session-persistence-event-catalog/events/agent-preset:
en: 49891ca9be5ac123
zh: 49891ca9be5ac123
@@ -141,8 +144,8 @@
en: 9edf4ef31e60e880
zh: 9edf4ef31e60e880
/session-persistence-event-catalog/events/model/model-selection-log-only:
- en: c2a60ec7d297ed79
- zh: a2f6b2d3c733ce96
+ en: 404c54b08c088438
+ zh: eb86ed4e7d0e9228
/session-persistence-event-catalog/events/permission:
en: 19917bc5438e60af
zh: 19917bc5438e60af
@@ -330,8 +333,11 @@
en: b3b528acadd04168
zh: b3b528acadd04168
/session-persistence-event-catalog/resolved-persistence-types/agent-inbox-spliced:
- en: 8f6d91375141d923
- zh: 8f6d91375141d923
+ en: 8719b8f3a0388f64
+ zh: 8719b8f3a0388f64
+/session-persistence-event-catalog/resolved-persistence-types/agent-retry:
+ en: f634a360f6f4a5a7
+ zh: f634a360f6f4a5a7
/session-persistence-event-catalog/resolved-persistence-types/allowed-once:
en: 6c3effe2abc1aa53
zh: 6c3effe2abc1aa53
@@ -711,8 +717,11 @@
en: ade80ed58d3b63b2
zh: ade80ed58d3b63b2
/session-persistence-event-catalog/resolved-persistence-types/resume:
- en: e27772872766bd6d
- zh: e27772872766bd6d
+ en: f9a34b26963fc22d
+ zh: f9a34b26963fc22d
+/session-persistence-event-catalog/resolved-persistence-types/retry:
+ en: 68c90ebe83c80004
+ zh: 68c90ebe83c80004
/session-persistence-event-catalog/resolved-persistence-types/runtime-context:
en: 784f0d4afdaaa472
zh: 784f0d4afdaaa472
@@ -1008,11 +1017,11 @@
en: a6a2e38fdd6d127a
zh: 1fe82356d89f5769
/session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest:
- en: cf3979c698e2b2ac
- zh: 6252c8b668faa706
+ en: 96cd6b26224ff94f
+ zh: cf340bff9692f438
/session-persistence-event-catalog/resolved-persistence-types/developermessage:
- en: 16992572a6919e52
- zh: 853f4873fd520444
+ en: 6a4c81ce3ec34434
+ zh: 96bced8b8cad4b1a
/session-persistence-event-catalog/resolved-persistence-types/epochheader:
en: 3bdc8ecd0bdf0c9c
zh: 0fe5b75c174ebab4
@@ -1077,8 +1086,8 @@
en: a3087ab7bebe9371
zh: abf6afc2f9f5b692
/session-persistence-event-catalog/resolved-persistence-types/inboxtarget:
- en: b3b7b8a3414bcb6b
- zh: 06be5b8e56bacedf
+ en: f17ed3bd806e279f
+ zh: 72920ace94922d45
/session-persistence-event-catalog/resolved-persistence-types/jobregistereddata:
en: f604eebb973fe2cf
zh: 0c0aba1e17ecf66e
@@ -1122,11 +1131,11 @@
en: 6dbe099c62f7c226
zh: 6fd788f63c038c91
/session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata:
- en: 5d61649043e317aa
- zh: bdf9b9d553d291cf
+ en: dc14ea1c81e3d226
+ zh: 21a2b974bdc708d2
/session-persistence-event-catalog/resolved-persistence-types/message:
- en: 56953b055da0dc2b
- zh: dcaa193ddb2fece0
+ en: 7b11edaf7aa790ae
+ zh: 514e2826b20dd7d3
/session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete:
en: c582953270d4ca44
zh: 966ccfe2e7660a2a
@@ -1137,20 +1146,20 @@
en: f58575a1fc96df42
zh: 9f0c6ed9033e5c9d
/session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating:
- en: 70ade19513d0070f
- zh: ff9acb50974a32ae
+ en: 9897f3cb7a6c7233
+ zh: 30e377f7c302a32b
/session-persistence-event-catalog/resolved-persistence-types/messagesource:
- en: b1bd7c8bd2309d45
- zh: d434e41517c3620d
+ en: 91e395f09fd30c1a
+ zh: 5efbc52f4e72e049
/session-persistence-event-catalog/resolved-persistence-types/message~2:
- en: dcd71b3190faff96
- zh: b2c07224705aa52e
+ en: 11eafb2bccde2b25
+ zh: 681c0cdb27f6a1c0
/session-persistence-event-catalog/resolved-persistence-types/modelmessagesource:
en: 5f9154ca34235ab9
zh: a833105910ebfa82
/session-persistence-event-catalog/resolved-persistence-types/modelselection:
- en: 6e301c0ea35d9e0e
- zh: 6fbf1d318db9a4e0
+ en: 970930c4a8b1bad8
+ zh: 6c596aba5e3c5020
/session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange:
en: a3300babe57b6b67
zh: 4aaf06a149b399c3
@@ -1206,11 +1215,11 @@
en: 9a54c4478ad3b620
zh: 02c4c1fd7e252770
/session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata:
- en: 46baac9dcdfb1b63
- zh: 30a357bb8405c147
+ en: c8e30c2e9886e459
+ zh: d7b53b79e0849e64
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata:
- en: 035b656ba6500174
- zh: fc9d4d7cd154ca78
+ en: aefe8cd3c2b273b2
+ zh: b2e21ce4ca05f3fb
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity:
en: 9531468264499498
zh: 9da23c0ad2776563
@@ -1317,14 +1326,14 @@
en: 778debd61e0284a2
zh: 2cc82ad9e80f18b1
/session-persistence-event-catalog/resolved-persistence-types/turnendreason:
- en: 3cbc1b5ae251451f
- zh: a95ff0555e368432
+ en: 2ef8e1ebf9e02934
+ zh: 986c170add1fb987
/session-persistence-event-catalog/resolved-persistence-types/usermessage:
- en: 1b30ae0450f156bd
- zh: 00bb0efae7ac741e
+ en: cf92679783fc00c6
+ zh: 7a6ba95081ca4ace
/session-persistence-event-catalog/resolved-persistence-types/usermessage~2:
- en: 1f242f48a3c8c230
- zh: 2516be50ce573b0c
+ en: f2f335cf1b45d3f9
+ zh: 4df02f25e9a324dd
/session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome:
en: d0a3f9aab18c3de5
zh: 76ac69a835ca4e29
@@ -1371,9 +1380,12 @@
en: f692844b29923b2e
zh: 72f12bca08d63267
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~2:
- en: 69d360cf5a1ae6b4
- zh: f23faf39a2a0e6f1
+ en: 2453bf81cd9cd1f6
+ zh: b1cef542e013c238
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~3:
+ en: 1137a9a48ddad4b3
+ zh: 222c1545cdb19d76
+/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~4:
en: 5a1af9849b0967ea
zh: 1085d793bbc47360
/session-persistence-event-catalog/resolved-persistence-types/union-3-variants:
@@ -1443,8 +1455,11 @@
en: 43cf5cd666b591a0
zh: 7c7a3011f8681f12
/session-persistence-event-catalog/resolved-persistence-types/childcreatedat-childid-label-mode~6:
- en: a1f188c9058cf621
- zh: eb43c0f045fb9afb
+ en: 959fc569699d21f0
+ zh: b48a0a3b1639957d
+/session-persistence-event-catalog/resolved-persistence-types/clienttimezone-kind-retry-principal-rpcid:
+ en: 1c523784a95eff23
+ zh: e19a26cb9ae14e12
/session-persistence-event-catalog/resolved-persistence-types/code-name-reason:
en: 25df2af19bc9c46d
zh: ebb6ecbeee25c9be
@@ -1503,11 +1518,11 @@
en: 51af619ed8532a43
zh: a5ad878c63d0e046
/session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries:
- en: 27f5b76003ac1ead
- zh: 2a5a0490cc22512b
+ en: 616424f7d89b2617
+ zh: 1a90b1c57ecb4f88
/session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn:
- en: 598021cdfe2a8687
- zh: 02e63a8b453f2f4e
+ en: bcdb5e4a5a9b0aac
+ zh: a214e6242694bfdd
/session-persistence-event-catalog/resolved-persistence-types/height-width:
en: a5df50710083ac25
zh: aad3ccf6f6de70e4
@@ -1524,11 +1539,14 @@
en: 41f73c58a77dfa28
zh: af77f4aee3cb4a40
/session-persistence-event-catalog/resolved-persistence-types/inherited:
- en: 7e64cae3bd1b71e9
- zh: 7451c0639044c59a
+ en: 7b92563703daff7b
+ zh: c91b356048b96141
+/session-persistence-event-catalog/resolved-persistence-types/inputid-source-step-turn:
+ en: 930d9c64fe39857d
+ zh: 65cd07dc3c7789db
/session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start:
- en: df2dd98c35b34bd2
- zh: ee25992a3f29d561
+ en: 4edbaf2329a66619
+ zh: 7d29428252d9d84f
/session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream:
en: 8e86cd838e3e4a49
zh: f5fec2a8adf1f3cc
@@ -1719,8 +1737,11 @@
en: 1f6dbdbf5d923df0
zh: aea576e5ec34c0fc
/session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder-form-snapshot:
- en: e9c88cceef2d8432
- zh: 14fb0897543b6960
+ en: fb3160150710d86b
+ zh: 27a71fa774531620
+/session-persistence-event-catalog/resolved-persistence-types/kind-retry:
+ en: db4c8e67a5176225
+ zh: 762719a4d2ab1f73
/session-persistence-event-catalog/resolved-persistence-types/kind-runtime-context:
en: 68b5598e21cf8ba2
zh: 95ef1685af64a5f4
@@ -1944,11 +1965,14 @@
en: 374b8adee8d571fa
zh: b21057efc58e6490
/session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected:
- en: a990181189cf77ac
- zh: 70198727356d1ad3
+ en: 7626d618ab37dec4
+ zh: 662b0be4c51feebc
/session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced:
- en: f3b491e65c79b3a0
- zh: 2d3e53ceb67a55a3
+ en: 60466c7cafae41b5
+ zh: 9dee130ddd7b0fd1
+/session-persistence-event-catalog/resolved-persistence-types/type-agent-retry:
+ en: 7a5e1c92d8760186
+ zh: ae4173989281facd
/session-persistence-event-catalog/resolved-persistence-types/type-approval-asked:
en: 9c77f18b0dad22f0
zh: 8894cf2878fe90db
@@ -1992,11 +2016,11 @@
en: ba73fb10b7e2ffc9
zh: 8d5c97e63e5f16da
/session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented:
- en: 17956f440c284ea3
- zh: 5dbfd68d50483047
+ en: 7fbea1c722f8c2f3
+ zh: d732349d783b9b01
/session-persistence-event-catalog/resolved-persistence-types/type-developer-message:
- en: ded2f3b02f776800
- zh: 4d6619c283a18e3c
+ en: f231622830903402
+ zh: 03457dc27539fafd
/session-persistence-event-catalog/resolved-persistence-types/type-disabled:
en: 585627a50ece4e5f
zh: 157837aee3da0ac3
@@ -2070,11 +2094,11 @@
en: acf7e21e4d21ebff
zh: fb83a853634d204a
/session-persistence-event-catalog/resolved-persistence-types/type-session-title:
- en: f0acf1c873cd9e2a
- zh: b8e175e14306afd4
+ en: a642705c8e44a3c9
+ zh: 10aa1aff0d3766d3
/session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request:
- en: ab4f20d890eed5e5
- zh: 6e4035a166821157
+ en: dd94dea63f118b12
+ zh: e7feae9219037e9a
/session-persistence-event-catalog/resolved-persistence-types/type-step-end:
en: 7fc233c4a569dae2
zh: bc2b3fed4a397c00
@@ -2151,11 +2175,11 @@
en: 1b045de96172be4b
zh: 8a15c6f35b576b37
/session-persistence-event-catalog/resolved-persistence-types/type-usage:
- en: 4ed36e4498f5f203
- zh: 7b3fcddec14c306b
+ en: 311b42bd63735e87
+ zh: 37ea452a672fca21
/session-persistence-event-catalog/resolved-persistence-types/type-user-message:
- en: 5b8341a673284ec1
- zh: e3264a827babffeb
+ en: 07e60b23dc23e0a3
+ zh: dc74747ec207e1bb
/session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request:
en: 99c8837344178a47
zh: bf746f5678346596
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index a498e62e2e..70e16e7ce7 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -23,7 +23,8 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) |
| `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) |
| `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) |
-| `event:agent/inbox/spliced` | event | `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30) |
+| `event:agent/inbox/spliced` | event | `a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664) |
+| `event:agent/retry` | event | `d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c` | [`{ type: "agent/retry" }`](#persistence-type-sha256-d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c) |
| `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) |
| `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) |
| `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) |
@@ -36,7 +37,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) |
| `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) |
| `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) |
-| `event:developer/message` | event | `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f` | [`{ type: "developer/message" }`](#persistence-type-sha256-95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f) |
+| `event:developer/message` | event | `92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583` | [`{ type: "developer/message" }`](#persistence-type-sha256-92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583) |
| `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) |
| `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) |
| `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) |
@@ -58,7 +59,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) |
| `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) |
| `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) |
-| `event:session/title-llm-request` | event | `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5) |
+| `event:session/title-llm-request` | event | `fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172) |
| `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) |
| `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) |
| `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) |
@@ -80,7 +81,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) |
| `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) |
| `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) |
-| `event:user/message` | event | `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f` | [`{ type: "user/message" }`](#persistence-type-sha256-39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f) |
+| `event:user/message` | event | `c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5` | [`{ type: "user/message" }`](#persistence-type-sha256-c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5) |
| `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) |
| `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) |
| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) |
@@ -185,7 +186,20 @@ Sources: [`packages/core/session/src/types.ts:439`](../packages/core/session/src
}
```
-Source: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+
+
+
+#### `agent/retry` — log-only
+
+```ts persistence-catalog
+/** An admitted retry control, correlated with its durable inbox input; never enters model history. */
+'agent/retry': { turn: number; step: number; inputId: MessageId; source: Extract }
+```
+
+Types: [MessageSource](subsystems/core.md)
+
+Source: [`packages/core/agent/src/types.ts:113`](../packages/core/agent/src/types.ts)
### `agent-preset/*`
@@ -700,7 +714,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
'model/selection': ModelSelection
```
-Source: [`packages/core/agent/src/types.ts:121`](../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:130`](../packages/core/agent/src/types.ts)
### `permission/*`
@@ -1405,6 +1419,14 @@ SHA-256: `829a2fb3be6efd4ba36aef8ed2d381a4c6fb2a0aaf50c823a6f3f8290786b809`
`"agent/inbox/spliced"`
+
+
+### `"agent/retry"`
+
+SHA-256: `30093f9f63a120cb932f07577594c04afcec3f0bd02859154a6e5b0e79a46359`
+
+`"agent/retry"`
+
### `"allowed-once"`
@@ -2427,6 +2449,14 @@ SHA-256: `34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66`
`"resume"`
+
+
+### `"retry"`
+
+SHA-256: `63ba8895cc09e4693cf0819fdcd18c084bef195febda5b6a23af279b3852be36`
+
+`"retry"`
+
### `"runtime-context"`
@@ -3375,7 +3405,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web
| `body` | required | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) |
| `endpoint` | required | `string` |
-
+
@@ -3383,7 +3413,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web
### `DeveloperMessage`
-SHA-256: `cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0`
+SHA-256: `0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc`
Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts)
@@ -3392,7 +3422,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message
| `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
| `id` | required | `string` |
| `role` | required | `"developer"` |
-| `source` | required | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) |
+| `source` | required | [`MessageSource`](#persistence-type-sha256-e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c) |
Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`.
@@ -3798,7 +3828,7 @@ Array of [`ImageOffloadTarget`](#persistence-type-sha256-1cde1bedde79c5e8a56aee9
SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
-Sources: [`packages/core/agent/src/types.ts:40`](../packages/core/agent/src/types.ts)
+Sources: [`packages/core/agent/src/types.ts:47`](../packages/core/agent/src/types.ts)
One of:
@@ -4091,7 +4121,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr
| `step` | required | `number` |
| `turn` | required | `number` |
-
+
@@ -4099,15 +4129,15 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr
### `Message`
-SHA-256: `4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d`
+SHA-256: `7cf091467eb288875ea4e88397003a7e43d95ab75afaf1b42554a8cc766799f2`
Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts)
One of:
- [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731)
-- [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0)
-- [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14)
+- [`DeveloperMessage`](#persistence-type-sha256-0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc)
+- [`UserMessage`](#persistence-type-sha256-9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263)
- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363)
- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be)
@@ -4184,7 +4214,7 @@ One of:
- `"negative"`
- `"positive"`
-
+
@@ -4192,7 +4222,7 @@ One of:
### `MessageSource`
-SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591`
+SHA-256: `e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c`
Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts)
@@ -4254,6 +4284,8 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message
| `repeat-tool-reminder` | `"relay"` | none | [`{ kind: "repeat-tool-reminder", form: "relay" }`](#persistence-type-sha256-4991dfc415c9efebc36fa6969e1798c7d2e42396cc1e6e0ac7d7c89c5c5c4aea) |
| `repeat-tool-reminder` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "repeat-tool-reminder", form: "snapshot" }`](#persistence-type-sha256-db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c) |
| `repeat-tool-reminder` | not declared | none | [`{ kind: "repeat-tool-reminder" }`](#persistence-type-sha256-b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1) |
+| `retry` | not declared | none | [`{ kind: "retry" }`](#persistence-type-sha256-414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9) |
+| `retry` | not declared | `rpcId`: `string` | [`{ clientTimeZone?, kind: "retry", principal?, rpcId }`](#persistence-type-sha256-e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664) |
| `runtime-context` | `"catalog"` | none | [`{ kind: "runtime-context", form: "catalog" }`](#persistence-type-sha256-58015da1c1950a66daf66ae24913d6a1fc6d1d9ac441b155f8847f7f8584ba8e) |
| `runtime-context` | `"instructions"` | none | [`{ kind: "runtime-context", form: "instructions" }`](#persistence-type-sha256-0eeb17e1df89b1ebc2902116fe7145e0d321ef0db26e6bdebc802cb8513a01c1) |
| `runtime-context` | `"notice"` | `summary`: `string` | [`{ kind: "runtime-context", form: "notice" }`](#persistence-type-sha256-cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc) |
@@ -4317,13 +4349,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message
| `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) |
| `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) |
-
+
### `Message[]`
-SHA-256: `d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc`
+SHA-256: `2f5b14bc2ccbe8810568df6392f1a8e0c3a2be59440051b60666266519e403ff`
-Array of [`Message`](#persistence-type-sha256-4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d).
+Array of [`Message`](#persistence-type-sha256-7cf091467eb288875ea4e88397003a7e43d95ab75afaf1b42554a8cc766799f2).
@@ -4354,7 +4386,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message.
SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
-Sources: [`packages/core/agent/src/types.ts:79`](../packages/core/agent/src/types.ts)
+Sources: [`packages/core/agent/src/types.ts:86`](../packages/core/agent/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4707,7 +4739,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/
| `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) |
| `title` | required | `string` |
-
+
@@ -4715,7 +4747,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/
### `SessionTitleLlmRequestEventData`
-SHA-256: `9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f`
+SHA-256: `8eda0195dd1d4a3fee80f5e0b9f702fdf0f342c27ece60d44d459899b6a30b60`
Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts)
@@ -4723,7 +4755,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/sess
|---|---|---|
| `maxTokens` | required | `number` |
| `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
-| `messages` | required | [`Message[]`](#persistence-type-sha256-d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc) |
+| `messages` | required | [`Message[]`](#persistence-type-sha256-2f5b14bc2ccbe8810568df6392f1a8e0c3a2be59440051b60666266519e403ff) |
| `reasoningEffort` | optional | `string` |
| `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) |
| `system` | required | `string` |
@@ -5380,7 +5412,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src
| `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) |
| `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) |
-
+
@@ -5388,7 +5420,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src
### `UserMessage`
-SHA-256: `8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14`
+SHA-256: `9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263`
Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts)
@@ -5397,19 +5429,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message
| `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
| `id` | required | `string` |
| `role` | required | `"user"` |
-| `source` | required | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) |
+| `source` | required | [`MessageSource`](#persistence-type-sha256-e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c) |
Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`.
Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`.
-
+
### `UserMessage[]`
-SHA-256: `aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292`
+SHA-256: `347c1718600bbb3814ae6b4df06e6aceae35e8a8e8cb3c39ac28b2e932c5abd6`
-Array of [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14).
+Array of [`UserMessage`](#persistence-type-sha256-9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263).
@@ -5673,6 +5705,17 @@ One of:
- `"fork"`
- `"fresh"`
+
+
+### `union (2 variants)`
+
+SHA-256: `671e72b8036a3afb018b38ca5ae9ee3aabe6a314647411655b4b9497b75010b1`
+
+| kind | Form property | Other required fields | Full definition |
+|---|---|---|---|
+| `retry` | not declared | none | [`{ kind: "retry" }`](#persistence-type-sha256-414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9) |
+| `retry` | not declared | `rpcId`: `string` | [`{ clientTimeZone?, kind: "retry", principal?, rpcId }`](#persistence-type-sha256-e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664) |
+
### `union (2 variants)`
@@ -5991,6 +6034,21 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09`
| `mode` | required | `"external"` |
| `version` | required | `2` |
+
+
+### `{ clientTimeZone?, kind: "retry", principal?, rpcId }`
+
+SHA-256: `e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664`
+
+Sources: [`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts)
+
+| Property | Presence | Type |
+|---|---|---|
+| `clientTimeZone` | optional | `string` |
+| `kind` | required | `"retry"` |
+| `principal` | optional | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) |
+| `rpcId` | required | `string` |
+
### `{ code, name, reason? }`
@@ -6294,18 +6352,18 @@ Sources: [`packages/core/session/src/types.ts:398`](../packages/core/session/src
| `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) |
| `startsSeries` | optional | `true` |
-
+
### `{ headerSeq?, message, step, turn }`
-SHA-256: `12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2`
+SHA-256: `d582ddae3f21edf55b6f50454e3015ef78ed3788fee4670827f2c3c4bd0b47e2`
Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
| `headerSeq` | optional | `number` |
-| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0) |
+| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc) |
| `step` | required | `number` |
| `turn` | required | `number` |
@@ -6389,17 +6447,32 @@ Sources: [`packages/core/session/src/types.ts:435`](../packages/core/session/src
|---|---|---|
| `inherited` | optional | `true` |
-
+
-### `{ inserted, outcome?, removedCount?, start, … }`
+### `{ inputId, source, step, turn }`
-SHA-256: `c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c`
+SHA-256: `2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3`
-Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts)
+Sources: [`packages/core/agent/src/types.ts:113`](../packages/core/agent/src/types.ts)
| Property | Presence | Type |
|---|---|---|
-| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292) |
+| `inputId` | required | `string` |
+| `source` | required | [`union (2 variants)`](#persistence-type-sha256-671e72b8036a3afb018b38ca5ae9ee3aabe6a314647411655b4b9497b75010b1) |
+| `step` | required | `number` |
+| `turn` | required | `number` |
+
+
+
+### `{ inserted, outcome?, removedCount?, start, … }`
+
+SHA-256: `c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039`
+
+Sources: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+
+| Property | Presence | Type |
+|---|---|---|
+| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-347c1718600bbb3814ae6b4df06e6aceae35e8a8e8cb3c39ac28b2e932c5abd6) |
| `outcome` | optional | `"canceled"` |
| `removedCount` | optional | `number` |
| `start` | required | `number` |
@@ -7147,6 +7220,18 @@ SHA-256: `db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c`
| `kind` | required | `"repeat-tool-reminder"` |
| `sections` | required | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) |
+
+
+### `{ kind: "retry" }`
+
+SHA-256: `414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9`
+
+Sources: [`packages/core/agent/src/types.ts:24`](../packages/core/agent/src/types.ts)
+
+| Property | Presence | Type |
+|---|---|---|
+| `kind` | required | `"retry"` |
+
### `{ kind: "runtime-context" }`
@@ -8046,22 +8131,38 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b`
| `time` | required | `number` |
| `type` | required | `"agent-preset/selected"` |
-
+
### `{ type: "agent/inbox/spliced" }`
-SHA-256: `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30`
+SHA-256: `a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664`
| Property | Presence | Type |
|---|---|---|
-| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c) |
+| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `time` | required | `number` |
| `type` | required | `"agent/inbox/spliced"` |
+
+
+
+
+### `{ type: "agent/retry" }`
+
+SHA-256: `d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c`
+
+| Property | Presence | Type |
+|---|---|---|
+| `data` | required | [`{ inputId, source, step, turn }`](#persistence-type-sha256-2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3) |
+| `ignorable` | optional | `true` |
+| `seq` | required | `number` |
+| `time` | required | `number` |
+| `type` | required | `"agent/retry"` |
+
@@ -8297,17 +8398,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176`
| `time` | required | `number` |
| `type` | required | `"deliverables/presented"` |
-
+
### `{ type: "developer/message" }`
-SHA-256: `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f`
+SHA-256: `92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583`
| Property | Presence | Type |
|---|---|---|
-| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2) |
+| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-d582ddae3f21edf55b6f50454e3015ef78ed3788fee4670827f2c3c4bd0b47e2) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
@@ -8707,17 +8808,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5`
| `time` | required | `number` |
| `type` | required | `"session/title"` |
-
+
### `{ type: "session/title-llm-request" }`
-SHA-256: `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5`
+SHA-256: `fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172`
| Property | Presence | Type |
|---|---|---|
-| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f) |
+| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-8eda0195dd1d4a3fee80f5e0b9f702fdf0f342c27ece60d44d459899b6a30b60) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `time` | required | `number` |
@@ -9140,17 +9241,17 @@ Sources: [`packages/llm/llm/src/types.ts:476`](../packages/llm/llm/src/types.ts)
| `type` | required | `"usage"` |
| `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) |
-
+
### `{ type: "user/message" }`
-SHA-256: `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f`
+SHA-256: `c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5`
| Property | Presence | Type |
|---|---|---|
-| `data` | required | [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) |
+| `data` | required | [`UserMessage`](#persistence-type-sha256-9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index d75ff6f2b1..47f1c30ed9 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -25,7 +25,8 @@
| `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) |
| `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) |
| `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) |
-| `event:agent/inbox/spliced` | event | `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30) |
+| `event:agent/inbox/spliced` | event | `a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664) |
+| `event:agent/retry` | event | `d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c` | [`{ type: "agent/retry" }`](#persistence-type-sha256-d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c) |
| `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) |
| `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) |
| `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) |
@@ -38,7 +39,7 @@
| `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) |
| `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) |
| `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) |
-| `event:developer/message` | event | `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f` | [`{ type: "developer/message" }`](#persistence-type-sha256-95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f) |
+| `event:developer/message` | event | `92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583` | [`{ type: "developer/message" }`](#persistence-type-sha256-92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583) |
| `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) |
| `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) |
| `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) |
@@ -60,7 +61,7 @@
| `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) |
| `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) |
| `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) |
-| `event:session/title-llm-request` | event | `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5) |
+| `event:session/title-llm-request` | event | `fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172) |
| `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) |
| `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) |
| `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) |
@@ -82,7 +83,7 @@
| `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) |
| `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) |
| `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) |
-| `event:user/message` | event | `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f` | [`{ type: "user/message" }`](#persistence-type-sha256-39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f) |
+| `event:user/message` | event | `c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5` | [`{ type: "user/message" }`](#persistence-type-sha256-c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5) |
| `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) |
| `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) |
| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) |
@@ -187,7 +188,20 @@ export type SessionEvent = {
}
```
-来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+
+
+
+#### `agent/retry` — log-only
+
+```ts persistence-catalog
+/** An admitted retry control, correlated with its durable inbox input; never enters model history. */
+'agent/retry': { turn: number; step: number; inputId: MessageId; source: Extract }
+```
+
+类型:[MessageSource](subsystems/core.zh.md)
+
+来源:[`packages/core/agent/src/types.ts:113`](../packages/core/agent/src/types.ts)
### `agent-preset/*`
@@ -702,7 +716,7 @@ export type SessionEvent = {
'model/selection': ModelSelection
```
-来源:[`packages/core/agent/src/types.ts:121`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:130`](../packages/core/agent/src/types.ts)
### `permission/*`
@@ -1407,6 +1421,14 @@ SHA-256: `829a2fb3be6efd4ba36aef8ed2d381a4c6fb2a0aaf50c823a6f3f8290786b809`
`"agent/inbox/spliced"`
+
+
+### `"agent/retry"`
+
+SHA-256: `30093f9f63a120cb932f07577594c04afcec3f0bd02859154a6e5b0e79a46359`
+
+`"agent/retry"`
+
### `"allowed-once"`
@@ -2429,6 +2451,14 @@ SHA-256: `34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66`
`"resume"`
+
+
+### `"retry"`
+
+SHA-256: `63ba8895cc09e4693cf0819fdcd18c084bef195febda5b6a23af279b3852be36`
+
+`"retry"`
+
### `"runtime-context"`
@@ -3377,7 +3407,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c`
| `body` | 必需 | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) |
| `endpoint` | 必需 | `string` |
-
+
@@ -3385,7 +3415,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c`
### `DeveloperMessage`
-SHA-256: `cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0`
+SHA-256: `0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc`
来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts)
@@ -3394,7 +3424,7 @@ SHA-256: `cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0`
| `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
| `id` | 必需 | `string` |
| `role` | 必需 | `"developer"` |
-| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) |
+| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c) |
消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`.
@@ -3800,7 +3830,7 @@ SHA-256: `9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb`
SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
-来源:[`packages/core/agent/src/types.ts:40`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:47`](../packages/core/agent/src/types.ts)
以下类型之一:
@@ -4093,7 +4123,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20`
| `step` | 必需 | `number` |
| `turn` | 必需 | `number` |
-
+
@@ -4101,15 +4131,15 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20`
### `Message`
-SHA-256: `4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d`
+SHA-256: `7cf091467eb288875ea4e88397003a7e43d95ab75afaf1b42554a8cc766799f2`
来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts)
以下类型之一:
- [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731)
-- [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0)
-- [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14)
+- [`DeveloperMessage`](#persistence-type-sha256-0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc)
+- [`UserMessage`](#persistence-type-sha256-9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263)
- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363)
- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be)
@@ -4186,7 +4216,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2`
- `"negative"`
- `"positive"`
-
+
@@ -4194,7 +4224,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2`
### `MessageSource`
-SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591`
+SHA-256: `e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c`
来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts)
@@ -4256,6 +4286,8 @@ SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591`
| `repeat-tool-reminder` | `"relay"` | 无 | [`{ kind: "repeat-tool-reminder", form: "relay" }`](#persistence-type-sha256-4991dfc415c9efebc36fa6969e1798c7d2e42396cc1e6e0ac7d7c89c5c5c4aea) |
| `repeat-tool-reminder` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "repeat-tool-reminder", form: "snapshot" }`](#persistence-type-sha256-db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c) |
| `repeat-tool-reminder` | 未声明 | 无 | [`{ kind: "repeat-tool-reminder" }`](#persistence-type-sha256-b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1) |
+| `retry` | 未声明 | 无 | [`{ kind: "retry" }`](#persistence-type-sha256-414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9) |
+| `retry` | 未声明 | `rpcId`: `string` | [`{ clientTimeZone?, kind: "retry", principal?, rpcId }`](#persistence-type-sha256-e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664) |
| `runtime-context` | `"catalog"` | 无 | [`{ kind: "runtime-context", form: "catalog" }`](#persistence-type-sha256-58015da1c1950a66daf66ae24913d6a1fc6d1d9ac441b155f8847f7f8584ba8e) |
| `runtime-context` | `"instructions"` | 无 | [`{ kind: "runtime-context", form: "instructions" }`](#persistence-type-sha256-0eeb17e1df89b1ebc2902116fe7145e0d321ef0db26e6bdebc802cb8513a01c1) |
| `runtime-context` | `"notice"` | `summary`: `string` | [`{ kind: "runtime-context", form: "notice" }`](#persistence-type-sha256-cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc) |
@@ -4319,13 +4351,13 @@ SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591`
| `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) |
| `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) |
-
+
### `Message[]`
-SHA-256: `d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc`
+SHA-256: `2f5b14bc2ccbe8810568df6392f1a8e0c3a2be59440051b60666266519e403ff`
-[`Message`](#persistence-type-sha256-4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d) 的数组。
+[`Message`](#persistence-type-sha256-7cf091467eb288875ea4e88397003a7e43d95ab75afaf1b42554a8cc766799f2) 的数组。
@@ -4356,7 +4388,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91`
SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
-来源:[`packages/core/agent/src/types.ts:79`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:86`](../packages/core/agent/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4709,7 +4741,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4`
| `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) |
| `title` | 必需 | `string` |
-
+
@@ -4717,7 +4749,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4`
### `SessionTitleLlmRequestEventData`
-SHA-256: `9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f`
+SHA-256: `8eda0195dd1d4a3fee80f5e0b9f702fdf0f342c27ece60d44d459899b6a30b60`
来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts)
@@ -4725,7 +4757,7 @@ SHA-256: `9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f`
|---|---|---|
| `maxTokens` | 必需 | `number` |
| `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
-| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc) |
+| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-2f5b14bc2ccbe8810568df6392f1a8e0c3a2be59440051b60666266519e403ff) |
| `reasoningEffort` | 可选 | `string` |
| `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) |
| `system` | 必需 | `string` |
@@ -5382,7 +5414,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
| `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) |
| `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) |
-
+
@@ -5390,7 +5422,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
### `UserMessage`
-SHA-256: `8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14`
+SHA-256: `9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263`
来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts)
@@ -5399,19 +5431,19 @@ SHA-256: `8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14`
| `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
| `id` | 必需 | `string` |
| `role` | 必需 | `"user"` |
-| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) |
+| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c) |
消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`.
仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`.
-
+
### `UserMessage[]`
-SHA-256: `aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292`
+SHA-256: `347c1718600bbb3814ae6b4df06e6aceae35e8a8e8cb3c39ac28b2e932c5abd6`
-[`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) 的数组。
+[`UserMessage`](#persistence-type-sha256-9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263) 的数组。
@@ -5675,6 +5707,17 @@ SHA-256: `50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48`
- `"fork"`
- `"fresh"`
+
+
+### `union (2 variants)`
+
+SHA-256: `671e72b8036a3afb018b38ca5ae9ee3aabe6a314647411655b4b9497b75010b1`
+
+| kind | form 属性 | 其他必需字段 | 完整定义 |
+|---|---|---|---|
+| `retry` | 未声明 | 无 | [`{ kind: "retry" }`](#persistence-type-sha256-414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9) |
+| `retry` | 未声明 | `rpcId`: `string` | [`{ clientTimeZone?, kind: "retry", principal?, rpcId }`](#persistence-type-sha256-e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664) |
+
### `union (2 variants)`
@@ -5993,6 +6036,21 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09`
| `mode` | 必需 | `"external"` |
| `version` | 必需 | `2` |
+
+
+### `{ clientTimeZone?, kind: "retry", principal?, rpcId }`
+
+SHA-256: `e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664`
+
+来源:[`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts)
+
+| 属性 | 存在性 | 类型 |
+|---|---|---|
+| `clientTimeZone` | 可选 | `string` |
+| `kind` | 必需 | `"retry"` |
+| `principal` | 可选 | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) |
+| `rpcId` | 必需 | `string` |
+
### `{ code, name, reason? }`
@@ -6296,18 +6354,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585`
| `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) |
| `startsSeries` | 可选 | `true` |
-
+
### `{ headerSeq?, message, step, turn }`
-SHA-256: `12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2`
+SHA-256: `d582ddae3f21edf55b6f50454e3015ef78ed3788fee4670827f2c3c4bd0b47e2`
来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
| `headerSeq` | 可选 | `number` |
-| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0) |
+| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc) |
| `step` | 必需 | `number` |
| `turn` | 必需 | `number` |
@@ -6391,17 +6449,32 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3`
|---|---|---|
| `inherited` | 可选 | `true` |
-
+
-### `{ inserted, outcome?, removedCount?, start, … }`
+### `{ inputId, source, step, turn }`
-SHA-256: `c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c`
+SHA-256: `2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3`
-来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:113`](../packages/core/agent/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
-| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292) |
+| `inputId` | 必需 | `string` |
+| `source` | 必需 | [`union (2 variants)`](#persistence-type-sha256-671e72b8036a3afb018b38ca5ae9ee3aabe6a314647411655b4b9497b75010b1) |
+| `step` | 必需 | `number` |
+| `turn` | 必需 | `number` |
+
+
+
+### `{ inserted, outcome?, removedCount?, start, … }`
+
+SHA-256: `c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039`
+
+来源:[`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+
+| 属性 | 存在性 | 类型 |
+|---|---|---|
+| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-347c1718600bbb3814ae6b4df06e6aceae35e8a8e8cb3c39ac28b2e932c5abd6) |
| `outcome` | 可选 | `"canceled"` |
| `removedCount` | 可选 | `number` |
| `start` | 必需 | `number` |
@@ -7149,6 +7222,18 @@ SHA-256: `db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c`
| `kind` | 必需 | `"repeat-tool-reminder"` |
| `sections` | 必需 | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) |
+
+
+### `{ kind: "retry" }`
+
+SHA-256: `414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9`
+
+来源:[`packages/core/agent/src/types.ts:24`](../packages/core/agent/src/types.ts)
+
+| 属性 | 存在性 | 类型 |
+|---|---|---|
+| `kind` | 必需 | `"retry"` |
+
### `{ kind: "runtime-context" }`
@@ -8048,22 +8133,38 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b`
| `time` | 必需 | `number` |
| `type` | 必需 | `"agent-preset/selected"` |
-
+
### `{ type: "agent/inbox/spliced" }`
-SHA-256: `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30`
+SHA-256: `a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664`
| 属性 | 存在性 | 类型 |
|---|---|---|
-| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c) |
+| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `time` | 必需 | `number` |
| `type` | 必需 | `"agent/inbox/spliced"` |
+
+
+
+
+### `{ type: "agent/retry" }`
+
+SHA-256: `d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c`
+
+| 属性 | 存在性 | 类型 |
+|---|---|---|
+| `data` | 必需 | [`{ inputId, source, step, turn }`](#persistence-type-sha256-2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3) |
+| `ignorable` | 可选 | `true` |
+| `seq` | 必需 | `number` |
+| `time` | 必需 | `number` |
+| `type` | 必需 | `"agent/retry"` |
+
@@ -8299,17 +8400,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176`
| `time` | 必需 | `number` |
| `type` | 必需 | `"deliverables/presented"` |
-
+
### `{ type: "developer/message" }`
-SHA-256: `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f`
+SHA-256: `92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583`
| 属性 | 存在性 | 类型 |
|---|---|---|
-| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2) |
+| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-d582ddae3f21edf55b6f50454e3015ef78ed3788fee4670827f2c3c4bd0b47e2) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
@@ -8709,17 +8810,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5`
| `time` | 必需 | `number` |
| `type` | 必需 | `"session/title"` |
-
+
### `{ type: "session/title-llm-request" }`
-SHA-256: `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5`
+SHA-256: `fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172`
| 属性 | 存在性 | 类型 |
|---|---|---|
-| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f) |
+| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-8eda0195dd1d4a3fee80f5e0b9f702fdf0f342c27ece60d44d459899b6a30b60) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `time` | 必需 | `number` |
@@ -9142,17 +9243,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
| `type` | 必需 | `"usage"` |
| `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) |
-
+
### `{ type: "user/message" }`
-SHA-256: `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f`
+SHA-256: `c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5`
| 属性 | 存在性 | 类型 |
|---|---|---|
-| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) |
+| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml b/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml
new file mode 100644
index 0000000000..3513ba5aaa
--- /dev/null
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml
@@ -0,0 +1,28 @@
+# Bilingual-pair consistency record for 2026-10-27-retry-control-input.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-27-retry-control-input.md
+/:
+ en: 3d5a424ae7d7e46b
+ zh: c22a8e4a62d8cc1a
+/2026-10-27-retry-control-input:
+ en: 32c7e9cc4a9f86b4
+ zh: f1cb7169df15e8e4
+/2026-10-27-retry-control-input/summary:
+ en: e3a11c473fcdc452
+ zh: 02ce33e05c10de71
+/2026-10-27-retry-control-input/table-of-contents:
+ en: 6cf314a069ceb451
+ zh: 9adff678e9ecab3b
+/2026-10-27-retry-control-input/declaration:
+ en: 6907136f28e48c90
+ zh: 02f8298485f3d8e7
+/2026-10-27-retry-control-input/compatibility:
+ en: 4487a595853a65d6
+ zh: d9b356c77ccab559
+/2026-10-27-retry-control-input/verification:
+ en: da37e5d4769c40fe
+ zh: 992f66b1a2c66fb2
+/2026-10-27-retry-control-input/dev-note:
+ en: b75c8458928b72b9
+ zh: 003ba4e17b4b5269
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.md b/docs/persistence-changes/2026-10-27-retry-control-input.md
new file mode 100644
index 0000000000..3faecc4503
--- /dev/null
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.md
@@ -0,0 +1,64 @@
+---
+description: "Records a persistence type transition and its compatibility acknowledgement."
+kind: persistence-change
+---
+
+# 2026-10-27-retry-control-input
+
+English | [中文](2026-10-27-retry-control-input.zh.md)
+
+## Summary
+
+Add an explicit retry control source and the required agent/retry acceptance event, correlated with durable inbox identity, turn, and step.
+
+## Table of Contents
+
+- [Declaration](#declaration)
+- [Compatibility](#compatibility)
+- [Verification](#verification)
+- [Dev Note](#dev-note)
+
+
+## Declaration
+
+```yaml persistence-change
+schemaVersion: 1
+id: 2026-10-27-retry-control-input
+baseline: false
+changes:
+ - root: "event:agent/inbox/spliced"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664"
+ decision: same-version
+ - root: "event:agent/retry"
+ previous: null
+ after: "d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c"
+ decision: same-version
+ - root: "event:developer/message"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583"
+ decision: same-version
+ - root: "event:session/title-llm-request"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172"
+ decision: same-version
+ - root: "event:user/message"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5"
+ decision: same-version
+```
+
+
+## Compatibility
+
+Updated readers continue accepting older logs with no retry controls. Older readers reject the unknown required agent/retry event before treating a retry as ordinary user content. The retry discriminator also distinguishes queued controls from ordinary empty user messages. API and both SDK consumers are updated together; the current Session format version is retained because event and source discriminators prevent unsafe interpretation.
+
+
+## Verification
+
+Source review confirms controls retain existing inbox and turn lifecycle while agent/retry has no model-surface operation. Regression coverage covers repeated retry, input-id correlation, balanced turns, ordinary empty messages, non-empty retry rejection, deduplication, checkpoint replay, browser behavior, and both SDK recovery recordings. Execution is pending post-push validation.
+
+
+## Dev Note
+
+None.
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.schema.json b/docs/persistence-changes/2026-10-27-retry-control-input.schema.json
new file mode 100644
index 0000000000..54eea6ef36
--- /dev/null
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.schema.json
@@ -0,0 +1,257 @@
+{
+ "formatVersion": 2,
+ "roots": [
+ {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664"},
+ {"key":"event:agent/retry","kind":"event","event":"agent/retry","surface":false,"digest":"d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c"},
+ {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583"},
+ {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172"},
+ {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5"}
+ ],
+ "types": [],
+ "nodes": {
+ "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
+ "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
+ "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]},
+ "05205074b24d8340": {"kind":"literal","value":"action-cards"},
+ "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"},
+ "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"},
+ "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]},
+ "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
+ "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
+ "0ef3072084202b08": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"e3e9f4d1e245b470","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
+ "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "0fe52387f6505270": {"kind":"literal","value":"plan-mode"},
+ "11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]},
+ "1379f0b72275994d": {"kind":"literal","value":"developer/message"},
+ "16d0507cb94b4cfc": {"kind":"literal","value":"relay"},
+ "1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"},
+ "1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
+ "1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
+ "1f0133217eeeffd8": {"kind":"literal","value":"tool-call"},
+ "1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"},
+ "1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
+ "2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]},
+ "224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
+ "23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"},
+ "24b10f7cdae64928": {"kind":"literal","value":"runtime-context"},
+ "24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
+ "260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
+ "2765e9119edf5816": {"kind":"literal","value":"model"},
+ "277b61f9c6f81704": {"kind":"literal","value":"file"},
+ "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
+ "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
+ "2a668a48dec17397": {"kind":"object","properties":[{"name":"inputId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"671e72b8036a3afb","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"},
+ "2e4debca12f2c449": {"kind":"literal","value":"team-message"},
+ "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]},
+ "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"},
+ "2f5b14bc2ccbe881": {"kind":"array","element":"7cf091467eb28887"},
+ "30093f9f63a120cb": {"kind":"literal","value":"agent/retry"},
+ "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]},
+ "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]},
+ "347c1718600bbb38": {"kind":"array","element":"9632024d168f8169"},
+ "383cf73ae4748e5b": {"kind":"literal","value":"recall"},
+ "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
+ "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
+ "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]},
+ "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"},
+ "3e2ece4967cfebe9": {"kind":"literal","value":"next-step"},
+ "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"},
+ "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
+ "414a44a96f06339f": {"kind":"object","properties":[{"name":"kind","type":"63ba8895cc09e469","optional":false}],"indices":[]},
+ "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]},
+ "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
+ "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"},
+ "4e44c9118152f682": {"kind":"literal","value":"text"},
+ "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]},
+ "4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"},
+ "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]},
+ "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
+ "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
+ "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"},
+ "589d0d91239681f8": {"kind":"literal","value":"developer"},
+ "58f94390b982f8a5": {"kind":"literal","value":"image/png"},
+ "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
+ "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
+ "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"},
+ "5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
+ "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
+ "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
+ "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"},
+ "63ba8895cc09e469": {"kind":"literal","value":"retry"},
+ "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]},
+ "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]},
+ "671e72b8036a3afb": {"kind":"union","types":["414a44a96f06339f","e103228e063b4762"]},
+ "672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "68f44f59e91c4139": {"kind":"literal","value":"user"},
+ "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"},
+ "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "6dacb1584f2bfbe2": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"deebfc7c62177ea8","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
+ "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"},
+ "6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]},
+ "6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
+ "6f9e00383836275e": {"kind":"literal","value":"snapshot"},
+ "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]},
+ "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"},
+ "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
+ "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
+ "78a9be340090b735": {"kind":"literal","value":"tmux-context"},
+ "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"},
+ "7cf091467eb28887": {"kind":"union","types":["09f3f8dc417f2782","0ef3072084202b08","4b259f7f8f0a44ad","9632024d168f8169","fa87537f4a9c1e25"]},
+ "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
+ "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
+ "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"},
+ "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]},
+ "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
+ "8067b0d232c350f1": {"kind":"literal","value":"time-context"},
+ "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]},
+ "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"},
+ "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
+ "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"},
+ "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "83d6916021eede8f": {"kind":"literal","value":"tool-removal"},
+ "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "85d6d96ff8842974": {"kind":"literal","value":"answered"},
+ "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"},
+ "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
+ "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
+ "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
+ "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
+ "8af841fc9b265ade": {"kind":"literal","value":"system"},
+ "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]},
+ "8eda0195dd1d4a3f": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"2f5b14bc2ccbe881","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
+ "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]},
+ "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]},
+ "92b82e8b7bbb38b3": {"kind":"object","properties":[{"name":"data","type":"d582ddae3f21edf5","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]},
+ "938f7c7357bc403e": {"kind":"primitive","type":"null"},
+ "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"},
+ "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"},
+ "9632024d168f8169": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"e3e9f4d1e245b470","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
+ "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"},
+ "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
+ "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]},
+ "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
+ "99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"},
+ "99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
+ "9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"},
+ "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
+ "a13d8f379222ece8": {"kind":"literal","value":"tool"},
+ "a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
+ "a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
+ "a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
+ "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "a6d8c25f4b2ca2d1": {"kind":"object","properties":[{"name":"data","type":"c66c6c3b411abf46","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]},
+ "a720757f704a2512": {"kind":"literal","value":"tool-registry"},
+ "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"},
+ "aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
+ "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"},
+ "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
+ "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"},
+ "ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]},
+ "ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
+ "aec150797b655c5f": {"kind":"literal","value":"catalog"},
+ "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
+ "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
+ "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
+ "b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"},
+ "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]},
+ "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
+ "b765ee31033cea46": {"kind":"literal","value":"set"},
+ "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
+ "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"},
+ "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"},
+ "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"},
+ "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"},
+ "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]},
+ "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]},
+ "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"},
+ "c5bbcb3435908af2": {"kind":"object","properties":[{"name":"data","type":"9632024d168f8169","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]},
+ "c66c6c3b411abf46": {"kind":"object","properties":[{"name":"inserted","type":"347c1718600bbb38","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]},
+ "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"},
+ "cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
+ "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"},
+ "d582ddae3f21edf5": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"0ef3072084202b08","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
+ "d8dde9629b7323b0": {"kind":"object","properties":[{"name":"data","type":"2a668a48dec17397","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"30093f9f63a120cb","optional":false}],"indices":[]},
+ "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]},
+ "d9edf1862e4471d6": {"kind":"literal","value":"schedule"},
+ "dacf3d5f3e4ff619": {"kind":"literal","value":true},
+ "db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "db6c5246911d47a7": {"kind":"literal","value":"webhook"},
+ "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]},
+ "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
+ "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]},
+ "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "de25352d2dc41963": {"kind":"literal","value":"user/message"},
+ "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"},
+ "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
+ "deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]},
+ "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"},
+ "e103228e063b4762": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"63ba8895cc09e469","optional":false},{"name":"principal","type":"deebfc7c62177ea8","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "e3e9f4d1e245b470": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","414a44a96f06339f","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e103228e063b4762","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]},
+ "e438a9d4fb883b3e": {"kind":"literal","value":"append"},
+ "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
+ "e8de0f457fed5b9f": {"kind":"literal","value":"notice"},
+ "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"},
+ "edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
+ "f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]},
+ "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]},
+ "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
+ "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]},
+ "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
+ "f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
+ "f68c335306535a00": {"kind":"literal","value":"user-approval"},
+ "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]},
+ "f6cb462f808309e3": {"kind":"literal","value":"replace"},
+ "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
+ "f88f1b7b6bb733f6": {"kind":"literal","value":1},
+ "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
+ "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]},
+ "fb28ad7d7aabceb5": {"kind":"object","properties":[{"name":"data","type":"8eda0195dd1d4a3f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]},
+ "fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"},
+ "fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"},
+ "fcf427290c6213c2": {"kind":"primitive","type":"never"},
+ "fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
+ "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"}
+ }
+}
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.zh.md b/docs/persistence-changes/2026-10-27-retry-control-input.zh.md
new file mode 100644
index 0000000000..672f1d032c
--- /dev/null
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.zh.md
@@ -0,0 +1,64 @@
+---
+description: "记录持久化类型更改及其兼容性确认。"
+kind: persistence-change
+---
+
+# 2026-10-27-retry-control-input
+
+[English](2026-10-27-retry-control-input.md) | 中文
+
+## 概述
+
+新增显式 retry 控制来源与必读 agent/retry 接受事件,关联持久 inbox 身份、轮次和步骤。
+
+## 目录
+
+- [声明](#declaration)
+- [兼容性](#compatibility)
+- [验证](#verification)
+- [开发备注](#dev-note)
+
+
+## 声明
+
+```yaml persistence-change
+schemaVersion: 1
+id: 2026-10-27-retry-control-input
+baseline: false
+changes:
+ - root: "event:agent/inbox/spliced"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664"
+ decision: same-version
+ - root: "event:agent/retry"
+ previous: null
+ after: "d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c"
+ decision: same-version
+ - root: "event:developer/message"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583"
+ decision: same-version
+ - root: "event:session/title-llm-request"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172"
+ decision: same-version
+ - root: "event:user/message"
+ previous: "2026-10-25-skill-catalog-incomplete"
+ after: "c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5"
+ decision: same-version
+```
+
+
+## 兼容性
+
+更新后的读取方继续接受没有重试控制输入的旧日志。旧读取方会拒绝未知必读 agent/retry 事件,不会将重试当作普通用户内容。retry 判别标签也将排队控制输入与普通空用户消息区分开。API 与两套 SDK 消费方同步更新;事件及来源判别标签可阻止不安全解释,因此保留当前 Session 格式版本。
+
+
+## 验证
+
+源码审查确认控制输入保留现有 inbox 和轮次生命周期,agent/retry 不携带模型表面操作。回归用例覆盖连续重试、输入身份关联、轮次闭合、普通空消息、拒绝非空重试、去重、检查点回放、浏览器行为及两套 SDK 恢复录制。执行验证将在 push 后进行。
+
+
+## 开发备注
+
+无。
diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json
index 70f64325e4..07e2ce9415 100644
--- a/docs/persistence-schema.json
+++ b/docs/persistence-schema.json
@@ -5,7 +5,8 @@
{"key":"JsonlHeaderLine","kind":"header","digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"},
{"key":"SessionEventEnvelope","kind":"envelope","digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"},
{"key":"event:agent-preset/selected","kind":"event","event":"agent-preset/selected","surface":false,"digest":"a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"},
- {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30"},
+ {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664"},
+ {"key":"event:agent/retry","kind":"event","event":"agent/retry","surface":false,"digest":"d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c"},
{"key":"event:approval/asked","kind":"event","event":"approval/asked","surface":false,"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"},
{"key":"event:approval/decided","kind":"event","event":"approval/decided","surface":false,"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"},
{"key":"event:approval/policy","kind":"event","event":"approval/policy","surface":false,"digest":"26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"},
@@ -18,7 +19,7 @@
{"key":"event:compaction/start","kind":"event","event":"compaction/start","surface":false,"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"},
{"key":"event:compaction/summary","kind":"event","event":"compaction/summary","surface":false,"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"},
{"key":"event:deliverables/presented","kind":"event","event":"deliverables/presented","surface":false,"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"},
- {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f"},
+ {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583"},
{"key":"event:feedback/message-delete","kind":"event","event":"feedback/message-delete","surface":false,"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"},
{"key":"event:feedback/message-put","kind":"event","event":"feedback/message-put","surface":false,"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"},
{"key":"event:feedback/record","kind":"event","event":"feedback/record","surface":false,"digest":"b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"},
@@ -40,7 +41,7 @@
{"key":"event:session-log-deepseek/delivery-accepted","kind":"event","event":"session-log-deepseek/delivery-accepted","surface":false,"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"},
{"key":"event:session/end-seed","kind":"event","event":"session/end-seed","surface":false,"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"},
{"key":"event:session/title","kind":"event","event":"session/title","surface":false,"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"},
- {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5"},
+ {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172"},
{"key":"event:step/end","kind":"event","event":"step/end","surface":false,"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"},
{"key":"event:step/start","kind":"event","event":"step/start","surface":false,"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"},
{"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac"},
@@ -62,7 +63,7 @@
{"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"},
{"key":"event:turn/end","kind":"event","event":"turn/end","surface":false,"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"},
{"key":"event:turn/start","kind":"event","event":"turn/start","surface":false,"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"},
- {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f"},
+ {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5"},
{"key":"event:web/deepseek-search-llm-request","kind":"event","event":"web/deepseek-search-llm-request","surface":false,"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52"},
{"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"},
{"key":"event:workspace/changes","kind":"event","event":"workspace/changes","surface":false,"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"}
@@ -98,6 +99,7 @@
{"digest":"0e7b2a97f54f1afdb8cdfad1635abe9145327db539bfb69b4045c19d8c17755d","names":[],"sources":[]},
{"digest":"0ed8d1108fe7a5e1c3078ef1faea12199a56b6b1216ccaceb0f6103b0c9a7691","names":[],"sources":[]},
{"digest":"0eeb17e1df89b1ebc2902116fe7145e0d321ef0db26e6bdebc802cb8513a01c1","names":[],"sources":[]},
+ {"digest":"0ef3072084202b084b2a25678fe9014ec339ee3797bc0bb164538ff3fa5e3fcc","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]},
{"digest":"0f46153645c297846a3fad3911636659ca4717a5e7d71c9f50bffacd312115ef","names":[],"sources":["packages/attachment/attachment/src/types.ts:28"]},
{"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce","names":[],"sources":[]},
{"digest":"0fe52387f65052702ed9d7cd59db826468936595d1b3eec207e7a823adc719db","names":[],"sources":[]},
@@ -109,7 +111,6 @@
{"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3","names":[],"sources":[]},
{"digest":"12a0cfa619ac4a5fc6c3e8e359c8dae15769edc61497a575935c78d5842f8e42","names":[],"sources":[]},
{"digest":"12d5808fac1996b9b3c2afa806e03022252ba2d7b88f835862ae1896591f60c7","names":["packages/llm/llm/src/types.ts#ImageBlock"],"sources":["packages/llm/llm/src/types.ts:79"]},
- {"digest":"12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2","names":[],"sources":["packages/core/session/src/types.ts:317"]},
{"digest":"1379f0b72275994d20f1ddca2d60a96ab16ec80397ca9f1af21bdc67ba5c269c","names":[],"sources":[]},
{"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176","names":[],"sources":[]},
{"digest":"14230f9b8b5797bb7e8c621fa636531cfbde2ffba1ef5f1fd058216ff21952a0","names":[],"sources":[]},
@@ -173,6 +174,7 @@
{"digest":"28936c614bd4e7e2774806edcbb3cbfd3ee643ac1cbf0bbdff43d915505a8fe3","names":[],"sources":[]},
{"digest":"290f32bfd81ff85a8095886a8066fae06e885942b63a8bcd6a8d66d328445808","names":[],"sources":[]},
{"digest":"297fbd8067d7815adae02b89790ec6ff5fb7c2cb0d0eb026e1746c24ec857761","names":[],"sources":[]},
+ {"digest":"2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3","names":[],"sources":["packages/core/agent/src/types.ts:113"]},
{"digest":"2aad1179f957631477b582e57cd8f538cb82618fc3fb816bde1171154235b065","names":[],"sources":[]},
{"digest":"2ab12a0e345658f9342816d773fe610f049982bcad82631f43b9205abe43e5ee","names":[],"sources":[]},
{"digest":"2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095","names":[],"sources":["packages/llm/llm/src/types.ts:473"]},
@@ -186,7 +188,9 @@
{"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:129"]},
{"digest":"2f1e6a7ebace43d727ae138ddcd352eb3813960009e5805b8c5ac84300c97716","names":["packages/attachment/attachment/src/types.ts#ImageMediaType"],"sources":["packages/attachment/attachment/src/types.ts:8"]},
{"digest":"2f3f201c8846e19c20bb75c02280e3d4eb3c57509e4715f0c0a4d1d4f91a7a06","names":[],"sources":[]},
+ {"digest":"2f5b14bc2ccbe8810568df6392f1a8e0c3a2be59440051b60666266519e403ff","names":[],"sources":[]},
{"digest":"2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchStartEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:12"]},
+ {"digest":"30093f9f63a120cb932f07577594c04afcec3f0bd02859154a6e5b0e79a46359","names":[],"sources":[]},
{"digest":"30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f","names":["packages/session/session-title/src/types.ts#SessionTitleSource"],"sources":["packages/session/session-title/src/types.ts:28"]},
{"digest":"312aee2f0c42aabbae6cc60d92d596d8453c0aeb061c5868d1755a69ef49e69a","names":[],"sources":[]},
{"digest":"31907de9563751822af6d9be02bafaf26faa098bbd1cb412524134d577cc1648","names":["packages/compaction/compaction/src/checkpoint.ts#CompactionCheckpointSource"],"sources":["packages/compaction/compaction/src/checkpoint.ts:22"]},
@@ -195,6 +199,7 @@
{"digest":"3305a4bb6b6c29b482ba91d59c60b47e8f98282d3dacc8c29e4a4c2712209bdd","names":[],"sources":[]},
{"digest":"33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef","names":["packages/llm/llm/src/assistant-stream.ts#AssistantStreamRecord"],"sources":["packages/llm/llm/src/assistant-stream.ts:20"]},
{"digest":"335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5","names":["packages/core/session/src/types.ts#SurfaceOp"],"sources":["packages/core/session/src/types.ts:470"]},
+ {"digest":"347c1718600bbb3814ae6b4df06e6aceae35e8a8e8cb3c39ac28b2e932c5abd6","names":[],"sources":[]},
{"digest":"347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd","names":[],"sources":[]},
{"digest":"34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66","names":[],"sources":[]},
{"digest":"35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc","names":[],"sources":[]},
@@ -207,7 +212,6 @@
{"digest":"383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f","names":[],"sources":[]},
{"digest":"3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:35"]},
{"digest":"390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackRating"],"sources":["packages/feedback/message-feedback/src/types.ts:17"]},
- {"digest":"39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f","names":[],"sources":[]},
{"digest":"39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f","names":["packages/jobs/jobs/src/types.ts#JobToolCall"],"sources":["packages/jobs/jobs/src/types.ts:16"]},
{"digest":"3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408","names":[],"sources":[]},
{"digest":"3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48","names":[],"sources":[]},
@@ -227,6 +231,7 @@
{"digest":"3f74ba74acd10644c1ed6947a411d8088bd2cfac74f61bb8540f96d68e31f004","names":[],"sources":[]},
{"digest":"3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f","names":["packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:161"]},
{"digest":"408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c","names":[],"sources":[]},
+ {"digest":"414a44a96f06339f04657b0e441a2baed954f7e31aa1552f37dbe7107f0df8e9","names":[],"sources":["packages/core/agent/src/types.ts:24"]},
{"digest":"4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41","names":[],"sources":[]},
{"digest":"42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f","names":[],"sources":[]},
{"digest":"4312134fc6f9cad507c31390bd3d147535492dd77621ec517c5e85f853d53a03","names":[],"sources":[]},
@@ -258,7 +263,6 @@
{"digest":"4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a","names":["packages/subagent/subagent/src/continuation-messages.ts#SubagentSettledMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:29"]},
{"digest":"4edec1b770e8ec4ce1220bf42edc5ac46713a1b48d4caa778dcfc98bad1f28a3","names":[],"sources":[]},
{"digest":"4f507598cf476214aa7646ce7841a7064a9dcb05d0a1eaaefaf79c2b738f160f","names":[],"sources":[]},
- {"digest":"4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]},
{"digest":"4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4","names":[],"sources":["packages/context/session-reference/src/types.ts:18"]},
{"digest":"4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc","names":[],"sources":[]},
{"digest":"50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48","names":[],"sources":[]},
@@ -311,11 +315,13 @@
{"digest":"6292bcbedaa97feaec5b48a03bb8144b581b93309d9d6c905f26da7a6a7a0a4f","names":[],"sources":[]},
{"digest":"62e6a429ff3f390a46ac39fb468692032f42936ca0db19b53454184189401379","names":[],"sources":[]},
{"digest":"62ecb62dc0fe1b5e70217f8a80c39256a40ee3a1917be14e5ca899d20c35f7fd","names":[],"sources":[]},
+ {"digest":"63ba8895cc09e4693cf0819fdcd18c084bef195febda5b6a23af279b3852be36","names":[],"sources":[]},
{"digest":"6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f","names":["packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:72"]},
{"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/experimental/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:48"]},
{"digest":"65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad","names":["packages/llm/llm/src/message.ts#SystemPromptMessageSource"],"sources":["packages/llm/llm/src/message.ts:35"]},
{"digest":"65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099","names":["packages/skill/tool-skill/src/index.ts#SkillCatalogSource"],"sources":["packages/skill/tool-skill/src/index.ts:35"]},
{"digest":"66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2","names":["packages/llm/llm/src/types.ts#ToolSchema"],"sources":["packages/llm/llm/src/types.ts:491"]},
+ {"digest":"671e72b8036a3afb018b38ca5ae9ee3aabe6a314647411655b4b9497b75010b1","names":[],"sources":[]},
{"digest":"672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db","names":["packages/skill/skill/src/index.ts#SkillInvocationSource"],"sources":["packages/skill/skill/src/index.ts:146"]},
{"digest":"67d5f73ceae82f5265258142ad6cf172de2c1fef93ba551b7f0454e0a93bffe0","names":["packages/llm/llm/src/call-config.ts#LlmCallConfig"],"sources":["packages/llm/llm/src/call-config.ts:31"]},
{"digest":"68141cbe3994a5ffcd064c07c75996538437fd9dbf77ce8a2927e89ec577fabe","names":[],"sources":[]},
@@ -364,6 +370,7 @@
{"digest":"7b1ce60193fa8b69292e5843858d9cee7dc565dcb40d33c136e5d7e8b8d4296a","names":[],"sources":[]},
{"digest":"7c6129b6961cf4b2a030f36c78d2b0a5be38617651a9b81322e054ee512bee06","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackDelete"],"sources":["packages/feedback/message-feedback/src/types.ts:46"]},
{"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397","names":[],"sources":[]},
+ {"digest":"7cf091467eb288875ea4e88397003a7e43d95ab75afaf1b42554a8cc766799f2","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]},
{"digest":"7d226b9fd95b4ba213d4237c22e930a91c3c769c95ca7f92977b9d625ba13438","names":["packages/goal/goal/src/domain.ts#GoalChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:44"]},
{"digest":"7d54a5a5e629e2e7783bfac72683bcc5e969e9f43426b9289826aaf294ff6344","names":["packages/feedback/command-feedback/src/types.ts#FeedbackRecord"],"sources":["packages/feedback/command-feedback/src/types.ts:27"]},
{"digest":"7d65acf0a3a43a8f445ce5d3320b42eca492739fc931b2a7516bfb8368dc2c68","names":["packages/llm/llm/src/types.ts#ContentBlockType"],"sources":["packages/llm/llm/src/types.ts:148"]},
@@ -405,7 +412,6 @@
{"digest":"8ad45ad053b4d89f8865c17cc9ff3b8748e86045f3f0fb816269a34af2619141","names":[],"sources":["packages/experimental/hook-protocol/src/types.ts:31"]},
{"digest":"8af841fc9b265ade3441caf9fe40dd24db24226befba48150e6d0eeccc1389c3","names":[],"sources":[]},
{"digest":"8bb8c3751a6ce64b61aba1ec7ae798c00a5803c4d868a0430873216c6569ed52","names":[],"sources":["packages/session/session-log-deepseek/src/types.ts:81"]},
- {"digest":"8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]},
{"digest":"8c1c4f72755a78ea163523a46565316e36e9b644382eb48e6635c71a6f412dd2","names":[],"sources":[]},
{"digest":"8c627d7a45fa406cea879a3922105e27ed4c3c7966e264afaf11ea7a330f2dd1","names":[],"sources":[]},
{"digest":"8cdd3fdd295cee53035de16a1b23411716f75e9f994fe9510cac5d7fef605c76","names":[],"sources":[]},
@@ -413,10 +419,12 @@
{"digest":"8e611bf46821e5003a1605a73560a6f56388295149d449dfe2142d5ec335f29c","names":["packages/llm/llm/src/types.ts#ToolAdditionBlock"],"sources":["packages/llm/llm/src/types.ts:115"]},
{"digest":"8e8155594e07812356b67d732196dda894b0f9c91a5969d51612cf490b0a016d","names":[],"sources":[]},
{"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:237"]},
+ {"digest":"8eda0195dd1d4a3fee80f5e0b9f702fdf0f342c27ece60d44d459899b6a30b60","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:37"]},
{"digest":"8f324cea3916c0517261e88167def8efb3a9994c1135e23d40ed9fd48f8c7bd4","names":[],"sources":[]},
{"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]},
{"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:28"]},
{"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]},
+ {"digest":"92b82e8b7bbb38b3adeb2c07eb76d0f0e0d18c1729808def7004ac2bc9baa583","names":[],"sources":[]},
{"digest":"930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7","names":["packages/interaction/user-approval/src/types.ts#ApprovalOutcome"],"sources":["packages/interaction/user-approval/src/types.ts:32"]},
{"digest":"935c8d486292b13c87b57ca280eb8c58b5b2f5239be86fcd4334c33fdeb87682","names":[],"sources":[]},
{"digest":"938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615","names":[],"sources":[]},
@@ -426,12 +434,11 @@
{"digest":"94c56aeb1564ad735eb773d88636b40761d063efa1610f1308004d32923f5378","names":[],"sources":[]},
{"digest":"95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65","names":[],"sources":["packages/llm/llm/src/types.ts:472"]},
{"digest":"95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd","names":[],"sources":[]},
- {"digest":"95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f","names":[],"sources":[]},
{"digest":"95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60","names":[],"sources":[]},
+ {"digest":"9632024d168f8169ad83fe3639da51c864ebb5444c9fc965ac9342611b2d5263","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]},
{"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]},
{"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]},
- {"digest":"96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c","names":["packages/core/agent/src/types.ts#InboxTarget"],"sources":["packages/core/agent/src/types.ts:40"]},
- {"digest":"9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:37"]},
+ {"digest":"96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c","names":["packages/core/agent/src/types.ts#InboxTarget"],"sources":["packages/core/agent/src/types.ts:47"]},
{"digest":"9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e","names":[],"sources":["packages/sandbox/sandbox-policy/src/session-mode.ts:33"]},
{"digest":"99135d8a3937977955eaac5f8f04f930dcd1562f8450a54a51cde9b921dd59a6","names":[],"sources":[]},
{"digest":"9947aa5938fe19a895298da412917acbf9ffb5093a73ab8baa257e6ae9e63a07","names":[],"sources":[]},
@@ -449,7 +456,6 @@
{"digest":"9e07693fd7cd9babe0688c22c986e4d519ae1c0e40eeea13b2dc583bed2abc86","names":[],"sources":[]},
{"digest":"9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb","names":[],"sources":[]},
{"digest":"9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2","names":[],"sources":["packages/compaction/compaction/src/types.ts:38","packages/compaction/compaction/src/types.ts:84"]},
- {"digest":"9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30","names":[],"sources":[]},
{"digest":"9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94","names":[],"sources":["packages/experimental/webhook/src/types.ts:74"]},
{"digest":"9f65c43e340bba31cdc0fc37bd93ccb68d5e9b36f47a68c207e95a8e68620a5d","names":[],"sources":[]},
{"digest":"a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb","names":[],"sources":[]},
@@ -469,6 +475,7 @@
{"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]},
{"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]},
{"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]},
+ {"digest":"a6d8c25f4b2ca2d137672a8a278ee1398df3f3cb60f5d32d8e3007c68299b664","names":[],"sources":[]},
{"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]},
{"digest":"a720757f704a2512e9d2f20da4a093e582472176236a75ab8b8eb7aaff17d85a","names":[],"sources":[]},
{"digest":"a7cf43ce7c2a4c038feed1885cd7a00d5c6ee2d90a7e0d56b46f78a3e1ca327f","names":[],"sources":[]},
@@ -484,7 +491,6 @@
{"digest":"aa7bc2817014ca6c45719b1c193c5c131be718a0921f34345098ff250f444626","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackPut"],"sources":["packages/feedback/message-feedback/src/types.ts:38"]},
{"digest":"aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877","names":[],"sources":[]},
{"digest":"aacda84a69739238aa782eea902d3073017d9d74b9808de2bf5a83c9738cbdc6","names":[],"sources":[]},
- {"digest":"aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292","names":[],"sources":[]},
{"digest":"aae343bc3d663c83f2e8b5e1c7eea5d509dc7a5361bc8bd8b5ac3bb8d3b1ed56","names":[],"sources":[]},
{"digest":"ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7","names":[],"sources":["packages/core/session/src/types.ts:220","packages/llm/llm/src/types.ts:159"]},
{"digest":"aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba","names":[],"sources":[]},
@@ -503,7 +509,6 @@
{"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]},
{"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]},
{"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]},
- {"digest":"b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]},
{"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:260"]},
{"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]},
{"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/experimental/agent-team/src/types.ts:230"]},
@@ -556,13 +561,14 @@
{"digest":"c219efbdfd53b836786202ab4532913d555798f0a8615a4e9629736e3799fb70","names":[],"sources":[]},
{"digest":"c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896","names":["packages/workflow/workflow/src/types.ts#WorkflowAgentOutcome"],"sources":["packages/workflow/workflow/src/types.ts:110"]},
{"digest":"c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3","names":[],"sources":[]},
- {"digest":"c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c","names":[],"sources":["packages/core/agent/src/types.ts:110"]},
{"digest":"c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943","names":[],"sources":[]},
{"digest":"c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49","names":[],"sources":[]},
{"digest":"c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005","names":[],"sources":["packages/interaction/user-questions/src/types.ts:134"]},
{"digest":"c5687b1313206a6ff334066047b6a0681611467dc4df8c5a8e71d0b6df39311f","names":[],"sources":[]},
{"digest":"c589e938cc0e4989df8b82abef6339de621f0dc4fe62c230d145b94996af7f92","names":[],"sources":[]},
{"digest":"c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6","names":["packages/util/values/src/index.ts#JsonValue"],"sources":["packages/util/values/src/index.ts:4"]},
+ {"digest":"c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5","names":[],"sources":[]},
+ {"digest":"c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039","names":[],"sources":["packages/core/agent/src/types.ts:119"]},
{"digest":"c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262","names":[],"sources":[]},
{"digest":"c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff","names":[],"sources":[]},
{"digest":"c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954","names":[],"sources":[]},
@@ -575,20 +581,20 @@
{"digest":"cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc","names":[],"sources":[]},
{"digest":"cdca80b8dad24d4e7b1d5a288a8475ba8d8d46be385bd2da85f122ee836f1670","names":[],"sources":[]},
{"digest":"cdee10e87dbed8440d8b6935888624884878efc7af95402e94171b29f173dced","names":[],"sources":["packages/llm/llm-retry/src/types.ts:17"]},
- {"digest":"cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]},
{"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:21"]},
{"digest":"cffcdc19881ab61ec0a9ce18743996f7ac44ac8889f34dfac36ae745574aa22f","names":["packages/goal/goal/src/domain.ts#GoalClearChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:35"]},
{"digest":"d0fbb0ca1bbba059830826f33f801da1aced35a2504376d61e020bdfec8d50c0","names":[],"sources":[]},
{"digest":"d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2","names":[],"sources":[]},
- {"digest":"d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc","names":[],"sources":[]},
{"digest":"d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d","names":[],"sources":["packages/llm/llm/src/types.ts:475"]},
{"digest":"d4c799e3d51cae1040362ba4968efc484739e14416c9c1df20c2f108b4ef2eb5","names":[],"sources":[]},
{"digest":"d4e4d575abeb4b72d616dc65c17ac0eaa943cc135609395044c38d5afcb10e31","names":[],"sources":[]},
+ {"digest":"d582ddae3f21edf55b6f50454e3015ef78ed3788fee4670827f2c3c4bd0b47e2","names":[],"sources":["packages/core/session/src/types.ts:317"]},
{"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a","names":[],"sources":[]},
{"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9","names":[],"sources":[]},
{"digest":"d66e11dddf5dfa3c71547e2cbd7b0608824cd8d6243fc16f532017ff28e8fba5","names":[],"sources":[]},
{"digest":"d6e654b88b584fcdf290a56802b1730bc7b9847ae86baa4265ab7769eb48990c","names":[],"sources":[]},
{"digest":"d85590a5675c6e2fd8164e5792a6cceac714605b8ac228adae5ee668facb1490","names":[],"sources":[]},
+ {"digest":"d8dde9629b7323b03c1c3fb0db75d6b72c73666dc0240ab7d3e41b9c0c40fc7c","names":[],"sources":[]},
{"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]},
{"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]},
{"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]},
@@ -612,6 +618,7 @@
{"digest":"df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18","names":[],"sources":[]},
{"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/experimental/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/experimental/agent-team/src/types.ts:45"]},
{"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05","names":[],"sources":[]},
+ {"digest":"e103228e063b4762f2fb51ee5e50acaddd53a513de107efae09bdebf2958a664","names":[],"sources":["packages/api/session-controller/src/types.ts:399"]},
{"digest":"e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187","names":[],"sources":[]},
{"digest":"e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa","names":[],"sources":[]},
{"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602","names":[],"sources":[]},
@@ -619,6 +626,7 @@
{"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72","names":[],"sources":[]},
{"digest":"e31572180d6b28b73f073ad30ae19d5d82982d3daf984723984e23da1e7c459a","names":[],"sources":[]},
{"digest":"e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f","names":[],"sources":[]},
+ {"digest":"e3e9f4d1e245b470cba77c3e6b94ea141408604d693b2a8437278abc8cfd8e2c","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]},
{"digest":"e41bdebe246abe17154de7e7a1d81035437eee8d20e7f1edff0d91e2598ad828","names":[],"sources":[]},
{"digest":"e42b0f40b5d0b628c6e1c44a8fb9eda0780037a81959e1b93d2dfb47b46b0e87","names":[],"sources":[]},
{"digest":"e438a9d4fb883b3e4a8d9fc8913aa1550ed50eb384bfbf943100a5605b537498","names":[],"sources":[]},
@@ -638,7 +646,6 @@
{"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]},
{"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/experimental/agent-team/src/types.ts:234"]},
{"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:201"]},
- {"digest":"eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5","names":[],"sources":[]},
{"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]},
{"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]},
{"digest":"ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7","names":[],"sources":["packages/core/session/src/types.ts:234"]},
@@ -651,7 +658,7 @@
{"digest":"ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21","names":[],"sources":[]},
{"digest":"ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45","names":[],"sources":[]},
{"digest":"f05fb85e715af4ab4de0b6ba111166dd0435bd8bb74a0fa0f32b9d174113fc7c","names":[],"sources":[]},
- {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/core/agent/src/types.ts#ModelSelection"],"sources":["packages/core/agent/src/types.ts:79"]},
+ {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/core/agent/src/types.ts#ModelSelection"],"sources":["packages/core/agent/src/types.ts:86"]},
{"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:238"]},
{"digest":"f1996af2602e6f62b5ab179ca5c4af00d8753938b74cc6ef102de09e99e74a7e","names":[],"sources":[]},
{"digest":"f19bee84bf3d20904553cdcd57465232bc1c2a194f54c4d195292ba0487223a6","names":["packages/attachment/attachment/src/types.ts#FileAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:39"]},
@@ -679,6 +686,7 @@
{"digest":"fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be","names":["packages/llm/llm/src/message.ts#AssistantMessage"],"sources":["packages/llm/llm/src/message.ts:167"]},
{"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:30"]},
{"digest":"fab35c4520883c0d69e42d079c9f543fc5ac7a2d15b537b5572fcd99059c7018","names":[],"sources":[]},
+ {"digest":"fb28ad7d7aabceb5bda505cf027e0e22354d7f31757337e7e51a4b592feac172","names":[],"sources":[]},
{"digest":"fb5e0517050d2c6d40e5b8145db1f7c5ca7044d5d50fada6ff80e0b22c6ca973","names":[],"sources":[]},
{"digest":"fb8b6a2e53cb9cf44c551176de0fb77d1ddc60037b99a891ff8a860b6d5c29a0","names":[],"sources":[]},
{"digest":"fbd2c74b346a1595b376139c587fadd23e4a21acc9c0abce342975b6cfc60986","names":[],"sources":[]},
@@ -722,6 +730,7 @@
"0e7b2a97f54f1afd": {"kind":"literal","value":"schedule/change"},
"0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
"0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
+ "0ef3072084202b08": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"e3e9f4d1e245b470","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
"0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"0f8512903d94f57a": {"kind":"object","properties":[{"name":"data","type":"e481c46af3813e00","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4312134fc6f9cad5","optional":false}],"indices":[]},
"0fe52387f6505270": {"kind":"literal","value":"plan-mode"},
@@ -733,7 +742,6 @@
"124aa62078ca3311": {"kind":"object","properties":[{"name":"data","type":"da60ed58a8fa23d7","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d4c799e3d51cae10","optional":false}],"indices":[]},
"12a0cfa619ac4a5f": {"kind":"literal","value":"read-only"},
"12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]},
- "12f9a5a7506bca46": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"cebb7e9f3f663e66","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"1379f0b72275994d": {"kind":"literal","value":"developer/message"},
"13d3d180f977bf78": {"kind":"object","properties":[{"name":"data","type":"1528539c63db8b23","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1a2718b00937b236","optional":false}],"indices":[]},
"14230f9b8b5797bb": {"kind":"literal","value":"chunk"},
@@ -797,6 +805,7 @@
"28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]},
"297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]},
+ "2a668a48dec17397": {"kind":"object","properties":[{"name":"inputId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"671e72b8036a3afb","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"},
"2abb711460e6238d": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"6f091fe32ab24407","optional":false}],"indices":[]},
@@ -810,7 +819,9 @@
"2eafefdc6f79a492": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"targetId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]},
"2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"},
+ "2f5b14bc2ccbe881": {"kind":"array","element":"7cf091467eb28887"},
"2fe5026daf14bd4f": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "30093f9f63a120cb": {"kind":"literal","value":"agent/retry"},
"30d5a537b2966857": {"kind":"union","types":["1b5649238876e288","ada310bf0bdb8fed","b81e9de81a481b24"]},
"312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]},
@@ -819,6 +830,7 @@
"3305a4bb6b6c29b4": {"kind":"union","types":["0341d4c3dfcd5325","07eccc9ea2f9b543","5bd310104cfe448e","7180844e1066230c","9d80129e13edfb6f","e4cac694f8b6e1d5","f05fb85e715af4ab"]},
"33341aac09312300": {"kind":"union","types":["3860b4757d512a28","487b65c37bfedb8b","71bb3254ec8f1e49","e72269f931559e90"]},
"335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]},
+ "347c1718600bbb38": {"kind":"array","element":"9632024d168f8169"},
"347de3380f2da308": {"kind":"literal","value":"job/settled"},
"34d1ab5c5df37818": {"kind":"literal","value":"resume"},
"35203ba7ad5ef6f9": {"kind":"object","properties":[{"name":"data","type":"f09e5e7acf5ff5cc","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1d5f04a03ea75fc8","optional":false}],"indices":[]},
@@ -831,7 +843,6 @@
"383cf73ae4748e5b": {"kind":"literal","value":"recall"},
"3860b4757d512a28": {"kind":"object","properties":[{"name":"args","type":"93c33d9687613293","optional":false},{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"616f93a5047de668","optional":false}],"indices":[]},
"390eed1028aba027": {"kind":"union","types":["b1f62380f6746fed","df5d9709b7db2a72"]},
- "39adb7fce8475af6": {"kind":"object","properties":[{"name":"data","type":"8bca2b4f53ec7745","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]},
"39cf2d3ff8bac81c": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
"3b1be838223869fe": {"kind":"object","properties":[{"name":"data","type":"e4c18e294232c3ba","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c589e938cc0e4989","optional":false}],"indices":[]},
@@ -851,6 +862,7 @@
"3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"},
"3fdf9582c3b5597d": {"kind":"object","properties":[{"name":"afterSeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"5b232a9b9bfcc0fc","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]},
"408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]},
+ "414a44a96f06339f": {"kind":"object","properties":[{"name":"kind","type":"63ba8895cc09e469","optional":false}],"indices":[]},
"4208123b50df5006": {"kind":"object","properties":[{"name":"data","type":"ad0c38a0adb82a94","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"3e0eff5946255ff0","optional":false}],"indices":[]},
"42e0916e0dda5f6d": {"kind":"object","properties":[{"name":"data","type":"7f9365bae9fcb73e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fe792a6cd4304945","optional":false}],"indices":[]},
"4312134fc6f9cad5": {"kind":"literal","value":"turn/end"},
@@ -882,7 +894,6 @@
"4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"},
"4f507598cf476214": {"kind":"literal","value":"series"},
- "4f9125fa028a5628": {"kind":"union","types":["09f3f8dc417f2782","4b259f7f8f0a44ad","8bca2b4f53ec7745","cebb7e9f3f663e66","fa87537f4a9c1e25"]},
"4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]},
"4fb59762612c3e3a": {"kind":"object","properties":[{"name":"data","type":"b1ebd5cda47d90d9","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"18fcdd273a2f2f9d","optional":false}],"indices":[]},
"50cf02d875f8a1f1": {"kind":"union","types":["2da725e554cd8930","dd2780e7e0768433"]},
@@ -935,11 +946,13 @@
"6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]},
"62e6a429ff3f390a": {"kind":"union","types":["1d8798b1a5e8c545","1e346446e183ee76","34d1ab5c5df37818","7624ca28c61ca0ea","ad22a5ca5bbaab2e","d9196086e41e4dd5"]},
"62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"},
+ "63ba8895cc09e469": {"kind":"literal","value":"retry"},
"6487b10ba7542f3c": {"kind":"object","properties":[{"name":"agentModel","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentProvider","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentReasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"persona","type":"4cd48f3e5108bd6e","optional":true},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"toolFilter","type":"3cfc3a56502da1f8","optional":true},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"6598cd6da253948d": {"kind":"object","properties":[{"name":"context","type":"50cf02d875f8a1f1","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"error","type":"4cd48f3e5108bd6e","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"phase","type":"dfce5db4aa42b7eb","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]},
"65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]},
"66ba70f6f068da44": {"kind":"object","properties":[{"name":"deferLoading","type":"dacf3d5f3e4ff619","optional":true},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parameters","type":"0aa62ab51b36c0e6","optional":false}],"indices":[]},
+ "671e72b8036a3afb": {"kind":"union","types":["414a44a96f06339f","e103228e063b4762"]},
"672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"67d5f73ceae82f52": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"stop","type":"93c33d9687613293","optional":true},{"name":"temperature","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]},
"68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"},
@@ -988,6 +1001,7 @@
"7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"},
"7c6129b6961cf4b2": {"kind":"object","properties":[{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"7c9f44e90a0058f4": {"kind":"object","properties":[{"name":"data","type":"5302e76315127761","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"570e850224346133","optional":false}],"indices":[]},
+ "7cf091467eb28887": {"kind":"union","types":["09f3f8dc417f2782","0ef3072084202b08","4b259f7f8f0a44ad","9632024d168f8169","fa87537f4a9c1e25"]},
"7d226b9fd95b4ba2": {"kind":"union","types":["1df34a6c086866ff","cffcdc19881ab61e"]},
"7d54a5a5e629e2e7": {"kind":"object","properties":[{"name":"category","type":"3305a4bb6b6c29b4","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]},
"7d65acf0a3a43a8f": {"kind":"union","types":["1f0133217eeeffd8","277b61f9c6f81704","3c2535ca0a2062f1","4e44c9118152f682","83d6916021eede8f","85f9b505b7bdff1f","fb8b6a2e53cb9cf4"]},
@@ -1029,7 +1043,6 @@
"8ad45ad053b4d89f": {"kind":"object","properties":[{"name":"decision","type":"4cd48f3e5108bd6e","optional":false},{"name":"durationMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"exitCode","type":"0657d3ca5ac4e7bf","optional":true},{"name":"handlerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"point","type":"4cd48f3e5108bd6e","optional":false},{"name":"stderrSummary","type":"4cd48f3e5108bd6e","optional":true},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"8af841fc9b265ade": {"kind":"literal","value":"system"},
"8bb8c3751a6ce64b": {"kind":"object","properties":[{"name":"sessionFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"throughSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
- "8bca2b4f53ec7745": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b112459dcb068609","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
"8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"8cdd3fdd295cee53": {"kind":"literal","value":"command/done"},
@@ -1037,10 +1050,12 @@
"8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]},
"8e8155594e078123": {"kind":"literal","value":"workspace/changes"},
"8e8e91646a5cae3f": {"kind":"object","properties":[{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"operation","type":"26c879bdd130e8b8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]},
+ "8eda0195dd1d4a3f": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"2f5b14bc2ccbe881","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]},
"8f6d609794bf5afc": {"kind":"tuple","elements":[{"type":"4e88776858652856","optional":false,"rest":false}]},
"8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]},
"904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]},
+ "92b82e8b7bbb38b3": {"kind":"object","properties":[{"name":"data","type":"d582ddae3f21edf5","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]},
"930c1cd289def119": {"kind":"union","types":["2142e391b9ccb60d","2c4c21f19f426a58","811229eedcec5797","fab35c4520883c0d"]},
"935c8d486292b13c": {"kind":"literal","value":"web_search"},
"938f7c7357bc403e": {"kind":"primitive","type":"null"},
@@ -1050,12 +1065,11 @@
"94c56aeb1564ad73": {"kind":"literal","value":"in_progress"},
"95080295115fbd83": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"5abab3a27fd27366","optional":false}],"indices":[]},
"95a3ffccbf3c8afc": {"kind":"literal","value":"owner"},
- "95acdbd066cf445d": {"kind":"object","properties":[{"name":"data","type":"12f9a5a7506bca46","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]},
"95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"},
+ "9632024d168f8169": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"e3e9f4d1e245b470","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
"9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"},
"96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]},
"96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]},
- "9808c3d0f0a1be8f": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"d2b617d9dfcc4e0a","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"9890113e97dd3541": {"kind":"object","properties":[{"name":"mode","type":"fab2bb09bc5e3b9d","optional":false},{"name":"source","type":"76632533f67879dc","optional":true}],"indices":[]},
"99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"9947aa5938fe19a8": {"kind":"union","types":["576a2de8355eb098","a99ec0993c62c058"]},
@@ -1073,7 +1087,6 @@
"9e07693fd7cd9bab": {"kind":"literal","value":"ask"},
"9e2c06d8653585b3": {"kind":"array","element":"1cde1bedde79c5e8"},
"9e41386b3a0c9572": {"kind":"object","properties":[{"name":"end","type":"0657d3ca5ac4e7bf","optional":false},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
- "9e8043098fb89db6": {"kind":"object","properties":[{"name":"data","type":"c433ef580a5b229b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]},
"9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]},
"a0a2e5c42e1c9294": {"kind":"object","properties":[{"name":"data","type":"2e3523541e942ef6","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0e7b2a97f54f1afd","optional":false}],"indices":[]},
@@ -1093,6 +1106,7 @@
"a40d12070f6f4a12": {"kind":"object","properties":[{"name":"task","type":"51edec100659da3a","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]},
"a6567ccb2e530606": {"kind":"object","properties":[{"name":"data","type":"0d47a8f3d847243c","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0a8a062bc9c3c0d9","optional":false}],"indices":[]},
"a6b41f5bede8094d": {"kind":"union","types":["15495614ccfa9cbd","aff60ef947c18b01"]},
+ "a6d8c25f4b2ca2d1": {"kind":"object","properties":[{"name":"data","type":"c66c6c3b411abf46","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]},
"a6da9ad5d42ae83a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"runId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"a720757f704a2512": {"kind":"literal","value":"tool-registry"},
"a7cf43ce7c2a4c03": {"kind":"object","properties":[{"name":"data","type":"20a57544bbf204da","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"f634871d1e18b1eb","optional":false}],"indices":[]},
@@ -1108,7 +1122,6 @@
"aa7bc2817014ca6c": {"kind":"object","properties":[{"name":"item","type":"3d7d07060a7be779","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"aa93010d0e521ad7": {"kind":"primitive","type":"boolean"},
"aacda84a69739238": {"kind":"literal","value":"subagent"},
- "aada2771a250136c": {"kind":"array","element":"8bca2b4f53ec7745"},
"aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]},
"ab7ec5f9eb2c6dfa": {"kind":"object","properties":[{"name":"kind","type":"68141cbe3994a5ff","optional":false}],"indices":[]},
"aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"},
@@ -1127,7 +1140,6 @@
"b0127044ab31a702": {"kind":"object","properties":[{"name":"data","type":"ca59530d1a4f3e19","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1986c2664009c991","optional":false}],"indices":[]},
"b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
"b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
- "b112459dcb068609": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]},
"b158f3ff69237148": {"kind":"object","properties":[{"name":"contextWindow","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"systemPromptUpdate","type":"f311671e07bc7fa6","optional":true}],"indices":[]},
"b1664fa89f8eab49": {"kind":"array","element":"bbc5696e3f13c758"},
"b1ebd5cda47d90d9": {"kind":"object","properties":[{"name":"member","type":"6598cd6da253948d","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]},
@@ -1180,13 +1192,14 @@
"c219efbdfd53b836": {"kind":"literal","value":"step/end"},
"c2deb7c9183735d4": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","b57a2889408de36e"]},
"c2f49004d9ae38a9": {"kind":"union","types":["1ebc6f9ff3aa9e3a","b57a2889408de36e","ef226a90ddac6cd8"]},
- "c433ef580a5b229b": {"kind":"object","properties":[{"name":"inserted","type":"aada2771a250136c","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]},
"c43b119ce579b3e9": {"kind":"literal","value":"paused"},
"c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]},
"c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]},
"c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"},
"c589e938cc0e4989": {"kind":"literal","value":"tool/call"},
"c592ce75aab73fca": {"kind":"union","types":["0657d3ca5ac4e7bf","4cd48f3e5108bd6e","529e0ccd0e34079a","72ec79127a9c0d02","938f7c7357bc403e","aa93010d0e521ad7"]},
+ "c5bbcb3435908af2": {"kind":"object","properties":[{"name":"data","type":"9632024d168f8169","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]},
+ "c66c6c3b411abf46": {"kind":"object","properties":[{"name":"inserted","type":"347c1718600bbb38","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]},
"c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"},
"c8e6f48878cc2a2f": {"kind":"literal","value":"active"},
"c99e20441bf15341": {"kind":"literal","value":"feedback/record"},
@@ -1199,20 +1212,20 @@
"cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"cdca80b8dad24d4e": {"kind":"literal","value":"tool-workflow/run-start"},
"cdee10e87dbed844": {"kind":"object","properties":[{"name":"delayMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"maxRetries","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mode","type":"3bc287e765d58dc3","optional":false},{"name":"policyKey","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
- "cebb7e9f3f663e66": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b112459dcb068609","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]},
"cef171adf021a186": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"isError","type":"aa93010d0e521ad7","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"cffcdc19881ab61e": {"kind":"object","properties":[{"name":"cleared","type":"0b6d718c693fdf7f","optional":false},{"name":"clearedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"kind","type":"237c004c73b6b288","optional":false},{"name":"operation","type":"e7e154d47d5fbc53","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]},
"d0fbb0ca1bbba059": {"kind":"literal","value":"delete"},
"d28765f87d6b6ab4": {"kind":"literal","value":"one-shot"},
- "d2b617d9dfcc4e0a": {"kind":"array","element":"4f9125fa028a5628"},
"d4b739491689cc6b": {"kind":"object","properties":[{"name":"block","type":"8064c03c87c357fd","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"eefbea819caeeea9","optional":false}],"indices":[]},
"d4c799e3d51cae10": {"kind":"literal","value":"working-directory/change"},
"d4e4d575abeb4b72": {"kind":"literal","value":"canceled"},
+ "d582ddae3f21edf5": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"0ef3072084202b08","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]},
"d595ec73b32b016a": {"kind":"object","properties":[{"name":"data","type":"a40d12070f6f4a12","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]},
"d63b8b8ffad9c02f": {"kind":"object","properties":[{"name":"data","type":"8bb8c3751a6ce64b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"47e5d84f1a93450a","optional":false}],"indices":[]},
"d66e11dddf5dfa3c": {"kind":"object","properties":[{"name":"data","type":"cef171adf021a186","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fb5e0517050d2c6d","optional":false}],"indices":[]},
"d6e654b88b584fcd": {"kind":"literal","value":"fallback"},
"d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]},
+ "d8dde9629b7323b0": {"kind":"object","properties":[{"name":"data","type":"2a668a48dec17397","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"30093f9f63a120cb","optional":false}],"indices":[]},
"d9196086e41e4dd5": {"kind":"literal","value":"complete"},
"d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]},
"d9edf1862e4471d6": {"kind":"literal","value":"schedule"},
@@ -1236,6 +1249,7 @@
"df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"},
"dfce5db4aa42b7eb": {"kind":"union","types":["871bfd6f7bb91061","b57a2889408de36e","c8e6f48878cc2a2f"]},
"e0a787e6ec76c7c9": {"kind":"object","properties":[{"name":"data","type":"ef11a3b7fa2033ae","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c219efbdfd53b836","optional":false}],"indices":[]},
+ "e103228e063b4762": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"63ba8895cc09e469","optional":false},{"name":"principal","type":"deebfc7c62177ea8","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
"e2ad8f5bcd813f26": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]},
"e2d0367e5716bc18": {"kind":"literal","value":"compaction/start"},
"e2f9a41e0989f54e": {"kind":"object","properties":[{"name":"data","type":"9947aa5938fe19a8","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ef4f00923b9edbae","optional":false}],"indices":[]},
@@ -1243,6 +1257,7 @@
"e308ccf867a5398e": {"kind":"object","properties":[{"name":"data","type":"f90eb4ab2d3897bc","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"8e8155594e078123","optional":false}],"indices":[]},
"e31572180d6b28b7": {"kind":"literal","value":"provider"},
"e3e77b26f0148755": {"kind":"object","properties":[{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]},
+ "e3e9f4d1e245b470": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","414a44a96f06339f","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e103228e063b4762","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]},
"e41bdebe246abe17": {"kind":"literal","value":"session"},
"e42b0f40b5d0b628": {"kind":"literal","value":"every"},
"e438a9d4fb883b3e": {"kind":"literal","value":"append"},
@@ -1262,7 +1277,6 @@
"e95f957154b2b1b5": {"kind":"literal","value":"assistant/attempt"},
"ea08ec1df0c33108": {"kind":"object","properties":[{"name":"message","type":"2eafefdc6f79a492","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]},
"eaa71be0fbf0f702": {"kind":"object","properties":[{"name":"kind","type":"ad3b56aa2fc1ad42","optional":false}],"indices":[]},
- "eaa9125aec0fb284": {"kind":"object","properties":[{"name":"data","type":"9808c3d0f0a1be8f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]},
"ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"},
"ec38b5949af8eaca": {"kind":"object","properties":[{"name":"data","type":"2fe5026daf14bd4f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"2aed07493b1c65b6","optional":false}],"indices":[]},
"ec8b5ff7c77ca689": {"kind":"object","properties":[{"name":"kind","type":"b2245a906e13aa94","optional":false}],"indices":[]},
@@ -1303,6 +1317,7 @@
"fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]},
"fab2bb09bc5e3b9d": {"kind":"union","types":["12a0cfa619ac4a5f","18fce9540bb20897","a3edd1efd1fef21b"]},
"fab35c4520883c0d": {"kind":"literal","value":"rejected"},
+ "fb28ad7d7aabceb5": {"kind":"object","properties":[{"name":"data","type":"8eda0195dd1d4a3f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]},
"fb5e0517050d2c6d": {"kind":"literal","value":"tool/ptc-dispatch"},
"fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"},
"fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"},
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index d11e03488a..d709735217 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -104,7 +104,8 @@ interface Agent {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
- * Empty ordinary input drives work without becoming a model-visible message.
+ * A retry-source input must be empty; admission records `agent/retry`
+ * without adding a model-visible message.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
@@ -117,9 +118,8 @@ interface Agent {
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
/**
- * Queue an ordinary follow-up turn and wake the driver. The item becomes the
- * sole ordinary message of its own turn. An empty ordinary input starts a
- * turn from committed history without appending a model-visible user message.
+ * Queue a follow-up turn and wake the driver. The item is its sole ordinary
+ * prompt, or an empty retry-source control with no model-visible message.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index 7e4c682f69..11fa2a6ffe 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -108,7 +108,8 @@ interface Agent {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
- * Empty ordinary input drives work without becoming a model-visible message.
+ * A retry-source input must be empty; admission records `agent/retry`
+ * without adding a model-visible message.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
@@ -121,9 +122,8 @@ interface Agent {
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
/**
- * Queue an ordinary follow-up turn and wake the driver. The item becomes the
- * sole ordinary message of its own turn. An empty ordinary input starts a
- * turn from committed history without appending a model-visible user message.
+ * Queue a follow-up turn and wake the driver. The item is its sole ordinary
+ * prompt, or an empty retry-source control with no model-visible message.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml
index cbda06d0c8..9573c050b1 100644
--- a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml
+++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml
@@ -3,14 +3,14 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
/:
- en: 7a194a68f49ac09a
- zh: 4d7bdff82c571c62
-/observe-empty-prompt-admission-through-the-inbox:
- en: 450277479989809e
- zh: 4b01f252faeae19c
-/observe-empty-prompt-admission-through-the-inbox/change:
- en: e1c081d5321f3256
- zh: a5dd5585fe4300b3
-/observe-empty-prompt-admission-through-the-inbox/migration:
- en: 36e80226df9dcf54
- zh: 81a9c0bd5c667470
+ en: f5e2d8120bcf97bd
+ zh: 64b692f69ec7b0d4
+/empty-prompts-and-retry-controls:
+ en: e26b58650aa8e914
+ zh: c0e5a824ec3bea40
+/empty-prompts-and-retry-controls/change:
+ en: bf69985ccd8cc89a
+ zh: b1046f8c98cbdb43
+/empty-prompts-and-retry-controls/migration:
+ en: 028f7fd6db73a36b
+ zh: b72f840db5301d6c
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
index 16876dd26c..c74f3c2047 100644
--- a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
+++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md
@@ -1,19 +1,20 @@
---
kind: upgrade-guide
-description: "Empty ordinary Agent inputs start requests without producing user/message events."
+description: Empty API and SDK prompts become retry controls whose required Session event needs an updated reader.
---
-# Observe empty prompt admission through the inbox
+
+# Empty prompts and retry controls
English | [中文](guide.zh.md)
## Change
-An ordinary Agent input with `content: []` starts a new turn from committed history without appending an empty `user/message`. The identified input remains in durable inbox events. Custom consumers that wait for every accepted input to produce a user message must observe its inbox receipt instead. Session logs need no migration; existing recorded empty user messages keep their historical interpretation.
+The Session prompt API accepts `content: []` to continue eligible failed, idle sessions in queue mode. SDK `session/prompt` maps empty `contentBlocks` to the same retry-source control. Acceptance records `agent/retry` with `turn`, `step`, `inputId`, and `source`, instead of a `user/message`. A new turn still starts and closes normally. Direct Agent callers using ordinary empty user messages retain their existing behavior.
-The Web Session prompt endpoint accepts an empty array only for an eligible failed, idle ordinary Session in queue mode. Whitespace-only text parts remain invalid.
+The new required event and retry source keep the current Session format version. Updated readers accept older logs; older readers reject logs containing the unknown required event. See the [persistence acknowledgement](../../../persistence-changes/2026-10-27-retry-control-input.md).
## Migration
-1. Keep ordinary text and attachment inputs unchanged. Use an empty array only when requesting another model call without new user text.
-2. Match acceptance by the input identity in `agent/inbox/spliced`, then observe the normal turn/status lifecycle. Do not wait for an empty `user/message`.
-3. Verify that continuation adds a balanced turn without adding a user message. The shipped TypeScript and Python SDK clients already observe inbox receipts.
+1. Upgrade Session readers and SDK runtimes together before opening logs containing retry controls.
+2. Consumers correlating empty API or SDK prompts with `user/message` must use the durable inbox identity and `agent/retry.inputId`. Handle the retry source without treating it as model-visible user content.
+3. Verify that a continued failure produces a balanced new turn and `agent/retry`, while derived model history contains no additional user message.
diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md
index 9c879cc2aa..66f436fae9 100644
--- a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md
+++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md
@@ -1,19 +1,20 @@
---
kind: upgrade-guide
-description: "空的普通 Agent 输入发起请求,但不产生 user/message 事件。"
+description: 空 API 和 SDK 提示词变为重试控制输入,其必读 Session 事件需要更新后的读取方。
---
-# 通过 inbox 观察空 prompt 准入
+
+# 空提示词与重试控制输入
[English](guide.md) | 中文
## 变更
-普通 Agent 输入的 `content: []` 基于已提交历史启动新轮次,不再追加空的 `user/message`。带身份的输入仍保留在持久 inbox 事件中。若自定义消费者等待每条已接受输入产生用户消息,应改为观察对应 inbox 回执。Session 日志无需迁移,历史空用户消息的解释保持不变。
+Session prompt API 接受 `content: []`,在 queue 模式下继续符合条件且空闲的失败会话。SDK `session/prompt` 将空 `contentBlocks` 映射到同样的 retry 来源控制输入。接受后记录带 `turn`、`step`、`inputId` 和 `source` 的 `agent/retry`,而非 `user/message`。新轮次仍正常开启和闭合。直接使用普通空用户消息的 Agent 调用方保留原有行为。
-Web Session prompt 端点仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时接受空数组。仅含空白的文本块仍无效。
+新增必读事件及 retry 来源保留当前 Session 格式版本。更新后的读取方接受旧日志;旧读取方会拒绝包含未知必读事件的日志。见[持久化确认记录](../../../persistence-changes/2026-10-27-retry-control-input.zh.md)。
## 迁移
-1. 普通文本和附件输入保持不变。仅在需要再次请求模型而不添加用户文本时使用空数组。
-2. 通过 `agent/inbox/spliced` 中的输入身份匹配准入,然后观察正常轮次与状态生命周期。不要等待空的 `user/message`。
-3. 验证继续操作增加正常闭合的轮次,但不增加用户消息。内置 TypeScript 和 Python SDK 客户端已观察 inbox 回执。
+1. 打开包含重试控制输入的日志前,同时升级 Session 读取方与 SDK 运行时。
+2. 通过 `user/message` 关联空 API 或 SDK 提示词的消费方,改用持久 inbox 身份与 `agent/retry.inputId`。处理 retry 来源时,不应将其视为模型可见用户内容。
+3. 确认继续失败请求会产生正常开启并闭合的新轮次和 `agent/retry`,而派生模型历史中没有额外用户消息。
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index a1daccc8f0..99568c2d0c 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 954923654eebe2dc
zh: 6bd9a7d2ea965aed
/session-controller/configuration:
- en: 94cb7b73b385b320
- zh: 1f3bc1e6552a01a3
+ en: c1b6d81775d0b56e
+ zh: 67c3ef16ff39c6c3
/session-controller/model-experience:
en: 30b917bf822bb4df
zh: b0b023b5b526c317
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 371ee3c13e..77b6f9eb1b 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -110,7 +110,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation.
-An empty `prompt.content` array requests a new turn only for an eligible failed, idle ordinary Session in queue mode. Pending human input prevents admission; non-waking injected context is retained. `conversationContinuation` supplies the shared six-code manual policy, independently of provider retry configuration. The input is logged through the inbox for request-id deduplication, but creates no model-visible user message. Whitespace-only text parts remain invalid.
+An empty `prompt.content` array requests a new turn only for an eligible failed, idle ordinary Session in queue mode. Pending human input prevents admission; non-waking injected context is retained. `conversationContinuation` supplies the shared six-code manual policy, independently of provider retry configuration. The input uses `source.kind: 'retry'` and ordinary inbox admission; Loop acceptance records `agent/retry` with the input identity and caller metadata for deduplication and audit, without adding a model-visible message. Whitespace-only text parts remain invalid.
## Model Experience
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 9e698a26e7..53387989ad 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -110,7 +110,7 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不
Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。
-空的 `prompt.content` 数组仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时请求新轮次。待消费的用户输入阻止准入,非唤醒的注入上下文保留。`conversationContinuation` 提供共用的六码手动策略,独立于提供方重试配置。输入通过 inbox 持久记录,用于 request-id 去重,但不生成模型可见的用户消息。仅含空白的文本块仍无效。
+空的 `prompt.content` 数组仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时请求新轮次。待消费的用户输入阻止准入,非唤醒的注入上下文保留。`conversationContinuation` 提供共用的六码手动策略,独立于提供方重试配置。输入使用 `source.kind: 'retry'` 并沿用普通 inbox 准入;Loop 接受后记录带输入身份和调用方元数据的 `agent/retry`,供去重和审计使用,不增加模型可见消息。仅含空白的文本块仍无效。
## 模型体验
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index 88d7486cbe..15b110c8ac 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -568,13 +568,14 @@ function resolvePromptFileReceipts(
function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
const matches = (message: UserMessage): boolean => {
const source = message.source
- return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
+ return (source.kind === 'user' || source.kind === 'retry') && 'rpcId' in source && source.rpcId === requestId
}
if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
return agent.session.snapshotEvents().some((event) => {
- if (event.type === 'agent/inbox/spliced') {
- return event.data.inserted.some(message => message.content.length === 0 && matches(message))
+ if (event.type === 'agent/retry') {
+ const source = event.data.source
+ return 'rpcId' in source && source.rpcId === requestId
}
if (event.type !== 'user/message') return false
const source = event.data.source
diff --git a/packages/api/session-controller/src/continuation.ts b/packages/api/session-controller/src/continuation.ts
index 5d8e97553e..0e8247688a 100644
--- a/packages/api/session-controller/src/continuation.ts
+++ b/packages/api/session-controller/src/continuation.ts
@@ -15,30 +15,25 @@ const stateSchema = z.object({
admitted: z.boolean(),
failedAttempt: z.boolean(),
ending: endingSchema,
- continuing: z.boolean(),
})
-/** Recoverable manual-continuation facts, including an accepted empty input awaiting its turn. */
+/** Recoverable manual-continuation facts over admitted user input and retry controls. */
export type ContinuationState = z.infer
-const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null, continuing: false }
+const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null }
/** Eligibility requires admitted human work and a failed settlement in the last request step. */
export const continuationProjectionDefinition = {
key: 'conversationContinuation',
- stateVersion: 2,
+ stateVersion: 3,
stateSchema,
init: (): ContinuationState => EMPTY,
apply: (state, event) => {
switch (event.type) {
- case 'agent/inbox/spliced':
- if (state.continuing && event.data.target === 'next-turn' && event.data.outcome === 'canceled'
- && event.data.start === 0) return { ...state, continuing: false }
- return state.ending !== null && !state.continuing && event.data.target === 'next-turn'
- && event.data.inserted.some(message => message.source.kind === 'user' && message.content.length === 0)
- ? { ...state, continuing: true } : state
case 'turn/start':
- return state.continuing ? { ...EMPTY, admitted: true } : EMPTY
+ return EMPTY
+ case 'agent/retry':
+ return state.admitted ? state : { ...state, admitted: true }
case 'step/start':
case 'assistant/message':
return state.failedAttempt ? { ...state, failedAttempt: false } : state
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index b5599f3a22..a10dcf5578 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -483,7 +483,7 @@ export class SessionController extends TypertRemoteService {
// The caller is recorded only when it is not the owner, so owner messages keep their exact source.
const principal = principalOf(this.ctx.invocation)
const source: MessageSource = {
- kind: 'user',
+ kind: request.content.length === 0 ? 'retry' : 'user',
rpcId: request.requestId,
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
...(principal === undefined ? {} : { principal }),
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index 798ca0e04c..91818f9748 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -395,6 +395,8 @@ declare module '@deepseek-ai/dsh-llm' {
* caller other than the owner, so an absent `principal` means the owner.
*/
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
+ /** Browser retry correlation with the same caller metadata as an ordinary prompt. */
+ 'retry-rpc': { kind: 'retry'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
}
}
diff --git a/packages/api/session-controller/tests/continuation.host.spec.ts b/packages/api/session-controller/tests/continuation.host.spec.ts
index f87b29809f..f2ae6ecf35 100644
--- a/packages/api/session-controller/tests/continuation.host.spec.ts
+++ b/packages/api/session-controller/tests/continuation.host.spec.ts
@@ -111,7 +111,7 @@ it('publishes a complete-log result independently of the visible page and releas
expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBe(ending)
expect(changed.mock.calls.map(([, key, value]) => [key, value])).toEqual([['conversationContinuation', null], ['conversationContinuation', ending]])
expect(ctx.sessionProjections.checkpoint(session).conversationContinuation)
- .toEqual({ ver: 2, seq: ending, val: { admitted: false, failedAttempt: false, ending, continuing: false } })
+ .toEqual({ ver: 3, seq: ending, val: { admitted: false, failedAttempt: false, ending } })
session.append('turn/start', { turn: 2 })
expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull()
expect(changed).toHaveBeenCalledTimes(3)
@@ -127,42 +127,28 @@ it('restores valid, absent and outdated checkpoints through the registry', async
await ctx.sessionProjections.register(definition)
const session = ctx.sessions.create(SessionId('restored'))
const log = events(failed('SERVER'))
- const checkpoint = { conversationContinuation: { ver: 2, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } }
+ const checkpoint = { conversationContinuation: { ver: 3, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } }
const restore = (rows: typeof checkpoint | Record, tail: readonly SessionEvent[], base: number) =>
ctx.sessionProjections.restore(rows, tail, SessionLogOffset(base), session.header, session.inheritedEventCount)
expect(restore(checkpoint, log.slice(3), 3).snapshot.values.conversationContinuation).toBe(5)
expect(restore({}, log, 0).snapshot.values.conversationContinuation).toBe(5)
- const outdated = { conversationContinuation: { ...checkpoint.conversationContinuation, ver: 0 } }
+ const outdated = { conversationContinuation: { ...checkpoint.conversationContinuation, ver: 2 } }
expect(restore(outdated, log, 0).snapshot.values.conversationContinuation).toBe(5)
expect(() => restore(outdated, log.slice(3), 3)).toThrow('re-read from seq 0')
expect(() => restore({}, log.slice(3), 3)).toThrow('re-read from seq 0')
})
-it('carries admitted work through an empty continuation and retires a canceled queued control', async () => {
- const ctx = new Context()
- onTestFinished(() => ctx.fiber.dispose())
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- const handle = await ctx.sessionProjections.register(definition)
- const session = ctx.sessions.create(SessionId('empty-repeat'))
- for (const event of events(failed('SERVER'))) {
- if (event.type === 'user/message') session.append(event.type, event.data, { surfaceOp: 'append' })
- else if (event.type === 'turn/start') session.append(event.type, event.data)
- else if (event.type === 'step/start' || event.type === 'step/end') session.append(event.type, event.data)
- else if (event.type === 'assistant/attempt') session.append(event.type, event.data)
- else if (event.type === 'turn/end') session.append(event.type, event.data)
+it('admits retry controls independently of empty ordinary user messages', () => {
+ const base = events(failed('SERVER'))
+ const input = base.find(event => event.type === 'user/message')!
+ const empty: SessionEvent<'user/message'> = { ...input, type: 'user/message', data: createUserMessage({
+ content: [], source: { kind: 'user' },
+ }) }
+ expect(fold(base.map(event => event === input ? empty : event)).ending).toBeNull()
+ const retry: SessionEvent<'agent/retry'> = { type: 'agent/retry', seq: empty.seq, time: empty.time,
+ data: { turn: 1, step: 1, inputId: empty.data.id, source: { kind: 'retry' } },
}
- const input = createUserMessage({ content: [], source: { kind: 'user' } })
- session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] })
- expect(handle.stateOf(session).continuing).toBe(true)
- session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' })
- expect(handle.stateOf(session).continuing).toBe(false)
- session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] })
- session.append('turn/start', { turn: 2 })
- expect(handle.stateOf(session).admitted).toBe(true)
- session.append('step/start', { turn: 2, step: 1 })
- session.append('assistant/attempt', { turn: 2, step: 1, stream: [] })
- session.append('step/end', { turn: 2, step: 1 })
- const ending = session.append('turn/end', { turn: 2, reason: failed('SERVER') }).seq
- expect(handle.stateOf(session).ending).toBe(ending)
+ expect(fold(base.map(event => event === input ? retry : event)).ending).toBe(5)
+ const admitted = definition.apply(definition.init(), retry)
+ expect(definition.apply(admitted, retry)).toBe(admitted)
})
diff --git a/packages/api/session-controller/tests/empty-prompt.host.spec.ts b/packages/api/session-controller/tests/empty-prompt.host.spec.ts
index 5f8d913f40..835d7ae442 100644
--- a/packages/api/session-controller/tests/empty-prompt.host.spec.ts
+++ b/packages/api/session-controller/tests/empty-prompt.host.spec.ts
@@ -25,7 +25,7 @@ async function setup() {
return { ctx, remote, agent, adapter, request }
}
-it('admits repeated empty prompts after failures without extra user messages or duplicate requests', async () => {
+it('records retry controls without user messages or duplicate requests', async () => {
const { remote, agent, adapter, request } = await setup()
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } }))
await agent.whenIdle()
@@ -40,7 +40,20 @@ it('admits repeated empty prompts after failures without extra user messages or
await agent.whenIdle()
expect(adapter.requests).toHaveLength(3)
const events = agent.session.snapshotEvents()
- expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
+ const messages = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? [event.data] : [])
+ expect(messages).toHaveLength(1)
+ const retries = events.flatMap(event => event.type === 'agent/retry' ? [event.data] : [])
+ expect(retries).toEqual([
+ { turn: 2, step: 1, inputId: expect.any(String), source: { kind: 'retry', rpcId: 'continue-1' } },
+ { turn: 3, step: 1, inputId: expect.any(String), source: { kind: 'retry', rpcId: 'continue-2' } },
+ ])
+ const controls = events.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : [])
+ .filter(message => message.source.kind === 'retry')
+ expect(retries.map(retry => retry.inputId)).toEqual(controls.map(control => control.id))
+ for (const call of adapter.requests) {
+ expect(call.messages.filter(message => message.role === 'user').map(message => message.content))
+ .toEqual([[{ type: 'text', text: 'original task' }]])
+ }
expect(events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])).toEqual(['error', 'error', 'completed'])
expect(events.filter(event => event.type === 'turn/start')).toHaveLength(3)
expect((await remote.prompt(request('after-completion'))).ok).toBe(false)
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 8507f1dd99..5ec07de08b 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -33,14 +33,14 @@
en: 6a7ae99570c9c007
zh: 26e86063e97d82c7
/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/what-the-model-sees:
- en: cfd91cdd0132732b
- zh: 298d1ce1b973e7b0
+ en: 5c81348e39a58220
+ zh: d61ecf2b51b2d583
/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/token-effect:
- en: a5bbf55d50568fa2
- zh: 6c43af211055db93
+ en: 777a5c51789aba0c
+ zh: 9a710c83d1765fba
/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/kv-cache-effect:
- en: bbb4b8186c85f62d
- zh: 222397b869a60df2
+ en: 4889ad2beba4cc52
+ zh: 778e8d7803481cd3
/deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work:
en: d76daa90b6d6f600
zh: 37a9f7d402b2552b
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 8955b71b31..9e06885423 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -193,15 +193,15 @@ The selector must be a pure function of the owner currency. Its non-null return
#### What the model sees
-The play action starts a new request from committed Session history. Its `content: []` input is a durable inbox command, not a model-visible user message; it adds no localized or hidden continuation instruction. Pending injected context follows ordinary admission.
+The play action submits `content: []` through the existing Session prompt API as a retry control. Loop acceptance records `agent/retry` without adding a `user/message` or a chat bubble. Pending injected context follows ordinary admission; the action adds no localized or hidden continuation text.
#### Token effect
-The action adds no continuation text or empty user-message overhead. The request still incurs the ordinary cost of retained history, current prompt sections, tool declarations, and any newly admitted context.
+The control adds no model tokens. The request still incurs the ordinary cost of retained history, current prompt sections, tool declarations, and any newly admitted context.
#### KV Cache effect
-The control input does not alter model history. Prefix reuse still depends on the current model, prompt, tools, injected context, and provider cache policy.
+The control leaves model history unchanged. Prefix reuse depends on the current model, prompt, tools, injected context, and provider cache policy.
## Known Limitations and Deferred Work
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 65c9feda93..152417372c 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -193,15 +193,15 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match
#### 模型看到什么
-播放按钮基于已提交的 Session 历史发起新请求。`content: []` 作为持久 inbox 控制记录,不成为模型可见的用户消息;不会添加本地化或隐藏的继续指令。待注入上下文遵循普通准入流程。
+播放按钮通过现有 Session prompt API 将 `content: []` 提交为重试控制输入。Loop 接受后记录 `agent/retry`,不增加 `user/message` 或聊天气泡。待注入上下文遵循普通准入流程;该操作不添加本地化或隐藏的继续文本。
#### Token 影响
-该操作不增加继续文本或空用户消息的封装开销。请求仍承担保留历史、当前提示词、工具声明以及新准入上下文的正常输入成本。
+控制输入不增加模型 token。请求仍承担保留历史、当前提示词、工具声明以及新准入上下文的正常输入成本。
#### KV Cache 影响
-控制输入不改变模型历史。前缀复用仍取决于当前模型、提示词、工具、注入上下文及提供方缓存策略。
+控制输入不改变模型历史。前缀复用取决于当前模型、提示词、工具、注入上下文及提供方缓存策略。
## 已知限制与暂缓事项
diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml
index 5b88c245a3..2d3b453c44 100644
--- a/packages/core/agent-loop/README.i18n.yaml
+++ b/packages/core/agent-loop/README.i18n.yaml
@@ -45,14 +45,14 @@
en: 3b48a480036bd5df
zh: 28cea2036b0dccee
/deepseek-ai-dsh-agent-loop/understand-the-implementation/turn-and-step-flow:
- en: ad92c751515520ff
- zh: ca6b24db5ab4e471
+ en: c9f75ebd07873e6a
+ zh: 863bc96addeb8eb9
/deepseek-ai-dsh-agent-loop/understand-the-implementation/failure-and-cancellation:
en: eea56903c4ff82f5
zh: c0d5abb5767780ed
/deepseek-ai-dsh-agent-loop/further-exploration:
- en: bd0b5d56ebef7e23
- zh: 52911b993bc41afb
+ en: 7ac519f8e635acbc
+ zh: 975e514915dc6c9f
/deepseek-ai-dsh-agent-loop/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index b18484ad82..021401061a 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -117,6 +117,8 @@ The loop is the production acquisition point for session write handles. When `ct
### Turn and step flow
+An empty input with `source.kind: 'retry'` is a control: on first-attempt admission, the Loop records `agent/retry` with its inbox identity, turn, step, and source instead of a model-visible `user/message`. A retry control containing content fails with `INVALID_REQUEST`. Queue ownership, pre-step decisions, and turn settlement remain the same as for ordinary input; ordinary empty user messages are still committed.
+
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. `AgentLoop` registers the standard `inbox` projection for its service lifetime, so cold reads work before any Agent exists and after all Agents unload. It also owns `modelSelection` and constructs an `AgentModel` for each Agent, with the public [Model API](../agent/README.md#model-selection). Its package-internal `ReactLoopInbox` uses that shared projection for structural commands and loop-only claims. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles the prompt and tools and runs `agent/pre-step`; the fallback projects runtime context after listeners run. A rejected decision or empty first batch opens no step. On the first attempt after acceptance, `step/start` precedes the `agent/request` waterfall and `prepareCall()`; neither async phase sees the pending system prompt or accepted users committed to history, and cancellation during either commits neither. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, refreshes registered runtime facts without reassembling the bound prompt or tools, appends the accepted `user/message` batch only on the first attempt with its owned context candidate replaced in place, and restores removed runtime context, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the same rendered assembly without repeating assembly, `agent/pre-step`, or user admission. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results... (line truncated to 2000 chars)
Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route, or at a new request series whose effective text differs, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. A series start whose latest surviving node already holds the rendering produces no system event, so the request keeps the prefix it had. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or a new series whose effective text differs refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries` or `session.surface.contentGeneration` changed since attachment or the last request (a replacement or image-offload decision). Visible tool-schema changes force prompt consolidation only when the prepared route lacks `toolUpdate`. Routes supporting both tool and system-prompt updates can append a changed prompt and activate added tools without rewriting earlier messages. If another admission condition replaces a system node, the request starts a new series with current tool declarations. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation.
@@ -146,8 +148,6 @@ The package-level contract is enough for most consumers; read these when you nee
-----
-Empty ordinary input remains an identified inbox command, so it can wake a new turn while adding no `user/message` to the model surface. It passes normal pre-step admission; a rejected or removed input does not authorize a model request. Turn and step endings retain their normal success, failure, and cancellation handling.
-
## Model Experience
diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md
index 54395d27dc..1937358731 100644
--- a/packages/core/agent-loop/README.zh.md
+++ b/packages/core/agent-loop/README.zh.md
@@ -117,6 +117,8 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄
### 轮次与步骤流程
+带 `source.kind: 'retry'` 的空输入属于控制输入:首次尝试准入时,Loop 记录带 inbox 身份、轮次、步骤和来源的 `agent/retry`,而不写入模型可见的 `user/message`。包含内容的重试控制输入以 `INVALID_REQUEST` 失败。队列所有权、pre-step 决策和轮次结算与普通输入一致;普通空用户消息仍会提交。
+
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。`AgentLoop` 在服务生命周期内注册标准 `inbox` 投影,因此冷读取在没有 Agent 和所有 Agent 卸载后都可用。它还拥有 `modelSelection`,并为每个 Agent 构建 `AgentModel`,实现公开的 [Model API](../agent/README.zh.md#model-selection)。其包内部 `ReactLoopInbox` 使用该共享投影执行结构化命令与仅供 loop 使用的领取操作。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装提示词与工具,并运行 `agent/pre-step`;fallback 在监听器执行后投影运行时上下文。被拒绝的决定或空的首批输入不打开步骤。接纳后的首次尝试先记录 `step/start`,再运行 `agent/request` waterfall 与 `prepareCall()`;这两个异步阶段都看不到待提交的系统提示词与已接纳用户消息进入历史,在任一阶段取消都不会提交这两者。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、刷新已注册运行时事实而不重新组装绑定的提示词或工具、仅在首次尝试追加已接纳的 `user/message` 批次并原位替换循环自有上下文候选、恢复被移除的运行时上下文、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用同一份已渲染组装结果,不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上,或有效文本不同的新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。新序列开始时若最新存活节点已经持有该渲染文本,则不产生系统事件,请求保留原有前缀。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或有效文本不同的新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`,或 `session.surface.contentGeneration` 自附接或上次请求以来发生变化(替换或图片省略决定)。仅当已准备路由未声明 `toolUpdate` 时,可见工具 schema 变化才强制归并提示词。同时支持工具和系统提示词更新的路由可以追加变更后的提示词并激活新增工具,无需改写此前消息。如果其他准入条件替换系统节点,请求会以当前工具声明开启新序列。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。
@@ -146,8 +148,6 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄
-----
-空的普通输入仍是带身份的 inbox 控制记录,可以唤醒新轮次,但不向模型消息面添加 `user/message`。它经过正常 pre-step 准入;被拒绝或移除的输入不授权模型请求。轮次和步骤结束沿用正常的成功、失败与取消处理。
-
## 模型体验
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index 619cca96ba..8150c334cc 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -455,12 +455,15 @@ export class ReactLoopAgent implements Agent {
let contextAdmitted = false
if (firstAttempt) {
for (const message of decision.messages) {
- if (message.source.kind === 'runtime-context') {
+ if (message.source.kind === 'retry') {
+ if (message.content.length > 0) throw new LlmError('Retry control input must be empty', 'INVALID_REQUEST')
+ this.session.append('agent/retry', { turn, step, inputId: message.id, source: message.source })
+ } else if (message.source.kind === 'runtime-context') {
if (context !== undefined && !contextAdmitted) {
this.session.append('user/message', context, { surfaceOp: 'append' })
contextAdmitted = true
}
- } else if (message.source.kind !== 'user' || message.content.length > 0) {
+ } else {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
}
diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts
index 16c07fcf4e..ed95db70b3 100644
--- a/packages/core/agent-loop/tests/request-error.spec.ts
+++ b/packages/core/agent-loop/tests/request-error.spec.ts
@@ -32,15 +32,16 @@ function fail(message: string, code: string): () => never {
}
describe('agent/request-error', () => {
- it('continues repeated failures through empty followups with balanced turns and one admitted user message', async () => {
+ it('records repeated retry controls with balanced turns and no additional model messages', async () => {
const adapter = new MockAdapter([fail('busy', 'SERVER'), fail('still busy', 'SERVER'), textResponse('done')])
const ctx = await harness(adapter)
onTestFinished(() => ctx.fiber.dispose())
- const agent = await ctx.agentLoop.create(SessionId('empty-continuation'), { provider: 'mock', model: 'mock' })
+ const agent = await ctx.agentLoop.create(SessionId('retry-controls'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } }))
await agent.whenIdle()
- for (let index = 0; index < 2; index++) {
- agent.followup(createUserMessage({ content: [], source: { kind: 'user' } }))
+ const controls = Array.from({ length: 2 }, () => createUserMessage({ content: [], source: { kind: 'retry' } }))
+ for (const control of controls) {
+ agent.followup(control)
await agent.whenIdle()
}
expect(adapter.requests).toHaveLength(3)
@@ -50,6 +51,9 @@ describe('agent/request-error', () => {
}
const events = agent.session.snapshotEvents()
expect(events.filter(event => event.type === 'user/message')).toHaveLength(1)
+ expect(events.flatMap(event => event.type === 'agent/retry' ? [event.data] : [])).toEqual(
+ controls.map((control, index) => ({ turn: index + 2, step: 1, inputId: control.id, source: { kind: 'retry' } })),
+ )
expect(events.flatMap(event => event.type === 'turn/start' || event.type === 'turn/end'
? [`${event.type}:${event.data.turn}`] : []))
.toEqual(['turn/start:1', 'turn/end:1', 'turn/start:2', 'turn/end:2', 'turn/start:3', 'turn/end:3'])
@@ -57,6 +61,26 @@ describe('agent/request-error', () => {
.toEqual(['error', 'error', 'completed'])
})
+ it('preserves ordinary empty user input and rejects retry controls containing content', async () => {
+ const adapter = new MockAdapter([textResponse('done')])
+ const ctx = await harness(adapter)
+ onTestFinished(() => ctx.fiber.dispose())
+ const agent = await ctx.agentLoop.create(SessionId('retry-validation'), { provider: 'mock', model: 'mock' })
+ const empty = createUserMessage({ content: [], source: { kind: 'user' } })
+ agent.followup(empty)
+ await agent.whenIdle()
+ expect(adapter.requests[0]?.messages.filter(message => message.role === 'user')).toEqual([empty])
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'must not disappear' }], source: { kind: 'retry' } }))
+ await agent.whenIdle()
+ expect(adapter.requests).toHaveLength(1)
+ const events = agent.session.snapshotEvents()
+ expect(events.filter(event => event.type === 'agent/retry')).toHaveLength(0)
+ expect(events.filter(event => event.type === 'user/message')).toMatchObject([{ data: empty }])
+ expect(events.filter(event => event.type === 'turn/end').at(-1)).toMatchObject({
+ data: { turn: 2, reason: { kind: 'error', error: { code: 'INVALID_REQUEST' } } },
+ })
+ })
+
it('does not offer middleware failures to request recovery', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml
index a7bdc11ed0..44fd486abc 100644
--- a/packages/core/agent/README.i18n.yaml
+++ b/packages/core/agent/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 85b0ff72a187110f
zh: 3f61c1568d27b8bc
/deepseek-ai-dsh-agent/use-this-package/drive-an-agent-s-conversation:
- en: 71e258897e2e7d73
- zh: abf0991012faa17e
+ en: 09f6d66dddba9ae7
+ zh: f3cd42db2375b705
/deepseek-ai-dsh-agent/use-this-package/scope-registrations-to-one-agent:
en: 2594a1e02adea801
zh: cae8860369728efb
@@ -60,8 +60,8 @@
en: e48eae6c7de88cf8
zh: 1f5b37bdb0e53788
/deepseek-ai-dsh-agent/further-exploration:
- en: faf384f89b18b671
- zh: 42400f79f5ab84b0
+ en: 4ef57cfafa344581
+ zh: 73575876bfdc7a18
/deepseek-ai-dsh-agent/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index aba28e301e..441d453f7f 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -55,7 +55,7 @@ For scoped switch notices, call `installModelSelection(agentCtx)` from `dsh-agen
### Drive an agent's conversation
-The handle's methods route identified user-role messages into the agent's inbox. `followup()` queues an ordinary next-turn prompt and wakes the driver; `steer()` submits next-step input and wakes it; `inject()` adds model-facing context without waking the driver, so it lands in the next admitted step. `cancel(cause)` aborts the active activity and, unless `keepInbox` is set, clears pending work; `whenIdle()` resolves after the whole agent reaches quiescence.
+The handle's methods route identified user-role messages into the agent's inbox. `followup()` queues an ordinary next-turn prompt and wakes the driver; `steer()` submits next-step input and wakes it; `inject()` adds model-facing context without waking the driver, so it lands in the next admitted step. An empty input with `source.kind: 'retry'` requests another model call through the same inbox; the shipped Loop records its acceptance as `agent/retry` without adding model-visible content. Ordinary empty user inputs retain ordinary message admission. `cancel(cause)` aborts the active activity and, unless `keepInbox` is set, clears pending work; `whenIdle()` resolves after the whole agent reaches quiescence.
```text
handle.agent.followup({
@@ -150,8 +150,6 @@ The package-level contract is enough for most consumers; read these when you nee
-----
-An ordinary `followup` with `content: []` is an explicit request to start a new turn from committed history. It retains the identified inbox receipt and normal lifecycle without creating a model-visible user message. A pre-step rejection or a decision rewritten to an empty batch still prevents the request.
-
## Model Experience
diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md
index c317176430..66367a5f6e 100644
--- a/packages/core/agent/README.zh.md
+++ b/packages/core/agent/README.zh.md
@@ -55,7 +55,7 @@ await handle.dispose() // stops the loop, unregisters, removes the session, un
### 驱动 agent 的对话
-句柄的方法把带标识的 user 角色消息路由进 agent 的收件箱。`followup()` 排队一条普通的下一个轮次提示词并唤醒驱动器;`steer()` 提交下一步输入并唤醒它;`inject()` 添加面向模型的上下文但不唤醒驱动器,因此它落在下一个被接纳的步骤中。`cancel(cause)` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;`whenIdle()` 会在整个 agent 达到完全停稳后完成。
+句柄的方法把带标识的 user 角色消息路由进 agent 的收件箱。`followup()` 排队一条普通的下一个轮次提示词并唤醒驱动器;`steer()` 提交下一步输入并唤醒它;`inject()` 添加面向模型的上下文但不唤醒驱动器,因此它落在下一个被接纳的步骤中。带 `source.kind: 'retry'` 的空输入通过同一 inbox 请求再次调用模型;内置 Loop 将接受记录为 `agent/retry`,不增加模型可见内容。普通空用户输入仍遵循普通消息准入。`cancel(cause)` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;`whenIdle()` 会在整个 agent 达到完全停稳后完成。
```text
handle.agent.followup({
@@ -150,8 +150,6 @@ await handle.agent.whenIdle()
-----
-普通 `followup` 的 `content: []` 明确请求基于已提交历史启动新轮次。它保留带身份的 inbox 回执和正常生命周期,不生成模型可见的用户消息。pre-step 拒绝或改写为空批次的决策仍会阻止请求。
-
## 模型体验
diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts
index 58d883721c..5ae5c35aff 100644
--- a/packages/core/agent/src/runtime-types.ts
+++ b/packages/core/agent/src/runtime-types.ts
@@ -232,7 +232,8 @@ declare module './types.ts' {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
- * Empty ordinary input drives work without becoming a model-visible message.
+ * A retry-source input must be empty; admission records `agent/retry`
+ * without adding a model-visible message.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
@@ -245,9 +246,8 @@ declare module './types.ts' {
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
/**
- * Queue an ordinary follow-up turn and wake the driver. The item becomes the
- * sole ordinary message of its own turn. An empty ordinary input starts a
- * turn from committed history without appending a model-visible user message.
+ * Queue a follow-up turn and wake the driver. The item is its sole ordinary
+ * prompt, or an empty retry-source control with no model-visible message.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts
index 06d2c88ab1..defbd94bec 100644
--- a/packages/core/agent/src/types.ts
+++ b/packages/core/agent/src/types.ts
@@ -4,8 +4,8 @@
* @module @deepseek-ai/dsh-agent/types
*/
-import type { ReasoningEffortId } from '@deepseek-ai/dsh-llm/brand'
-import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm/brand'
+import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm/types'
// Type-only: the Workspace registry's archive-admission family map this registry merges `turn` into.
import type {} from '@deepseek-ai/dsh-workspace/types'
import type { OptionalSessionSeq, SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
@@ -18,6 +18,13 @@ export interface Agent {
readonly id: SessionId
}
+declare module '@deepseek-ai/dsh-llm' {
+ interface MessageSourceMap {
+ /** Empty control input requesting a model call without adding user text. */
+ retry: { kind: 'retry' }
+ }
+}
+
declare module '@deepseek-ai/dsh-workspace/types' {
interface SessionActivityKindMap {
/** The session's own Agent is inside a turn, including one waiting for an approval or an answer. */
@@ -102,6 +109,13 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
+ /** An admitted retry control, correlated with its durable inbox input; never enters model history. */
+ 'agent/retry': {
+ turn: number
+ step: number
+ inputId: MessageId
+ source: Extract
+ }
/**
* One normalized mutation of an agent's durable pending-message lists.
* The session-projection registry applies the committed event before
diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts
index a0f5c17795..71a61005df 100644
--- a/packages/core/session/src/known-event-types.ts
+++ b/packages/core/session/src/known-event-types.ts
@@ -22,6 +22,7 @@
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([
'agent-preset/selected',
'agent/inbox/spliced',
+ 'agent/retry',
'approval/asked',
'approval/decided',
'approval/policy',
diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts
index 4bc8140c02..56f7401b7a 100644
--- a/packages/sdk/client/src/client.ts
+++ b/packages/sdk/client/src/client.ts
@@ -344,7 +344,7 @@ export class HarnessClient {
/**
* Queue one prompt and return its durable inbox identity.
* @param sessionId - target session; an unknown id creates it.
- * @param contentBlocks - the user message, sent verbatim.
+ * @param contentBlocks - user content, or an empty array to retry without adding a model-visible message.
* @returns the queued message id.
*/
async prompt(sessionId: string, contentBlocks: SdkPromptContentBlock[]): Promise {
diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml
index 2aed1dc438..c29b0abca2 100644
--- a/packages/sdk/protocol/README.i18n.yaml
+++ b/packages/sdk/protocol/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 9dcd999ae8c66b71
zh: 174c5fe0cbb0a9e4
/deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics:
- en: 3cf5d4ffbaf37f69
- zh: 23c4450e31c91d67
+ en: f5f0ff9090bdd076
+ zh: 83e43639a892879a
/deepseek-ai-dsh-sdk-protocol/understand-the-implementation:
en: a11b136d8296deda
zh: 85081da5be53c9e1
diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md
index 93a34454af..2240c78cf7 100644
--- a/packages/sdk/protocol/README.md
+++ b/packages/sdk/protocol/README.md
@@ -69,7 +69,7 @@ An answering host sets `initialize.capabilities.userQuestions: true`. The runtim
### Payload semantics
-`SessionPromptResult.messageId` identifies the queued user message; it does not identify a later assistant message, turn ending, or prompt result. `SdkPromptContentBlock` accepts ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission lets the selected adapter's exact-model default apply. The server resolves the exact route during initialization and rejects `session/prompt` until that handshake succeeds, so a missing adapter, unavailable model, or unsupported effort cannot fall back to constructor defaults. `SubagentFinishedNotification.lastAssistantMessage` carries the child's last non-empty assistant message, or its accumulated assistant text when no such message exists; the field is absent when the child produced neither. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. Notification payloads depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`), so the session vocabulary is part of the wire contract.
+`SessionPromptResult.messageId` identifies the queued prompt or retry control; it does not identify a later assistant message, turn ending, or prompt result. An empty `contentBlocks` array queues an empty retry-source control; admission records `agent/retry` rather than `user/message`. `SdkPromptContentBlock` accepts ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission lets the selected adapter's exact-model default apply. The server resolves the exact route during initialization and rejects `session/prompt` until that handshake succeeds, so a missing adapter, unavailable model, or unsupported effort cannot fall back to constructor defaults. `SubagentFinishedNotification.lastAssistantMessage` carries the child's last non-empty assistant message, or its accumulated assistant text when no such message exists; the field is absent when the child produced neither. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. Notification payloads depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`), so the session vocabulary is part of the wire contract.
-----
diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md
index f81f58c223..f5339e55c9 100644
--- a/packages/sdk/protocol/README.zh.md
+++ b/packages/sdk/protocol/README.zh.md
@@ -69,7 +69,7 @@ kind: "package-library"
### 载荷语义
-`SessionPromptResult.messageId` 标识已排队的用户消息;它不标识后续的助手消息、轮次结束或提示词结果。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器在入队前把编码图像转换为持久引用。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时应用所选适配器的确切模型默认值。服务器会在初始化期间解析确切路由,并在握手成功前拒绝 `session/prompt`,因此缺少适配器、模型不可用或推理强度不受支持时,不会回退到构造期默认值。`SubagentFinishedNotification.lastAssistantMessage` 携带子 agent 最后一条非空 assistant 消息;若不存在这类消息,则携带其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。通知载荷依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`),因此会话词汇是协议格式约定的一部分。
+`SessionPromptResult.messageId` 标识已排队的提示词或重试控制输入;它不标识后续的助手消息、轮次结束或提示词结果。空 `contentBlocks` 数组将空的 retry 来源控制输入排入队列;准入记录 `agent/retry`,而非 `user/message`。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器在入队前把编码图像转换为持久引用。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时应用所选适配器的确切模型默认值。服务器会在初始化期间解析确切路由,并在握手成功前拒绝 `session/prompt`,因此缺少适配器、模型不可用或推理强度不受支持时,不会回退到构造期默认值。`SubagentFinishedNotification.lastAssistantMessage` 携带子 agent 最后一条非空 assistant 消息;若不存在这类消息,则携带其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。通知载荷依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`),因此会话词汇是协议格式约定的一部分。
-----
diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts
index cfa746cac2..dd3c86a930 100644
--- a/packages/sdk/protocol/src/types.ts
+++ b/packages/sdk/protocol/src/types.ts
@@ -67,7 +67,7 @@ export interface InitializeResult {
export interface SessionPromptParams {
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
sessionId: string
- /** The prompt content blocks, sent verbatim as the user message. */
+ /** Prompt content; an empty array requests a retry control without a model-visible user message. */
contentBlocks: SdkPromptContentBlock[]
}
@@ -85,7 +85,7 @@ export type SdkPromptContentBlock = ContentBlock | SdkEncodedImageBlock
/** Durable enqueue receipt for one prompt. */
export interface SessionPromptResult {
- /** Identity of the queued user message. */
+ /** Identity of the queued prompt or retry control. */
messageId: string
}
diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml
index b56838412f..7ad56c42a9 100644
--- a/packages/sdk/server/README.i18n.yaml
+++ b/packages/sdk/server/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 784b68b5a498f2d5
zh: 2c3c5a1490845179
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/what-sdk-clients-can-do:
- en: 3e657fa4c9586454
- zh: 26d572f91e124cc5
+ en: 0936bb4ac5f59851
+ zh: fb91df93242fc80e
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/native-user-questions:
en: 5100c311c8476f93
zh: 60d4bc3165731246
@@ -60,8 +60,8 @@
en: 04d1b73cd8e06dfe
zh: cd67fbbfdf39f776
/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/what-the-model-sees:
- en: 2b5a80ec4b4df11e
- zh: e459fe77cc14d9b8
+ en: 57ed296ea4f0ec42
+ zh: a0bf3746ce9bdf01
/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/token-effect:
en: 372ed851986b76d2
zh: 075b94a565275368
diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md
index e9007e593c..f3dcb00c29 100644
--- a/packages/sdk/server/README.md
+++ b/packages/sdk/server/README.md
@@ -53,7 +53,7 @@ Stdout carries only JSON-RPC frames, so clients can parse every byte; diagnostic
### What SDK clients can do
-`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. The handshake returns the wire-stable identity `deepseek-harness-sdk-runtime`. The server validates the provider/model route and optional non-empty `reasoningEffort` through the selected adapter before it stores them; omission stores no effort, so the model retains its own default. An optional positive `maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants, while omission applies the selected adapter or provider route default. JSON-RPC requests may dispatch concurrently, so `session/prompt` rejects until one `initialize` has completed successfully; clients must await the handshake before sending prompts. An accepted prompt queues one identified user message and immediately returns `{ messageId }`; the server then streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`. It does not assign an assistant message or `turn/end` to a prompt, and independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition.
+`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. The handshake returns the wire-stable identity `deepseek-harness-sdk-runtime`. The server validates the provider/model route and optional non-empty `reasoningEffort` through the selected adapter before it stores them; omission stores no effort, so the model retains its own default. An optional positive `maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants, while omission applies the selected adapter or provider route default. JSON-RPC requests may dispatch concurrently, so `session/prompt` rejects until one `initialize` has completed successfully; clients must await the handshake before sending prompts. An accepted prompt queues one identified input and immediately returns `{ messageId }`; the server then streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`. It does not assign an assistant message or `turn/end` to a prompt, and independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition.
`session/wait` observes an existing SDK-owned session through managed descendant completion and any resulting root turns. It rejects unknown or retired roots, never creates a session, and responds after the root stays idle across the descendant check. Idle children with parked inbox messages remain resident but do not delay this response; later waking input can resume them. Session notifications emitted before completion precede the response on the stdio transport. A live Agent failure remains associated with that exact session until a later committed `turn/end` accounts for the outcome; otherwise the wait rejects, including when the failure preceded the wait request. Errors already represented by a terminal remain in Session events, and later successful activity clears an earlier live failure.
@@ -120,7 +120,7 @@ Read these pages when the plugin contract is not enough. They move from the wire
#### What the model sees
-For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition.
+For each non-empty accepted `session/prompt`, text and durable content references enter one user message verbatim. An empty content array queues a retry-source control; Loop admission records `agent/retry` without adding model-visible content. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition.
#### Token effect
diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md
index 56ae2f8bdf..4d56722a7f 100644
--- a/packages/sdk/server/README.zh.md
+++ b/packages/sdk/server/README.zh.md
@@ -53,7 +53,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应
### SDK 客户端可以做什么
-`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。握手返回协议稳定标识 `deepseek-harness-sdk-runtime`。服务器会通过所选适配器校验提供方/模型路由与可选的非空 `reasoningEffort`,再保存这些值;省略时不会保存推理强度,因此模型保留自身默认值。可选的正整数 `maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限,省略时则应用所选适配器或提供方路由的默认值。JSON-RPC 请求可能并发分派,因此在一次 `initialize` 成功完成之前,`session/prompt` 会拒绝;客户端必须等待握手完成后再发送提示词。已接受的提示词会把一条带标识的用户消息排入队列,并立即返回 `{ messageId }`;服务器随后把每个持久事实作为 `session.event`、把整个 agent 生命周期的每次状态转换作为 `session.status` 流式发出。它不会把某条助手消息或 `turn/end` 归属于某个提示词,同一会话上的独立请求可以继续排入更多工作。持久化根目录与 persona 来自外围组合。
+`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。握手返回协议稳定标识 `deepseek-harness-sdk-runtime`。服务器会通过所选适配器校验提供方/模型路由与可选的非空 `reasoningEffort`,再保存这些值;省略时不会保存推理强度,因此模型保留自身默认值。可选的正整数 `maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限,省略时则应用所选适配器或提供方路由的默认值。JSON-RPC 请求可能并发分派,因此在一次 `initialize` 成功完成之前,`session/prompt` 会拒绝;客户端必须等待握手完成后再发送提示词。已接受的提示词会把一条带标识的输入排入队列,并立即返回 `{ messageId }`;服务器随后把每个持久事实作为 `session.event`、把整个 agent 生命周期的每次状态转换作为 `session.status` 流式发出。它不会把某条助手消息或 `turn/end` 归属于某个提示词,同一会话上的独立请求可以继续排入更多工作。持久化根目录与 persona 来自外围组合。
`session/wait` 观察已有 SDK 会话,直到受管理后代及其触发的根 Agent 后续轮次完成。它拒绝未知或已释放的根 Agent,不会创建会话,并在后代检查期间根 Agent 持续空闲后响应。收件箱消息处于停放状态的空闲子级继续驻留,但不延迟此响应;后续唤醒输入可使其继续运行。完成前发出的会话通知在 stdio 传输上先于响应到达。Agent 的运行时错误会保留在对应的确切会话中,直到后续提交的 `turn/end` 记录结果;否则等待请求会失败,即使错误发生在等待请求之前。终止事件已经记录的错误仍通过会话事件表达,后续成功的活动会清除较早的运行时错误。
@@ -120,7 +120,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应
#### 模型看到什么
-对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。
+对于每个非空且已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。空内容数组将 retry 来源控制输入排入队列;Loop 准入记录 `agent/retry`,不增加模型可见内容。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。
#### Token 影响
diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts
index 0dceccc59c..8bb25cb1db 100644
--- a/packages/sdk/server/src/server.ts
+++ b/packages/sdk/server/src/server.ts
@@ -247,7 +247,7 @@ export class HarnessSdkJsonRpcServer {
/**
* Queue one identified prompt without assigning later activity to it.
- * @param params - target session and user content.
+ * @param params - target session and user content; an empty array queues a retry control.
* @returns the durable message identity.
*/
async prompt(params: SessionPromptParams): Promise {
@@ -263,7 +263,7 @@ export class HarnessSdkJsonRpcServer {
this.assertLiveAgent(rec, params.sessionId)
const message = createUserMessage({
content,
- source: { kind: 'user' },
+ source: { kind: content.length === 0 ? 'retry' : 'user' },
})
rec.handle.agent.followup(message)
return { messageId: message.id }
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index fe17f6f952..0ea6f3831c 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -1925,6 +1925,11 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho
raise AssertionError(f"scheduler recovery expected two serialized model requests: {requests}")
logs = read_session_logs(sessions)
records = logs[RECOVERY_SESSION_ID]
+ prompts = [record for record in records if record.get("type") == "user/message"
+ and record["data"]["source"].get("kind") == "user"]
+ retries = [record for record in records if record.get("type") == "agent/retry"]
+ if len(prompts) != 1 or len(retries) != 1:
+ raise AssertionError("scheduler recovery must record one original prompt and one retry control")
calls = [record["data"]["callId"] for record in records if record.get("type") == "tool/call"]
if calls != list(RECOVERY_CALL_IDS[:2]):
raise AssertionError(f"scheduler recovery dispatched the unstarted call: {calls}")
diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json
index 7172a0edd1..e023466dea 100644
--- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json
+++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json
@@ -57,6 +57,7 @@
"turn/start",
"agent/inbox/spliced",
"step/start",
+ "agent/retry",
"assistant/message",
"step/end",
"turn/end"
@@ -70,6 +71,7 @@
"session.event",
"session.event",
"session.event",
+ "session.event",
"session.status"
]
}
diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl
index 37ccfaa185..acab2bcdb6 100644
--- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl
+++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl
@@ -21,10 +21,11 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
-{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"retry"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"agent/retry","data":{"turn":2,"step":1,"inputId":"{{messageId}}","source":{"kind":"retry"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts
index ddd859bd38..c4c5a27aa7 100644
--- a/snapshots/sdk/sdk.snapshot.ts
+++ b/snapshots/sdk/sdk.snapshot.ts
@@ -485,23 +485,15 @@ function modelFromSession(log: string): { provider: string; model: string } {
function turnActions(log: string): TurnAction[] {
const actions: TurnAction[] = []
let current: TurnAction | undefined
- let emptyQueued = false
for (const record of records(log)) {
- if (record.type === 'agent/inbox/spliced') {
- const data = record.data as JsonObject | undefined
- if (data?.target === 'next-turn' && Array.isArray(data.inserted)) {
- emptyQueued ||= data.inserted.some(value => {
- const message = value as JsonObject
- return (message.source as JsonObject | undefined)?.kind === 'user'
- && Array.isArray(message.content) && message.content.length === 0
- })
- }
- }
if (record.type === 'turn/start') {
const data = record.data as JsonObject | undefined
if (typeof data?.turn !== 'number') throw new Error('SDK snapshot turn/start has no turn')
- current = { turn: data.turn, ...emptyQueued ? { content: [] } : {} }
- emptyQueued = false
+ current = { turn: data.turn }
+ continue
+ }
+ if (record.type === 'agent/retry' && current !== undefined) {
+ current = { turn: current.turn, content: [] }
continue
}
if (record.type === 'user/message' && current !== undefined && current.content === undefined) {
@@ -1132,6 +1124,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
if (scenario.name === 'tool-scheduler-recovery') {
expect(records(ordered[0]!.content).filter(event => event.type === 'user/message'
&& (event.data['source'] as { kind: string }).kind === 'user')).toHaveLength(1)
+ expect(records(ordered[0]!.content).filter(event => event.type === 'agent/retry')).toHaveLength(1)
expect(results).toHaveLength(2)
const events = results.flatMap(result => result.events)
expect(results[1]?.finalResponse, JSON.stringify(events.filter(event => event.type === 'turn/end')))
diff --git a/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl b/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl
index cfe4388d42..01f324070c 100644
--- a/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl
+++ b/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl
@@ -19,12 +19,13 @@
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"retry"},"role":"user","id":"{{sessionId}}"}]}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":23,"time":0,"data":{"turn":2}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}}
{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":2,"step":1}}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":2,"step":1}}}}
-{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/retry","seq":26,"time":0,"data":{"turn":2,"step":1,"inputId":"{{sessionId}}","source":{"kind":"retry"}}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":1}}}}
+{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}}
{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}}
diff --git a/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl b/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl
index 31eb0f7d38..704d83c721 100644
--- a/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl
+++ b/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl
@@ -21,10 +21,11 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}
-{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"retry"},"role":"user","id":"{{message:6}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
+{"type":"agent/retry","data":{"turn":2,"step":1,"inputId":"{{message:6}}","source":{"kind":"retry"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
From 40599db7c85fb030b2086bd4da81c32c3170ac41 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Tue, 27 Oct 2026 12:11:22 +0800
Subject: [PATCH 016/196] test(agent): verify retry control identity across SDK
recovery
---
apps/web/tests/live-interactions.e2e.ts | 6 +++---
docs/architecture.i18n.yaml | 4 ++--
docs/architecture.md | 4 ++--
docs/architecture.zh.md | 4 ++--
docs/persistence-catalog.i18n.yaml | 12 ++++++------
docs/persistence-catalog.md | 13 +++++++++----
docs/persistence-catalog.zh.md | 13 +++++++++----
.../historical-formats/README.i18n.yaml | 4 ++--
.../historical-formats/README.md | 2 +-
.../historical-formats/README.zh.md | 2 +-
docs/persistence-schema.json | 2 +-
.../tests/empty-prompt.host.spec.ts | 7 ++++---
scripts/smoke-python-runtime.py | 8 ++++++++
13 files changed, 50 insertions(+), 31 deletions(-)
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index bde84fe2d1..394da150c3 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -478,9 +478,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? [event.data] : [])
expect(userMessages.map(message => message.content)).toEqual([[{ type: 'text', text: PROMPT }]])
- expect(sessionEvents.filter(event => event.type === 'agent/retry')).toEqual([
- expect.objectContaining({ data: expect.objectContaining({ turn: 2, step: 1, source: expect.objectContaining({ kind: 'retry' }) }) }),
- ])
+ const retries = sessionEvents.filter(event => event.type === 'agent/retry')
+ expect(retries).toHaveLength(1)
+ expect(retries[0]?.data).toMatchObject({ turn: 2, step: 1, source: { kind: 'retry' } })
expect(sessionEvents.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(sessionEvents.filter(event => event.type === 'turn/end')).toHaveLength(2)
expect(await resume.count()).toBe(0)
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 5dfa2c2995..ca357a4a9c 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -24,8 +24,8 @@
en: 693407efb233f961
zh: 37b9dd0677d4405c
/deepseek-harness-architecture/turn-flow:
- en: 3770eacd9c3bae28
- zh: bd7b8a0374ba0ff8
+ en: d67fe476bec779ea
+ zh: 774a0913a8fb03d0
/deepseek-harness-architecture/session-log:
en: 8a7f19b95d426c99
zh: d5bb0538404e20ea
diff --git a/docs/architecture.md b/docs/architecture.md
index 064026d4d2..1ffee7bf2f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -107,9 +107,9 @@ turn/end
Factory setup follows SessionStore entry and `session/created`, preceding `agent/created`; closing events commit before removal.
-One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
+`inbox` persists pending input; injected context waits for waking input. Retry controls record `agent/retry` without changing turn settlement.
-Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Empty retry-source controls record `agent/retry` instead of entering model history; they retain ordinary inbox and turn lifecycle. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
+Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details.
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index e836ab7452..42297aca67 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -111,9 +111,9 @@ turn/end
工厂 setup 在 Session 进入 SessionStore 并完成 `session/created` 之后、`agent/created` 之前运行;收尾事件在移除之前提交。
-输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
+`inbox` 持久化待消费输入;注入上下文等待唤醒输入。重试控制输入记录 `agent/retry`,不改变轮次结算。
-循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 空的 retry 来源控制输入记录 `agent/retry`,不进入模型历史;inbox 与轮次生命周期仍按普通流程处理。
+循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 3214b08f10..546008ca82 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -21,8 +21,8 @@
en: 9188862058c6986b
zh: 9188862058c6986b
/session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only:
- en: 9ae614c041a27169
- zh: 8d669dd582b88f79
+ en: b647f3f80ec8b407
+ zh: d5e43856cfd85ddb
/session-persistence-event-catalog/events/agent/agent-retry-log-only:
en: 559e0484e8ae8904
zh: 8967a336e5aef50c
@@ -144,8 +144,8 @@
en: 9edf4ef31e60e880
zh: 9edf4ef31e60e880
/session-persistence-event-catalog/events/model/model-selection-log-only:
- en: 404c54b08c088438
- zh: eb86ed4e7d0e9228
+ en: 608081c311c1bf13
+ zh: bcd14effec5f6c78
/session-persistence-event-catalog/events/permission:
en: 19917bc5438e60af
zh: 19917bc5438e60af
@@ -1545,8 +1545,8 @@
en: 930d9c64fe39857d
zh: 65cd07dc3c7789db
/session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start:
- en: 4edbaf2329a66619
- zh: 7d29428252d9d84f
+ en: 473f9e6ab2b776dc
+ zh: ce701e3ff6ab63ab
/session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream:
en: 8e86cd838e3e4a49
zh: f5fec2a8adf1f3cc
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 70e16e7ce7..f3cfff9e7f 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -186,7 +186,7 @@ Sources: [`packages/core/session/src/types.ts:439`](../packages/core/session/src
}
```
-Source: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts)
@@ -194,7 +194,12 @@ Source: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/type
```ts persistence-catalog
/** An admitted retry control, correlated with its durable inbox input; never enters model history. */
-'agent/retry': { turn: number; step: number; inputId: MessageId; source: Extract }
+'agent/retry': {
+ turn: number
+ step: number
+ inputId: MessageId
+ source: Extract
+}
```
Types: [MessageSource](subsystems/core.md)
@@ -714,7 +719,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
'model/selection': ModelSelection
```
-Source: [`packages/core/agent/src/types.ts:130`](../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:135`](../packages/core/agent/src/types.ts)
### `permission/*`
@@ -6468,7 +6473,7 @@ Sources: [`packages/core/agent/src/types.ts:113`](../packages/core/agent/src/typ
SHA-256: `c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039`
-Sources: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+Sources: [`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts)
| Property | Presence | Type |
|---|---|---|
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 47f1c30ed9..05543778cb 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -188,7 +188,7 @@ export type SessionEvent = {
}
```
-来源:[`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts)
@@ -196,7 +196,12 @@ export type SessionEvent = {
```ts persistence-catalog
/** An admitted retry control, correlated with its durable inbox input; never enters model history. */
-'agent/retry': { turn: number; step: number; inputId: MessageId; source: Extract }
+'agent/retry': {
+ turn: number
+ step: number
+ inputId: MessageId
+ source: Extract
+}
```
类型:[MessageSource](subsystems/core.zh.md)
@@ -716,7 +721,7 @@ export type SessionEvent = {
'model/selection': ModelSelection
```
-来源:[`packages/core/agent/src/types.ts:130`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:135`](../packages/core/agent/src/types.ts)
### `permission/*`
@@ -6470,7 +6475,7 @@ SHA-256: `2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3`
SHA-256: `c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039`
-来源:[`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml
index 40d842e063..cae6902733 100644
--- a/docs/persistence-changes/historical-formats/README.i18n.yaml
+++ b/docs/persistence-changes/historical-formats/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 14c57f30e676f6e3
zh: b1a0ccf26fab3727
/session-persistence-formats/format-references:
- en: e3511a4ac3bc4457
- zh: 828f3c39f80d5370
+ en: 83fd4c4276a2600d
+ zh: 4f3431c960a7f8da
/session-persistence-formats/scope-and-evidence:
en: b220062139ddac1e
zh: 7c4c0efe05dafa9c
diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md
index 74c575b300..da1a2d629e 100644
--- a/docs/persistence-changes/historical-formats/README.md
+++ b/docs/persistence-changes/historical-formats/README.md
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
-| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 622 |
+| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 66 / 629 |
diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md
index 12eb600771..8a2460c46c 100644
--- a/docs/persistence-changes/historical-formats/README.zh.md
+++ b/docs/persistence-changes/historical-formats/README.zh.md
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
-| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 622 |
+| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 66 / 629 |
diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json
index 07e2ce9415..f7630fdb61 100644
--- a/docs/persistence-schema.json
+++ b/docs/persistence-schema.json
@@ -568,7 +568,7 @@
{"digest":"c589e938cc0e4989df8b82abef6339de621f0dc4fe62c230d145b94996af7f92","names":[],"sources":[]},
{"digest":"c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6","names":["packages/util/values/src/index.ts#JsonValue"],"sources":["packages/util/values/src/index.ts:4"]},
{"digest":"c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5","names":[],"sources":[]},
- {"digest":"c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039","names":[],"sources":["packages/core/agent/src/types.ts:119"]},
+ {"digest":"c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039","names":[],"sources":["packages/core/agent/src/types.ts:124"]},
{"digest":"c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262","names":[],"sources":[]},
{"digest":"c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff","names":[],"sources":[]},
{"digest":"c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954","names":[],"sources":[]},
diff --git a/packages/api/session-controller/tests/empty-prompt.host.spec.ts b/packages/api/session-controller/tests/empty-prompt.host.spec.ts
index 835d7ae442..50d4871ee8 100644
--- a/packages/api/session-controller/tests/empty-prompt.host.spec.ts
+++ b/packages/api/session-controller/tests/empty-prompt.host.spec.ts
@@ -43,9 +43,10 @@ it('records retry controls without user messages or duplicate requests', async (
const messages = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? [event.data] : [])
expect(messages).toHaveLength(1)
const retries = events.flatMap(event => event.type === 'agent/retry' ? [event.data] : [])
- expect(retries).toEqual([
- { turn: 2, step: 1, inputId: expect.any(String), source: { kind: 'retry', rpcId: 'continue-1' } },
- { turn: 3, step: 1, inputId: expect.any(String), source: { kind: 'retry', rpcId: 'continue-2' } },
+ expect(retries).toHaveLength(2)
+ expect(retries).toMatchObject([
+ { turn: 2, step: 1, source: { kind: 'retry', rpcId: 'continue-1' } },
+ { turn: 3, step: 1, source: { kind: 'retry', rpcId: 'continue-2' } },
])
const controls = events.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : [])
.filter(message => message.source.kind === 'retry')
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index 0ea6f3831c..24bc9f15f1 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -1930,6 +1930,13 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho
retries = [record for record in records if record.get("type") == "agent/retry"]
if len(prompts) != 1 or len(retries) != 1:
raise AssertionError("scheduler recovery must record one original prompt and one retry control")
+ retry_id = retries[0]["data"]["inputId"]
+ queued_retry_ids = [message.get("id") for record in records
+ if record.get("type") == "agent/inbox/spliced"
+ for message in record["data"]["inserted"]
+ if message.get("source", {}).get("kind") == "retry"]
+ if not isinstance(retry_id, str) or queued_retry_ids != [retry_id]:
+ raise AssertionError("scheduler recovery lost the retry control's durable inbox identity")
calls = [record["data"]["callId"] for record in records if record.get("type") == "tool/call"]
if calls != list(RECOVERY_CALL_IDS[:2]):
raise AssertionError(f"scheduler recovery dispatched the unstarted call: {calls}")
@@ -1946,6 +1953,7 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho
replacements = [
*snapshot_directory_replacements(root, "{{cwd}}"),
(RECOVERY_SESSION_ID, "{{parent}}"),
+ (retry_id, "{{messageId}}"),
]
result_value = [{
"session_id": result.session_id,
From 22db5af493661e9da3c37d3915ae58029052dd39 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Tue, 27 Oct 2026 12:15:57 +0800
Subject: [PATCH 017/196] docs(agent): record retry control compatibility and
validation
---
.../2026-10-27-retry-control-input.i18n.yaml | 8 ++++----
.../persistence-changes/2026-10-27-retry-control-input.md | 4 ++--
.../2026-10-27-retry-control-input.zh.md | 4 ++--
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml b/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml
index 3513ba5aaa..502105b258 100644
--- a/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.i18n.yaml
@@ -18,11 +18,11 @@
en: 6907136f28e48c90
zh: 02f8298485f3d8e7
/2026-10-27-retry-control-input/compatibility:
- en: 4487a595853a65d6
- zh: d9b356c77ccab559
+ en: ffc72b74956201d6
+ zh: 78cfe9d2353ca739
/2026-10-27-retry-control-input/verification:
- en: da37e5d4769c40fe
- zh: 992f66b1a2c66fb2
+ en: fbd40f2d4da56e1e
+ zh: 82546f33ad58b08b
/2026-10-27-retry-control-input/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.md b/docs/persistence-changes/2026-10-27-retry-control-input.md
index 3faecc4503..10ebe9fe04 100644
--- a/docs/persistence-changes/2026-10-27-retry-control-input.md
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.md
@@ -51,12 +51,12 @@ changes:
## Compatibility
-Updated readers continue accepting older logs with no retry controls. Older readers reject the unknown required agent/retry event before treating a retry as ordinary user content. The retry discriminator also distinguishes queued controls from ordinary empty user messages. API and both SDK consumers are updated together; the current Session format version is retained because event and source discriminators prevent unsafe interpretation.
+Updated readers preserve older logs and ordinary empty user-message admission. Older readers refuse the new required agent/retry event before append or tail repair. A queued control predating acceptance still has the existing identified empty UserMessage representation; an older Loop can consume it using its ordinary empty-input behavior, retaining inbox identity and adding no user text. API and both SDK consumers recognize retry controls and the acceptance event together. The current Session format version is retained; mixed ordinary messages and retry events remain readable by the updated reader.
## Verification
-Source review confirms controls retain existing inbox and turn lifecycle while agent/retry has no model-surface operation. Regression coverage covers repeated retry, input-id correlation, balanced turns, ordinary empty messages, non-empty retry rejection, deduplication, checkpoint replay, browser behavior, and both SDK recovery recordings. Execution is pending post-push validation.
+Focused AgentLoop, Agent, Session API, and SDK-server tests passed: 91 files, 1806 tests, with 100% statements, branches, functions, and lines for agent.ts and continuation.ts. Browser continuation replay and TypeScript/Python SDK scheduler-recovery replay passed; assertions retain one original user message, correlate retry input identity, preserve tool results, and close new turns normally. Current-event and native-source admission tests cover unknown required-event refusal without mutation and preservation of extensible source metadata. Build, aggregate checks, and typed lint passed. doc-sync passed 42 gates; its remaining repository-reference failure is the unchanged python/sdk/uv.lock:113 commit hash.
## Dev Note
diff --git a/docs/persistence-changes/2026-10-27-retry-control-input.zh.md b/docs/persistence-changes/2026-10-27-retry-control-input.zh.md
index 672f1d032c..5729a666e7 100644
--- a/docs/persistence-changes/2026-10-27-retry-control-input.zh.md
+++ b/docs/persistence-changes/2026-10-27-retry-control-input.zh.md
@@ -51,12 +51,12 @@ changes:
## 兼容性
-更新后的读取方继续接受没有重试控制输入的旧日志。旧读取方会拒绝未知必读 agent/retry 事件,不会将重试当作普通用户内容。retry 判别标签也将排队控制输入与普通空用户消息区分开。API 与两套 SDK 消费方同步更新;事件及来源判别标签可阻止不安全解释,因此保留当前 Session 格式版本。
+更新后的读取方保留旧日志含义和普通空用户消息准入。旧读取方会在追加或尾部修复前拒绝新的必读 agent/retry 事件。接受前的排队控制输入仍采用现有带身份的空 UserMessage 表示;旧 Loop 可按其普通空输入行为消费,保留 inbox 身份且不增加用户文本。API 与两套 SDK 消费方同步识别重试控制输入及接受事件。保留当前 Session 格式版本;更新后的读取方可读取普通消息与重试事件混合的日志。
## 验证
-源码审查确认控制输入保留现有 inbox 和轮次生命周期,agent/retry 不携带模型表面操作。回归用例覆盖连续重试、输入身份关联、轮次闭合、普通空消息、拒绝非空重试、去重、检查点回放、浏览器行为及两套 SDK 恢复录制。执行验证将在 push 后进行。
+AgentLoop、Agent、Session API 与 SDK 服务端定向测试通过:91 个文件、1806 个测试,agent.ts 和 continuation.ts 的语句、分支、函数及行覆盖率均为 100%。浏览器继续回放与 TypeScript/Python SDK scheduler-recovery 回放通过;断言保留一条原始用户消息、关联重试输入身份、保留工具结果且新轮次正常闭合。当前事件与原生来源准入测试覆盖未知必读事件在不修改数据的情况下拒绝,以及保留可扩展来源元数据。构建、聚合检查和类型感知 lint 通过。doc-sync 有 42 项通过;剩余仓库引用失败来自未改动的 python/sdk/uv.lock:113 提交哈希。
## 开发备注
From 3be7d56c6d27f7a71447262a85a6c1bcfb7053f2 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Wed, 28 Oct 2026 15:22:54 +0800
Subject: [PATCH 018/196] fix(session-controller): give repeated forks distinct
titles
Fixes #7471
Co-authored-by: imccyu <276526105+imccyu@users.noreply.github.com>
---
.../api/session-controller/README.i18n.yaml | 4 ++--
packages/api/session-controller/README.md | 2 +-
packages/api/session-controller/README.zh.md | 2 +-
.../src/client/contract/sessions.ts | 3 ++-
.../src/client/sessions/service.ts | 14 ++++++++++--
.../tests/sessions-service.client.spec.ts | 22 +++++++++++++++++++
.../src/client/api-catalog.ts | 2 +-
7 files changed, 41 insertions(+), 8 deletions(-)
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 010c41bf94..bb4a228804 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
- en: d3d53e899960d96e
- zh: 16b2bc7fe74215d8
+ en: 4e5e6899044ebd61
+ zh: c6da8d03641e56e9
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 6fd7b24561..2e1bd3e6df 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -64,7 +64,7 @@ During uninterrupted following, transcript and steering echoes stay in Chat thro
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
-Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
+Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation; an increment skips numbers that other listed titles already use. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 8729f555ba..d4a946d8ce 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -64,7 +64,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
-Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
+Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话;递增时会跳过其他已列出标题已使用的编号。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts
index a52d2c155a..52764f83a0 100644
--- a/packages/api/session-controller/src/client/contract/sessions.ts
+++ b/packages/api/session-controller/src/client/contract/sessions.ts
@@ -128,7 +128,8 @@ export interface ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
- * inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.
+ * inherited durable title, skipping numbers listed titles already use,
+ * before resolving. `onCreated` observes the catalogued child before that optional rename.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts
index 4b70888468..282086b7de 100644
--- a/packages/api/session-controller/src/client/sessions/service.ts
+++ b/packages/api/session-controller/src/client/sessions/service.ts
@@ -132,10 +132,18 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
+ * The number keeps increasing while the candidate is already taken.
* @param title - source session's durable title.
+ * @param taken - durable titles of listed sessions.
* @returns the title assigned to the fork child.
*/
-function increasedForkTitle(title: string): string {
+function increasedForkTitle(title: string, taken: ReadonlySet): string {
+ let next = incrementedTitle(title)
+ while (taken.has(next)) next = incrementedTitle(next)
+ return next
+}
+
+function incrementedTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
@@ -480,7 +488,9 @@ export class ClientSessions implements ISessions {
const childId = result.value.sessionId
opts.onCreated?.(childId)
if (sourceTitle !== undefined) {
- const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
+ const list = this.list.getSnapshot()
+ const taken = new Set(list.ids.flatMap(id => list.byId[id]?.title ?? []))
+ const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle, taken))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index 212320fdf6..3043be5b2a 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -1010,6 +1010,28 @@ describe('fork', () => {
})
})
+ it('gives repeated forks distinct titles past numbers listed sessions use', async ({ bench }) => {
+ const b = bench()
+ for (const [id, value] of [['source', 'Roadmap'], ['sibling', 'Roadmap (2)']] as const) {
+ b.svc.handleControlFrame({ type: 'projection', sessionId: sid(id), key: 'title', value, seq: 2 })
+ }
+ await feedList(b, [{ id: 'source' }, { id: 'sibling' }])
+ b.mock.remote.session.rename.mockImplementation((payload) => {
+ const { title } = payload as { title: string }
+ return Promise.resolve(ok({ title, seq: 3 }))
+ })
+
+ for (const child of ['child-1', 'child-2']) {
+ b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid(child) }))
+ await b.svc.fork({ sessionId: sid('source'), increaseTitle: true })
+ }
+
+ expect(b.mock.remote.session.rename.mock.calls).toEqual([
+ [{ sessionId: 'child-1', title: 'Roadmap (1)' }],
+ [{ sessionId: 'child-2', title: 'Roadmap (3)' }],
+ ])
+ })
+
it('sends the exact boundary seq verbatim: callers pass real event seqs', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
index 8777befdb0..3662cd2eb5 100644
--- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
@@ -215,7 +215,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void /** False refuses source migration; omission preserves the Host default. */ allowMigration?: boolean }): Promise',
description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is catalogued and can be explicitly retained.',
- parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.' }],
+ parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title, skipping numbers listed titles already use, before resolving. `onCreated` observes the catalogued child before that optional rename.' }],
returns: 'the child session id.',
throws: ['when the fork fails, or when a requested child-title rename fails after creation.'],
},
From 8818fdd2e0a073847eae48335fb76830df3b3289 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Wed, 28 Oct 2026 15:35:43 +0800
Subject: [PATCH 019/196] docs(session-controller): document fork title limits
Co-authored-by: imccyu <276526105+imccyu@users.noreply.github.com>
---
packages/api/session-controller/README.i18n.yaml | 8 ++++----
packages/api/session-controller/README.md | 3 ++-
packages/api/session-controller/README.zh.md | 3 ++-
.../src/client/contract/sessions.ts | 2 +-
.../src/client/sessions/service.ts | 15 ++++++++++-----
.../tests/sessions-service.client.spec.ts | 1 +
.../src/client/api-catalog.ts | 2 +-
7 files changed, 21 insertions(+), 13 deletions(-)
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index bb4a228804..2df5bf6abc 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
- en: 4e5e6899044ebd61
- zh: c6da8d03641e56e9
+ en: eb59401ac9f8489f
+ zh: 7ab1650c6df67461
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
@@ -33,8 +33,8 @@
en: 22fc0c9dbedd3bb6
zh: fad040ca55fd0e0a
/session-controller/known-limitations-and-deferred-work:
- en: 5db0f9f69a8be926
- zh: 6ba1cc4a3850fcbc
+ en: 23bc3db43e84daa6
+ zh: 0242338ac7bd24fa
/session-controller/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 2e1bd3e6df..9f7878c3ba 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -64,7 +64,7 @@ During uninterrupted following, transcript and steering echoes stay in Chat thro
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
-Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation; an increment skips numbers that other listed titles already use. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
+Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation; an increment skips numbers that other Host-listed titles already use. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
@@ -127,6 +127,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
- The image byte cap does not validate decoded dimensions or pixel count.
- A failed follow resumption remains visible to the caller instead of retrying indefinitely.
- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero.
+- Fork title increments compare candidates with the Client list before the Host normalizes them: concurrent forks of one source, or a title the Host truncates at its byte limit, can still produce duplicate titles.
- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata.
- Accepted/running display memory lives only in Client memory and is lost on page reload.
- That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index d4a946d8ce..0799531e63 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -64,7 +64,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
-Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话;递增时会跳过其他已列出标题已使用的编号。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
+Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话;递增时会跳过其他由 Host 列出的标题已使用的编号。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
@@ -127,6 +127,7 @@ Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地
- 图片字节上限不校验解码后的尺寸或像素数。
- follow 恢复失败会对调用方可见,而不会无限重试。
- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。
+- Fork 标题递增会在 Host 规范化候选标题之前,先将其与客户端列表比较:同一来源的并发 Fork,或被 Host 按字节上限截断的标题,仍可能产生重复标题。
- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。
- 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。
- 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。
diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts
index 52764f83a0..c1fede0937 100644
--- a/packages/api/session-controller/src/client/contract/sessions.ts
+++ b/packages/api/session-controller/src/client/contract/sessions.ts
@@ -128,7 +128,7 @@ export interface ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
- * inherited durable title, skipping numbers listed titles already use,
+ * inherited durable title, skipping numbers Host-listed titles already use,
* before resolving. `onCreated` observes the catalogued child before that optional rename.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts
index 282086b7de..86b1ad4757 100644
--- a/packages/api/session-controller/src/client/sessions/service.ts
+++ b/packages/api/session-controller/src/client/sessions/service.ts
@@ -130,11 +130,9 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
}
/**
- * Increment a trailing fork number while preserving its half-width or
- * full-width parentheses; an unnumbered title starts with ` (1)`.
- * The number keeps increasing while the candidate is already taken.
+ * Increment the fork number until the candidate is not taken.
* @param title - source session's durable title.
- * @param taken - durable titles of listed sessions.
+ * @param taken - durable titles of Host-listed sessions.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string, taken: ReadonlySet): string {
@@ -143,6 +141,12 @@ function increasedForkTitle(title: string, taken: ReadonlySet): string {
return next
}
+/**
+ * Increment a trailing fork number while preserving its half-width or
+ * full-width parentheses; an unnumbered title starts with ` (1)`.
+ * @param title - title to increment.
+ * @returns the title with the next fork number.
+ */
function incrementedTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
@@ -463,7 +467,8 @@ export class ClientSessions implements ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
- * inherited durable title before resolving, plus permission to start source migration.
+ * inherited durable title, skipping numbers Host-listed titles already use,
+ * before resolving, plus permission to start source migration.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index 3043be5b2a..effb9ee1b6 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -1030,6 +1030,7 @@ describe('fork', () => {
[{ sessionId: 'child-1', title: 'Roadmap (1)' }],
[{ sessionId: 'child-2', title: 'Roadmap (3)' }],
])
+ expect(b.svc.list.getSnapshot().byId[sid('child-2')]?.title).toBe('Roadmap (3)')
})
it('sends the exact boundary seq verbatim: callers pass real event seqs', async ({ bench }) => {
diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
index 3662cd2eb5..56780cfe72 100644
--- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
@@ -215,7 +215,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void /** False refuses source migration; omission preserves the Host default. */ allowMigration?: boolean }): Promise',
description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is catalogued and can be explicitly retained.',
- parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title, skipping numbers listed titles already use, before resolving. `onCreated` observes the catalogued child before that optional rename.' }],
+ parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title, skipping numbers Host-listed titles already use, before resolving. `onCreated` observes the catalogued child before that optional rename.' }],
returns: 'the child session id.',
throws: ['when the fork fails, or when a requested child-title rename fails after creation.'],
},
From b8ede96fd88fc0b12acc6dfd9aba345dd60d815c Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Thu, 29 Oct 2026 20:03:57 +0800
Subject: [PATCH 020/196] feat(client): move native app opening into Session
menus
---
apps/web/tests/context-menus.e2e.ts | 3 +-
.../tests/expected/context-menus/session.txt | 1 -
.../expected/context-menus/ssh-header.txt | 5 +
apps/web/tests/open-in-app-menus.e2e.ts | 164 ++++++++
apps/web/tests/open-in-app-ssh.e2e.ts | 6 +
apps/web/tsconfig.json | 1 +
.../session-header-menu/guide.i18n.yaml | 16 +
.../session-header-menu/guide.md | 17 +
.../session-header-menu/guide.zh.md | 17 +
packages/bundle/web-app/cordis.patch.yml | 6 +-
.../ui-context-menu/src/client/View.tsx | 7 +-
.../ui-context-menu/src/client/index.ts | 4 +-
.../ui-context-menu/src/client/locales.ts | 18 +-
.../tests/overlay.client.spec.tsx | 11 +-
.../client/ui-open-in-app/README.i18n.yaml | 28 +-
packages/client/ui-open-in-app/README.md | 20 +-
packages/client/ui-open-in-app/README.zh.md | 20 +-
packages/client/ui-open-in-app/locale/zh.json | 2 +-
packages/client/ui-open-in-app/package.json | 3 +-
.../src/client/OpenInAppAction.tsx | 2 +-
.../src/client/context-menus.tsx | 6 +-
.../ui-open-in-app/src/client/controller.ts | 9 +-
.../client/ui-open-in-app/src/client/index.ts | 21 +-
.../ui-open-in-app/src/client/locales.ts | 8 +
.../src/client/session-open-with.ts | 52 +++
.../src/client/session-reveal.tsx | 37 --
.../tests/browser-plugin.client.spec.ts | 51 +--
.../tests/session-open-with.client.spec.ts | 124 ++++++
.../tests/session-reveal.client.spec.ts | 48 ---
packages/client/ui-open-in-app/tsconfig.json | 3 +
.../client/ui-primitives/README.i18n.yaml | 8 +-
packages/client/ui-primitives/README.md | 6 +-
packages/client/ui-primitives/README.zh.md | 6 +-
.../client/ui-primitives/src/Menu.module.css | 33 +-
packages/client/ui-primitives/src/Menu.tsx | 236 +++++++++---
.../src/menu-submenu-position.ts | 78 ++++
.../ui-primitives/tests/atoms.client.spec.tsx | 16 +-
.../tests/menu-submenu.client.spec.tsx | 363 ++++++++++++++++++
.../session-log-export/README.i18n.yaml | 12 +-
.../session-log-export/README.md | 6 +-
.../session-log-export/README.zh.md | 6 +-
.../session-log-export/package.json | 2 +
.../src/client/HeaderAction.tsx | 98 ++---
.../session-log-export/src/client/index.ts | 51 ++-
.../tests/client-apply.client.spec.tsx | 96 ++++-
.../tests/header-action.client.spec.tsx | 221 +++--------
.../session-log-export/tsconfig.client.json | 1 +
pnpm-lock.yaml | 6 +
tsconfig.host.json | 1 +
49 files changed, 1412 insertions(+), 544 deletions(-)
create mode 100644 apps/web/tests/expected/context-menus/ssh-header.txt
create mode 100644 apps/web/tests/open-in-app-menus.e2e.ts
create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml
create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md
create mode 100644 packages/client/ui-open-in-app/src/client/session-open-with.ts
delete mode 100644 packages/client/ui-open-in-app/src/client/session-reveal.tsx
create mode 100644 packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts
delete mode 100644 packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts
create mode 100644 packages/client/ui-primitives/src/menu-submenu-position.ts
create mode 100644 packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx
diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts
index 0cc7a7abf8..dfa506b6bc 100644
--- a/apps/web/tests/context-menus.e2e.ts
+++ b/apps/web/tests/context-menus.e2e.ts
@@ -70,7 +70,8 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s',
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
const names = await menuLabels(menu)
- expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder'])
+ expect(names.slice(0, 4)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID'])
+ expect(names).not.toContain('Open in')
expect(names.at(-1)).toBe('Archive session')
expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true)
expect(await menu.getByRole('separator').count()).toBe(2)
diff --git a/apps/web/tests/expected/context-menus/session.txt b/apps/web/tests/expected/context-menus/session.txt
index 5468d20e51..ac477e2304 100644
--- a/apps/web/tests/expected/context-menus/session.txt
+++ b/apps/web/tests/expected/context-menus/session.txt
@@ -2,5 +2,4 @@ Pin session
Rename
Fork session
Copy Session ID
-Show in folder
Archive session
diff --git a/apps/web/tests/expected/context-menus/ssh-header.txt b/apps/web/tests/expected/context-menus/ssh-header.txt
new file mode 100644
index 0000000000..00155b9379
--- /dev/null
+++ b/apps/web/tests/expected/context-menus/ssh-header.txt
@@ -0,0 +1,5 @@
+- menu:
+ - menuitem "Copy Session as Markdown"
+ - menuitem "Download session log"
+ - separator
+ - menuitem "Feedback"
diff --git a/apps/web/tests/open-in-app-menus.e2e.ts b/apps/web/tests/open-in-app-menus.e2e.ts
new file mode 100644
index 0000000000..e3448b18fa
--- /dev/null
+++ b/apps/web/tests/open-in-app-menus.e2e.ts
@@ -0,0 +1,164 @@
+/** Session menu opening over built plugins; only native application discovery and launch are simulated. */
+import { mkdir, readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest'
+import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
+import { launchWebScaffold, seedSession, type WebScaffold } from './scaffold.ts'
+
+const FIRST = 'open-app-menu-foreground'
+const OTHER = 'open-app-menu-background'
+const ICON = ' '
+const devices = [
+ { name: 'macOS desktop', platform: 'MacIntel', width: 1280, height: 800, touch: false, mobile: false },
+ { name: 'Windows desktop', platform: 'Win32', width: 1280, height: 800, touch: false, mobile: false },
+ { name: 'Linux desktop', platform: 'Linux x86_64', width: 1280, height: 800, touch: false, mobile: false },
+ { name: 'narrow desktop', platform: 'MacIntel', width: 390, height: 844, touch: false, mobile: false },
+ { name: 'iPhone', platform: 'iPhone', width: 390, height: 844, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' },
+ { name: 'Android', platform: 'Linux armv8l', width: 412, height: 915, touch: true, mobile: true, userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' },
+ { name: 'iPad portrait', platform: 'MacIntel', width: 820, height: 1180, touch: true, mobile: true },
+ { name: 'iPad landscape', platform: 'MacIntel', width: 1180, height: 820, touch: true, mobile: true },
+] as const
+
+/** Choose the recorded Session through the real sidebar before testing viewport changes. */
+async function openSeed(page: Page, url: string): Promise {
+ await page.goto(url)
+ const group = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' })
+ await group.click()
+ await page.locator(`[data-row-key="session:${FIRST}"]`).click()
+ await page.getByText('DONE', { exact: true }).waitFor()
+}
+
+/** Every visible parent and child menu stays inside the viewport, including long lists. */
+async function expectMenusInside(page: Page): Promise {
+ const bounds = await page.getByRole('menu').evaluateAll(nodes => nodes.map((node) => {
+ const rect = node.getBoundingClientRect()
+ return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: innerWidth, height: innerHeight }
+ }))
+ expect(bounds.length).toBeGreaterThan(0)
+ for (const rect of bounds) {
+ expect(rect.left).toBeGreaterThanOrEqual(0)
+ expect(rect.top).toBeGreaterThanOrEqual(0)
+ expect(rect.right).toBeLessThanOrEqual(rect.width)
+ expect(rect.bottom).toBeLessThanOrEqual(rect.height)
+ }
+}
+
+/** Install an instance-local native-operation simulation without changing the menu implementation. */
+async function nativeRoutes(
+ context: BrowserContext,
+ catalog: readonly string[],
+ launch: (body: { app: string; path: string }) => Promise,
+): Promise {
+ await context.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: catalog } }))
+ await context.route('**/open-in-app/icon/*', route => route.fulfill({ body: ICON, contentType: 'image/svg+xml' }))
+ await context.route('**/open-in-app/open', async (route) => {
+ const status = await launch(route.request().postDataJSON() as { app: string; path: string })
+ await route.fulfill({ status, json: { ok: status === 200 } })
+ })
+}
+
+describe('Open in Session menus', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let otherDirectory: string
+ beforeAll(async () => {
+ // The real Host receives SSH facts to avoid probing or starting this runner's native applications.
+ scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([
+ { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } },
+ ]) })
+ const recorded = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8')
+ otherDirectory = join(scaffold.workspaceCwd, 'background-directory')
+ await mkdir(otherDirectory)
+ await seedSession(scaffold, recorded, FIRST)
+ await seedSession({ ...scaffold, workspaceCwd: otherDirectory }, recorded, OTHER)
+ browser = await chromium.launch()
+ })
+ afterAll(async () => {
+ try { await browser?.close() } finally { await scaffold?.close() }
+ })
+
+ it.each(devices)('opens through Header More on $name with device-specific feedback', async (device) => {
+ const context = await browser.newContext({
+ viewport: { width: 1280, height: 800 }, locale: 'en-US', hasTouch: device.touch,
+ ...('userAgent' in device ? { userAgent: device.userAgent } : {}),
+ })
+ onTestFinished(() => context.close())
+ await context.addInitScript(({ platform, touch }) => {
+ Object.defineProperty(navigator, 'platform', { value: platform })
+ Object.defineProperty(navigator, 'maxTouchPoints', { value: touch ? 5 : 0 })
+ }, device)
+ const launches: { app: string; path: string }[] = []
+ await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return 200 })
+ const page = await context.newPage()
+ await openSeed(page, scaffold.authenticatedUrl)
+ await page.setViewportSize({ width: device.width, height: device.height })
+ const more = page.getByRole('button', { name: 'More actions', exact: true })
+ expect(await page.getByRole('banner').locator('[data-open-target="directory"]').count()).toBe(0)
+ if (device.touch) await more.tap(); else await more.click()
+ const parent = page.getByRole('menuitem', { name: 'Open in', exact: true })
+ await parent.waitFor()
+ expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).count()).toBe(1)
+ expect(await page.getByRole('menuitem', { name: 'Pin session', exact: true }).count()).toBe(0)
+ if (device.touch) await parent.tap(); else await parent.click()
+ await page.getByRole('menuitem', { name: 'Cursor', exact: true }).waitFor()
+ await expectMenusInside(page)
+ if (device.width === 390) {
+ expect(await page.getByRole('menuitem', { name: 'Back', exact: true }).count()).toBe(1)
+ }
+ const choice = page.getByRole('menuitem', { name: 'Cursor', exact: true })
+ if (device.touch) await choice.tap(); else await choice.click()
+ await expect.poll(() => launches).toEqual([{ app: 'cursor', path: scaffold.workspaceCwd }])
+ await expect.poll(() => page.getByRole('menu').count()).toBe(0)
+ await expect.poll(() => page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"cursor"')
+ if (device.mobile) await page.getByText('Opened on another device', { exact: true }).waitFor()
+ else expect(await page.getByText('Opened on another device', { exact: true }).count()).toBe(0)
+ })
+
+ it('captures a background Session, remembers only success, and prevents a second launch while busy', async () => {
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
+ onTestFinished(() => context.close())
+ const response = Promise.withResolvers()
+ onTestFinished(() => { response.resolve(502) })
+ const launches: { app: string; path: string }[] = []
+ await nativeRoutes(context, ['vscode', 'cursor', 'finder'], async (body) => { launches.push(body); return response.promise })
+ const page = await context.newPage()
+ await openSeed(page, scaffold.authenticatedUrl)
+ const background = page.locator(`[data-row-key="session:${OTHER}"]`)
+ await background.click({ button: 'right' })
+ await page.getByRole('menuitem', { name: 'Open in', exact: true }).click()
+ await page.getByRole('menuitem', { name: 'Cursor', exact: true }).click()
+ await expect.poll(() => launches).toEqual([{ app: 'cursor', path: otherDirectory }])
+ expect(await background.getAttribute('aria-selected')).toBe('false')
+ await page.getByRole('button', { name: 'More actions', exact: true }).click()
+ const disabled = page.getByRole('menuitem', { name: 'Open in', exact: true })
+ await expect.poll(() => disabled.isDisabled()).toBe(true)
+ expect(launches).toHaveLength(1)
+ await page.keyboard.press('Escape')
+ response.resolve(502)
+ await page.getByText('Could not open. Try again.', { exact: true }).waitFor()
+ expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBeNull()
+ await background.hover()
+ await background.getByRole('button', { name: /actions/i }).click()
+ await expect.poll(() => page.getByRole('menuitem', { name: 'Open in', exact: true }).isEnabled()).toBe(true)
+ expect(await page.getByRole('menuitem', { name: 'Show in Finder', exact: true }).count()).toBe(0)
+ })
+
+ it.each([{ catalog: [] }, { catalog: ['unnameable'] }, { catalog: ['finder'] }])('keeps empty and single-application catalogs consistent: $catalog', async ({ catalog }) => {
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: 'en-US' })
+ onTestFinished(() => context.close())
+ await nativeRoutes(context, catalog, async () => 200)
+ const page = await context.newPage()
+ await openSeed(page, scaffold.authenticatedUrl)
+ await page.getByRole('button', { name: 'More actions', exact: true }).click()
+ await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor()
+ const open = page.getByRole('menuitem', { name: 'Open in', exact: true })
+ if (catalog[0] !== 'finder') expect(await open.count()).toBe(0)
+ else {
+ await open.click()
+ await page.getByRole('menuitem', { name: 'Finder', exact: true }).waitFor()
+ expect(await page.getByRole('menuitem', { name: /^Finder/ }).count()).toBe(1)
+ await expectMenusInside(page)
+ }
+ })
+})
diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts
index ff57285d88..52ca0d0f27 100644
--- a/apps/web/tests/open-in-app-ssh.e2e.ts
+++ b/apps/web/tests/open-in-app-ssh.e2e.ts
@@ -65,6 +65,12 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => {
const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE)
+ await page.getByRole('button', { name: 'More actions', exact: true }).click()
+ await page.getByRole('menuitem', { name: 'Copy Session as Markdown', exact: true }).waitFor()
+ expect(await page.getByRole('menuitem', { name: 'Open in', exact: true }).count()).toBe(0)
+ await compareOrRefreshGolden(join(import.meta.dirname, 'expected/context-menus/ssh-header.txt'),
+ await captureStableAria(page, '[data-context-menu]', scaffold.workspaceCwd), MODE)
+ await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md'])
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 7d094253a5..8592244a30 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -182,6 +182,7 @@
"tests/file-upload-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/open-in-app-ssh.e2e.ts",
+ "tests/open-in-app-menus.e2e.ts",
"tests/fork-mid-turn.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/markdown-images.e2e.ts",
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml
new file mode 100644
index 0000000000..8ff580f16d
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
+/:
+ en: 269f7f4bd0420430
+ zh: e9425a80ccc3ef2f
+/session-export-requires-context-menus:
+ en: 835e8e217c9ac011
+ zh: 2d97e450247f0c78
+/session-export-requires-context-menus/change:
+ en: 9041761cf940965b
+ zh: ee5051b2c7d5f3db
+/session-export-requires-context-menus/migration:
+ en: e8a0ea0a7f7e333b
+ zh: 30d2541e92b6f6a8
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
new file mode 100644
index 0000000000..b2312568aa
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
@@ -0,0 +1,17 @@
+---
+kind: upgrade-guide
+description: "Custom Web compositions using session-log-export must provide the context-menu service."
+---
+
+# Session export requires context menus
+
+English | [中文](guide.zh.md)
+
+## Change
+
+The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to render its Header More menu. Custom Web compositions that previously mounted session-log-export without a context-menu provider must add one; otherwise the browser plugin waits for the missing service. The default Web composition already includes the provider. Host export routes and the `/export` command are unchanged.
+
+## Migration
+
+1. In a custom Web composition that lacks the provider, add `@deepseek-ai/dsh-client-ui-context-menu` to the same client plugin tree as session-log-export, with the menu package’s required services. See [context-menu composition](../../../../packages/client/ui-context-menu/README.md#use-this-package). Default Web profiles need no configuration change.
+2. Restart the profile. Open a Session’s Header More menu and confirm that Copy Session as Markdown and Download session log are available; confirm that downloading still opens its Session-scoped dialog.
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md
new file mode 100644
index 0000000000..47cce55be7
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md
@@ -0,0 +1,17 @@
+---
+kind: upgrade-guide
+description: "使用 session-log-export 的自定义 Web 组合必须提供右键菜单服务。"
+---
+
+# 会话导出需要右键菜单服务
+
+[English](guide.md) | 中文
+
+## 变更
+
+`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 来渲染头部更多菜单。此前挂载 session-log-export 但没有右键菜单提供者的自定义 Web 组合必须补充该提供者,否则浏览器插件会等待缺失的服务。默认 Web 组合已包含提供者。Host 导出路由及 `/export` 命令不变。
+
+## 迁移
+
+1. 自定义 Web 组合缺少提供者时,将 `@deepseek-ai/dsh-client-ui-context-menu` 加入 session-log-export 所在的客户端插件树,并提供菜单包要求的服务。参见[右键菜单组合](../../../../packages/client/ui-context-menu/README.zh.md#use-this-package)。默认 Web profile 无需修改配置。
+2. 重启 profile,打开会话头部更多菜单,确认复制会话为 Markdown 与下载会话日志可用,并确认下载仍显示会话范围的弹窗。
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 53d2b2971e..c07bbe81c2 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -67,9 +67,9 @@
- id: session-log-download
name: '@deepseek-ai/dsh-session-log-export'
- # Session-header "Open In..." split button over the host application
- # resolution (macOS/Windows/Linux; a host with no resolved application
- # renders no button). Two halves: the host routes and the browser surface.
+ # Session-menu application choices over Host resolution on macOS, Windows,
+ # and Linux. An empty catalog hides the action. The Host routes and browser
+ # controls also serve the file-tree directory opener.
- id: open-in-app
name: '@deepseek-ai/dsh-host-open-in-app'
config:
diff --git a/packages/client/ui-context-menu/src/client/View.tsx b/packages/client/ui-context-menu/src/client/View.tsx
index 58a9808f3a..c9dbe390c5 100644
--- a/packages/client/ui-context-menu/src/client/View.tsx
+++ b/packages/client/ui-context-menu/src/client/View.tsx
@@ -1,7 +1,7 @@
/** Shared Web renderer for Client menu contributions. */
import { Menu, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { InjectFace, PropsStore, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import type { InjectFace, PropsStore, PropsRenderSlots, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { Shortcuts, ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { createMenuStore } from './store.ts'
import type { MenuActionView } from './types.ts'
@@ -39,14 +39,15 @@ export interface MenuOverlayInjected {
select: (id: string) => void
}
type Props = PropsStore>
- & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace
+ & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace & PropsLocale<'contextMenu'>
/** @param props - registered state, target-specific legacy rows, and actions. @returns one menu and persistent feedback. */
-export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog }: Props) {
+export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog, t }: Props) {
const catalog = useCatalog(value => value)
const { target, point, items, notice, occurrence, closing, replacing } = useStore(s => s)
return <>
new DOMRect(point.x, point.y, 0, 0)}
items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)}
diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts
index ac4fc288be..b6c3c6ba8c 100644
--- a/packages/client/ui-context-menu/src/client/index.ts
+++ b/packages/client/ui-context-menu/src/client/index.ts
@@ -33,7 +33,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Optional React rows for existing extension positions; data contributions need no renderer. */
'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: ContextMenuTarget; dismiss: () => void } }
}
- interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' }
+ interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' | 'back' }
}
/** Maximum time to prepare each contribution before omitting its pending actions. */
@@ -141,7 +141,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
}, 'context menu: lifetime')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
- name: 'shell.overlay', id: 'context-menu', store,
+ name: 'shell.overlay', id: 'context-menu', store, locale: 'contextMenu',
children: {
'contextMenu.content': { kind: 'keyed', scope: 'root' },
'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true },
diff --git a/packages/client/ui-context-menu/src/client/locales.ts b/packages/client/ui-context-menu/src/client/locales.ts
index d8b33a422e..f44a8f96c3 100644
--- a/packages/client/ui-context-menu/src/client/locales.ts
+++ b/packages/client/ui-context-menu/src/client/locales.ts
@@ -1,11 +1,11 @@
/** Context-menu operation feedback, separate from plugin display metadata. */
export const dictionaries = {
- zh: { failed: '操作失败,请重试' },
- en: { failed: 'Could not complete the action. Try again.' },
- ja: { failed: '操作に失敗しました。再試行してください。' },
- es: { failed: 'No se pudo completar la acción. Inténtelo de nuevo.' },
- fr: { failed: 'Impossible de terminer l’action. Réessayez.' },
- de: { failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' },
- ru: { failed: 'Не удалось выполнить действие. Повторите попытку.' },
- pt: { failed: 'Não foi possível concluir a ação. Tente novamente.' },
-} satisfies Record
+ zh: { back: '返回', failed: '操作失败,请重试' },
+ en: { back: 'Back', failed: 'Could not complete the action. Try again.' },
+ ja: { back: '戻る', failed: '操作に失敗しました。再試行してください。' },
+ es: { back: 'Atrás', failed: 'No se pudo completar la acción. Inténtelo de nuevo.' },
+ fr: { back: 'Retour', failed: 'Impossible de terminer l’action. Réessayez.' },
+ de: { back: 'Zurück', failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' },
+ ru: { back: 'Назад', failed: 'Не удалось выполнить действие. Повторите попытку.' },
+ pt: { back: 'Voltar', failed: 'Não foi possível concluir a ação. Tente novamente.' },
+} satisfies Record
diff --git a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx
index 54194a811b..42268256b9 100644
--- a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx
+++ b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx
@@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
@@ -21,7 +22,7 @@ it('renders legacy Session actions before data contributions and restores More f
const useStore = bindSnapshotSelector(store)
const view = render(<>
{ store.actions.open({ kind: 'session', value: { sessionId: SessionId('clicked'), displayTitle: 'Clicked' } }, { x: 20, y: 30 }) }}>More
- select([])} useStore={useStore} actions={store.actions} close={close} select={run}
+ key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={useStore} actions={store.actions} close={close} select={run}
renderSlot={(_key, owner: object) => {
const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void }
return {
@@ -55,7 +56,7 @@ it('renders disabled, grouped and nested data actions and keeps feedback after m
{ id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] },
{ id: 'footer', label: 'Folder', afterContent: true },
])
- const view = render( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions}
+ const view = render( key === 'back' ? 'Back' : 'Failed'} useCatalog={select => select([])} useStore={bindSnapshotSelector(store)} actions={store.actions}
close={() => { store.actions.close() }} select={select} renderSlot={() => null} />)
expect(view.getByRole('menuitem', { name: 'Unavailable' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(view.getByRole('menuitem', { name: 'Open' }))
@@ -90,7 +91,9 @@ it('dispatches captured icon keys in rows, submenus and footers and retires rend
const runtime = await SlotTestRuntime.create()
onTestFinished(() => runtime.dispose())
runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never)
- runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never)
+ const locale = new LocaleRuntime(runtime.ctx)
+ runtime.ctx.provide('locale', locale)
+ runtime.slots.installLocale(locale)
await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) =>
<>{renderSlot('shell.overlay', {})}>)
const menuPlugin = await runtime.mount({ inject, apply })
@@ -143,7 +146,7 @@ it('tracks configured shortcuts for nested and footer actions without invoking c
{ id: 'footer', label: 'Footer', commandId, afterContent: true },
])
const select = vi.fn()
- const view = render( key === 'back' ? 'Back' : 'Failed'} useStore={bindSnapshotSelector(store)} actions={store.actions}
useCatalog={bindSnapshotSelector(shortcuts.catalog)} close={vi.fn()} select={select} renderSlot={() => null} />)
const item = view.getByRole('menuitem', { name: 'Rename' })
expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G')
diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml
index 16367c590b..437a2b672c 100644
--- a/packages/client/ui-open-in-app/README.i18n.yaml
+++ b/packages/client/ui-open-in-app/README.i18n.yaml
@@ -3,32 +3,32 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md
/:
- en: be11314755d8f4f9
- zh: dd75b03074eea543
+ en: 8caca7252f2cf5c7
+ zh: 47a157352d33265e
/deepseek-ai-dsh-client-ui-open-in-app:
en: 004b805ac638e884
zh: 6ac98df58affefcf
/deepseek-ai-dsh-client-ui-open-in-app/summary:
- en: e69e4c6df4ddffa1
- zh: d1ef0a722f48e9bd
+ en: 1e68d4eb8f246ed2
+ zh: d8bf9abe0c1cccee
/deepseek-ai-dsh-client-ui-open-in-app/table-of-contents:
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package:
- en: 056e2bd9e3c78dd8
- zh: be35d2ba53be0442
+ en: 812131f17abf1bd7
+ zh: 84481ac1622a2762
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
- en: 51e6019f011f394e
- zh: 818391262086ae10
+ en: e07d3a607d54cb47
+ zh: 9a7342f05b64db38
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
- en: ec7a7c56aaeafce6
- zh: 4cee0cd2cb7531df
+ en: 522e07823c4cbb5f
+ zh: 042ffeb95f569cc3
/deepseek-ai-dsh-client-ui-open-in-app/further-exploration:
- en: 562c58d589fb7afa
- zh: 9ba88b381160cc96
+ en: 81c9d99c0a1a1dd0
+ zh: 446056602f245293
/deepseek-ai-dsh-client-ui-open-in-app/model-experience:
- en: 5f9e6e0fb0438628
- zh: fc9a81185ea6cb14
+ en: aad141aa922e23f7
+ zh: 18432b4f70971fa7
/deepseek-ai-dsh-client-ui-open-in-app/model-experience/kv-cache-effect:
en: ca75c51c89c2c9b0
zh: c2241315dc0c89c9
diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md
index c2fb730478..0944b9b7fa 100644
--- a/packages/client/ui-open-in-app/README.md
+++ b/packages/client/ui-open-in-app/README.md
@@ -1,5 +1,5 @@
---
-description: "Web \"Open In...\" controls: the Session-header split button launching the remembered application on the workspace directory, and the document preview's default-application open and reveal controls for one file."
+description: "Open Session directories from application submenus and open previewed files through their native applications."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-This package provides the browser surface of the open-in-app feature. A Session-header split button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and its chevron lists every catalog application the host probed as installed; availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md), so mount the two packages together. In the right Sidebar's document preview, an "Open" split button and an empty-state button open the previewed file in its default application or show its location, through the Session Remote. A host without the capability renders none of these controls.
+Open a Session’s directory through **Open in** in its Header More menu or its row/title context menu. The submenu lists installed, localized applications in Host order with icons and names, without a default marker. File-tree, document-preview, and delivery controls also open files or directories through native applications. Availability, icons, and launches come from [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount both packages together. A Session without a known directory or eligible application has no Open in item.
## Table of Contents
@@ -25,23 +25,23 @@ This package provides the browser surface of the open-in-app feature. A Session-
## Use this package
-Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) grows its file controls whenever the Host reports a desktop through the Session Remote's `session.canOpenWorkspacePath`; removing this row removes every control at once.
+Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); this row takes no config. A known Session directory and at least one installed, localized catalog application enable its Open in submenu. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) shows file controls when the Host reports a desktop through `session.canOpenWorkspacePath`; removing this row removes all its controls and menu contributions.
### What to expect
-The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application.
+The Session Header places **Open in** in a separated group after download and before feedback. Session-row More, row right-click, and title right-click place it after Copy session ID and before Archive. Even one application stays in a submenu. The file-tree and document headers retain their 24px-high, 9px-radius icon-only split buttons. Their tooltips name the default application or reveal action, and their menus retain the default marker. All application icons fall back to a native-open glyph when loading fails. The file-tree opener targets its owner-supplied `absolutePath` and shares application choice and launch occupancy with Session menus. Directory choices persist in `dsh.open-in-app.choice` only after successful launches; unavailable choices fall back to the first eligible application. A busy directory launch disables both the submenu parent and all application rows.
On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode.
-The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch.
+The **Open locally** shortcut captures the main Session’s directory and remembered application. The effective binding remains in the file-tree opener tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and that Session has a directory and installed application to open. An in-flight launch blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller’s launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch.
File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action.
Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control.
-Workspace context menus open the captured folder through the same directory application catalog as the Session header. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save.
+Workspace context menus reveal the captured folder through the shared directory application catalog. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save.
-Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session reveal follows the installed file-manager catalog and registers a structured Session contribution at order 500, between Copy session ID and Archive. Native context-menu operations share the feature's overlay toast and mobile/desktop feedback policy after dismissal. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
+Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session submenus resolve the captured Session ID’s directory rather than the main view, and observe application, operation, Session-list, and locale changes. Native operations report through the feature’s global toast after menu dismissal or target changes, without a second registry toast. Native file actions resolve Session paths through authorized metadata before querying applications. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent.
@@ -55,7 +55,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
Implementation internals — click to expand
-Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes the captured directory and busy state. Adapters resolve native and transport failures to named outcomes; ignored or cancelled requests return a skipped outcome and announce nothing.
+[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across `session` and the Header owner’s `sessionHeader` target; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing.
The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
@@ -67,7 +67,7 @@ The directory and file adapters supply application metadata and operations to [`
## Further Exploration
- [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them.
-- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action.
+- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the Header More trigger and its independent context-menu target.
- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.md) — the document preview declaring the header and empty-state child slots the file controls occupy.
- [ui-deliverables](../ui-deliverables/README.md) — the delivery cards, which still open declared files through their own routes.
- [Web client architecture](../../../docs/subsystems/web-client.md) — how browser plugin rows load and register slots.
@@ -77,7 +77,7 @@ The directory and file adapters supply application metadata and operations to [`
## Model Experience
-None, as the split button is browser chrome; nothing here reaches a model request.
+None, as these opening controls and menus are browser chrome; nothing here reaches a model request.
#### KV Cache effect
diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md
index 04aa5edc1e..a5290914b8 100644
--- a/packages/client/ui-open-in-app/README.zh.md
+++ b/packages/client/ui-open-in-app/README.zh.md
@@ -1,5 +1,5 @@
---
-description: "Web \"Open In...\" 控件:会话头部在记住的应用中打开 workspace 目录的分体按钮,以及文档预览里用默认应用打开、显示单个文件位置的控件。"
+description: "通过应用子菜单打开会话目录,并通过原生应用打开预览文件。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-本包提供 open-in-app 功能的浏览器表面。会话头部的分体按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用;可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由,所以两个包要一起挂载。在右侧 Sidebar 的文档预览里,一个「打开」分体按钮和一个空态按钮经由 Session Remote 用默认应用打开当前文件或显示其位置。主机没有这项能力时,这些控件一个都不渲染。
+通过会话头部更多菜单或会话行、标题右键菜单中的**在应用中打开**打开会话目录。子菜单按 Host 顺序列出已安装且已本地化的应用,仅显示图标和名称,不标注默认应用。文件树、文档预览和交付控件也可通过原生应用打开文件或目录。可用性、图标和启动由 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 提供;两个包应一起挂载。会话目录未知或没有符合条件的应用时,不显示在应用中打开。
## 目录
@@ -25,23 +25,23 @@ kind: "package-reference"
## 使用本包
-把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。只要 Host 通过 Session Remote 的 `session.canOpenWorkspacePath` 报告有桌面,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览就会长出文件控件;删掉这一行会一次去掉所有控件。
+把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂载到 Web 组合;本行不接受配置。会话目录已知且至少有一个已安装、已本地化的目录应用时,显示在应用中打开子菜单。Host 通过 `session.canOpenWorkspacePath` 报告桌面可用时,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览显示文件控件;删除本行会移除其全部控件与菜单贡献。
### 预期行为
-会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。
+会话头部把**在应用中打开**放在下载之后、反馈之前的独立分隔组。会话行更多、行右键及标题右键菜单把它放在复制 Session ID 之后、归档之前。只有一个应用时仍保留子菜单。文件树与文档标题栏保留高 24px、圆角 9px 的纯图标分体按钮。这些按钮的悬停提示显示默认应用或定位动作,其菜单保留默认标记。所有应用图标加载失败时均使用原生打开图标。文件树按钮以所属组件传入的 `absolutePath` 为目标,并与会话菜单共享应用选择及启动占用状态。目录选择仅在启动成功后持久化到 `dsh.open-in-app.choice`;原选择不可用时回退到首个符合条件的应用。目录启动忙碌时,禁用子菜单父项和全部应用行。
在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。
-**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。
+**在本地打开**快捷键捕获主会话目录及已记住的应用。有效绑定继续显示在文件树打开按钮的 tooltip 和 `aria-keyshortcuts` 中;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面且该会话具有可打开目录与已安装应用时才执行命令。启动进行中会阻止命令;结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享控制器的启动状态,因此重复操作不会启动两次,也不会在启动期间更改已记住的选择。
文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。
使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。
-工作区右键菜单通过与会话标题相同的目录应用列表打开被点击的文件夹。文件树目录提供本机显示位置和复制文件夹路径,不提供文件预览或保存。
+工作区右键菜单通过共享的目录应用列表定位被点击的文件夹。文件树目录提供原生定位与复制文件夹路径,不提供文件预览或保存。
-右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话定位沿用已安装文件管理器目录,以顺序 500 注册为结构化 Session 贡献,位于复制 Session ID 与归档之间。右键原生操作在菜单关闭后,通过本功能的浮层 toast 沿用移动端与电脑端反馈规则。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对该原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及其完成提示,包括通过提示中的操作定位保存目标。
+右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话子菜单解析被捕获会话 ID 的目录,不使用主视图,并观察应用、操作、会话列表及语言变化。原生操作在菜单关闭或目标切换后仍通过本功能的全局 toast 报告,不产生第二个注册表提示。原生文件操作先通过授权元数据解析会话路径,再查询关联应用。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及完成提示,包括通过提示定位保存目标。
文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。
@@ -55,7 +55,7 @@ kind: "package-reference"
实现内幕——点击展开
-两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与等待状态。适配器将原生操作和传输失败解析为具名结果;被忽略或取消的请求返回跳过结果,不作播报。
+[`session-open-with.ts`](src/client/session-open-with.ts) 为 `session` 和头部所属包声明的 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。
目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
@@ -67,7 +67,7 @@ kind: "package-reference"
## 进一步探索
- [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。
-- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。
+- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——头部更多入口及其独立的右键菜单目标。
- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.zh.md)——声明文件控件所占头部与空态子 slot 的文档预览。
- [ui-deliverables](../ui-deliverables/README.zh.md)——交付卡片,仍通过自己的路由打开声明过的文件。
- [Web client 架构](../../../docs/subsystems/web-client.zh.md)——浏览器插件行如何加载并注册 slot。
@@ -77,7 +77,7 @@ kind: "package-reference"
## 模型体验
-无。分体按钮是浏览器 chrome;这里没有任何东西进入模型请求。
+无。这些打开控件与菜单是浏览器界面;不会进入模型请求。
#### KV Cache 影响
diff --git a/packages/client/ui-open-in-app/locale/zh.json b/packages/client/ui-open-in-app/locale/zh.json
index 078d8a6924..d167d8c088 100644
--- a/packages/client/ui-open-in-app/locale/zh.json
+++ b/packages/client/ui-open-in-app/locale/zh.json
@@ -1,6 +1,6 @@
{
"meta": {
"title": "外部应用打开界面",
- "description": "在会话和文档预览中提供「打开方式」入口。"
+ "description": "在应用中打开会话目录或预览文件。"
}
}
diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json
index 6d7778f135..88bf9d219c 100644
--- a/packages/client/ui-open-in-app/package.json
+++ b/packages/client/ui-open-in-app/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-open-in-app",
- "description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file",
+ "description": "Session application submenus and native opening controls for directories and previewed files",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
@@ -79,6 +79,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-host-open-in-app": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
+ "@deepseek-ai/dsh-session-log-export": "workspace:*",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:*",
"@deepseek-ai/dsh-native-command": "workspace:*",
diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx
index ceb84fc611..cdbf77d3b6 100644
--- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx
+++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx
@@ -7,7 +7,7 @@ import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/cli
import { NS } from './locales.ts'
import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx'
-/** Browser operations and state shared by the Session header and file tree contributions. */
+/** Directory operations and state shared with Session application submenus. */
export interface OpenInAppActionInjected extends OpenTargetReporter {
hooks: {
openInAppApps: ObservableSnapshot
diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx
index 2f6fb14295..5810a3aab7 100644
--- a/packages/client/ui-open-in-app/src/client/context-menus.tsx
+++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx
@@ -1,4 +1,5 @@
/** Native path and URL menu contributions, independent of their trigger components. */
+import { useState } from 'react'
import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { Context } from '@deepseek-ai/cordis'
@@ -141,5 +142,8 @@ export function registerContextMenus(
/** @param props - icon metadata captured with the resolved action. @returns Application image or the native-open glyph. */
export function ContextMenuApplicationIcon({ icon }: Pick, 'icon'>) {
- return icon.src ? :
+ const [failedSource, setFailedSource] = useState()
+ return icon.src && icon.src !== failedSource
+ ? { setFailedSource(icon.src) }} />
+ :
}
diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts
index c2e25085e8..bd43f8af7e 100644
--- a/packages/client/ui-open-in-app/src/client/controller.ts
+++ b/packages/client/ui-open-in-app/src/client/controller.ts
@@ -1,4 +1,4 @@
-/** Browser availability/choice state and the launch carrier for the split button. */
+/** Shared directory application catalog, preference, and launch carrier for controls and menus. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
@@ -20,7 +20,7 @@ type Fetch = (input: string | URL, init?: RequestInit) => Promise
/**
* Owns the once-per-page availability read, the persisted last choice, and
* the launch POST. Availability and choice publish through uSES-safe sources
- * so every Session header shares one truth.
+ * so Session menus and directory controls share the same catalog and choice.
*/
export class OpenInAppController {
/** Installed app ids in host menu order; null until the host answered. */
@@ -107,9 +107,8 @@ export class OpenInAppController {
const payload = await response.json() as OpenInAppAppsPayload
if (Array.isArray(payload.apps)) apps = payload.apps.filter(id => typeof id === 'string')
}
- } catch {
- // Swallows network failures: an unreachable host reads as no apps, and
- // the header simply shows no button rather than a broken one.
+ } catch (_error) {
+ // An unreachable Host supplies no directory openers in menus or controls.
}
this.apps.set(apps)
}
diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts
index 49e5fe25e4..e706f9eb25 100644
--- a/packages/client/ui-open-in-app/src/client/index.ts
+++ b/packages/client/ui-open-in-app/src/client/index.ts
@@ -7,8 +7,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
* file defaults and application lists come from the serving Host desktop.
*/
-import { createElement } from 'react'
-import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -23,8 +21,8 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
import { OpenInAppController } from './controller.ts'
-import { registerSessionReveal } from './session-reveal.tsx'
-import { OpenInAppAction, type OpenInAppActionInjected, type OpenInAppActionProps } from './OpenInAppAction.tsx'
+import { registerSessionOpenWith } from './session-open-with.ts'
+import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
import { OpenInAppPathController } from './open-path.ts'
import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx'
import { FileRouteAction } from './FileRouteAction.tsx'
@@ -35,7 +33,7 @@ import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
- /** Session-header "open workspace in application" copy. */
+ /** Native application menu and control copy. */
'open-in-app': OpenInAppKey
}
}
@@ -70,7 +68,7 @@ export function apply(ctx: ClientContext): void {
yield ctx.slots.register({ name: 'contextMenu.icon', key: 'open-in-app.application' }, ContextMenuApplicationIcon)
})
registerContextMenus(ctx, paths, t, controller, announcement.report)
- registerSessionReveal(ctx, controller, t, announcement.report)
+ registerSessionOpenWith(ctx, controller, t, announcement.report)
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'open-in-app.toast', locale: NS,
inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }),
@@ -113,17 +111,6 @@ export function apply(ctx: ClientContext): void {
choose: (appId) => { controller.choose(appId) },
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`,
})
- function SessionOpenInAppAction(
- props: PropsRuntime<'conversation.session.header.utilities'> & Omit,
- ) {
- const { sessionId, useSessions } = props
- const cwd = useSessions(state => state.byId[sessionId]?.cwd)
- return cwd ? createElement(OpenInAppAction, { ...props, absolutePath: cwd }) : null
- }
- ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
- name: 'conversation.session.header.utilities',
- id: 'open-in-app', order: -10, locale: NS, inject: directoryInjected,
- }, SessionOpenInAppAction))
ctx.slots.inject('sidebar.right.tab.files.actions', () => ctx.slots.register({
name: 'sidebar.right.tab.files.actions',
id: 'open-in-app', locale: NS, inject: directoryInjected,
diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts
index bd87413a4e..966834479c 100644
--- a/packages/client/ui-open-in-app/src/client/locales.ts
+++ b/packages/client/ui-open-in-app/src/client/locales.ts
@@ -39,6 +39,7 @@ const PRODUCT_NAMES = {
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
+ 'menu.openWith': '在应用中打开',
'menu.openInSidebar': '在侧边栏打开',
'menu.openInFloat': '在浮窗中打开',
'menu.finder': '在 Finder 中显示',
@@ -73,6 +74,7 @@ export const zh = {
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record = {
+ 'menu.openWith': 'Open in',
'menu.openInSidebar': 'Open in sidebar',
'menu.openInFloat': 'Open in floating panel',
'menu.finder': 'Show in Finder',
@@ -110,6 +112,7 @@ export type OpenInAppKey = keyof typeof zh
/** Japanese copy, checked against the English keys. */
export const ja = {
+ 'menu.openWith': 'アプリケーションで開く',
'menu.openInSidebar': 'サイドバーで開く',
'menu.openInFloat': 'フローティングパネルで開く',
'menu.finder': 'Finder に表示',
@@ -144,6 +147,7 @@ export const ja = {
/** Spanish dictionary, checked against the English keys. */
export const es = {
+ 'menu.openWith': 'Abrir con',
'menu.openInSidebar': 'Abrir en la barra lateral',
'menu.openInFloat': 'Abrir en un panel flotante',
'menu.finder': 'Mostrar en Finder',
@@ -177,6 +181,7 @@ export const es = {
/** French dictionary, checked against the English keys. */
export const fr = {
+ 'menu.openWith': 'Ouvrir avec',
'menu.openInSidebar': 'Ouvrir dans la barre latérale',
'menu.openInFloat': 'Ouvrir dans un panneau flottant',
'menu.finder': 'Afficher dans le Finder',
@@ -210,6 +215,7 @@ export const fr = {
/** German dictionary, checked against the English keys. */
export const de = {
+ 'menu.openWith': 'Öffnen mit',
'menu.openInSidebar': 'In der Seitenleiste öffnen',
'menu.openInFloat': 'In schwebendem Panel öffnen',
'menu.finder': 'Im Finder anzeigen',
@@ -243,6 +249,7 @@ export const de = {
/** Russian dictionary, checked against the English keys. */
export const ru = {
+ 'menu.openWith': 'Открыть с помощью',
'menu.openInSidebar': 'Открыть на боковой панели',
'menu.openInFloat': 'Открыть на плавающей панели',
'menu.finder': 'Показать в Finder',
@@ -276,6 +283,7 @@ export const ru = {
/** Portuguese dictionary, checked against the English keys. */
export const pt = {
+ 'menu.openWith': 'Abrir com',
'menu.openInSidebar': 'Abrir na barra lateral',
'menu.openInFloat': 'Abrir em painel flutuante',
'menu.finder': 'Mostrar no Finder',
diff --git a/packages/client/ui-open-in-app/src/client/session-open-with.ts b/packages/client/ui-open-in-app/src/client/session-open-with.ts
new file mode 100644
index 0000000000..07f794d581
--- /dev/null
+++ b/packages/client/ui-open-in-app/src/client/session-open-with.ts
@@ -0,0 +1,52 @@
+/** Application submenus for captured Sessions, shared by header and row menus. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ContextMenuTargetMap, MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client'
+import type {} from '@deepseek-ai/dsh-session-log-export/client'
+import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
+import { APP_LABEL_KEY } from './applications.ts'
+import type { OpenInAppController } from './controller.ts'
+import type { OpenInAppToastSource } from './open-toast.tsx'
+
+/**
+ * Share directory availability, successful choices, and launch feedback with the file-tree control.
+ * @param ctx - owning plugin context.
+ * @param controller - directory application catalog and launcher.
+ * @param t - localized action labels.
+ * @param report - app-wide native operation feedback; launch failures resolve without registry feedback.
+ */
+export function registerSessionOpenWith(
+ ctx: Context,
+ controller: OpenInAppController,
+ t: PropsLocale<'open-in-app'>['t'],
+ report: OpenInAppToastSource['report'],
+): void {
+ const resolve = ({ sessionId }: ContextMenuTargetMap['sessionHeader']): readonly MenuAction[] => {
+ const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
+ const applications = (controller.apps.getSnapshot() ?? []).flatMap((id) => {
+ const key = APP_LABEL_KEY[id]
+ return key === undefined ? [] : [{ id, name: t(key) }]
+ })
+ if (!directory || applications.length === 0) return []
+ const disabled = controller.operation.getSnapshot().phase === 'busy'
+ return [{
+ id: 'openWith', label: t('menu.openWith'), icon: { key: 'open-in-app.application' }, disabled,
+ children: applications.map(({ id, name }) => ({
+ id: `app:${id}`, label: name, disabled,
+ icon: { key: 'open-in-app.application', src: `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${id}` },
+ run: async () => {
+ const outcome = await controller.launch(id, directory)
+ if (outcome.kind === 'opened') controller.choose(id)
+ report(outcome)
+ },
+ })),
+ }]
+ }
+ for (const target of ['session', 'sessionHeader'] as const) {
+ ctx.effect(() => ctx.contextMenus.register({
+ id: 'open-in-app.openWith', target, order: 500,
+ observe: () => [controller.apps, controller.operation, ctx.sessions.list, ctx.locale],
+ resolve: value => resolve(value).map(action => target === 'sessionHeader' ? { ...action, separatorBefore: true } : action),
+ }), `open-in-app: ${target} directory menu`)
+ }
+}
diff --git a/packages/client/ui-open-in-app/src/client/session-reveal.tsx b/packages/client/ui-open-in-app/src/client/session-reveal.tsx
deleted file mode 100644
index 0dcf0c3a86..0000000000
--- a/packages/client/ui-open-in-app/src/client/session-reveal.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-/** Native directory reveal contributed to the captured Session's structured menu. */
-import type { Context } from '@deepseek-ai/cordis'
-import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { OpenInAppController } from './controller.ts'
-import type { OpenInAppToastSource } from './open-toast.tsx'
-
-/**
- * Share directory availability and launch feedback with the existing application controls.
- * @param ctx - owning plugin context.
- * @param controller - directory application catalog and launcher.
- * @param t - localized action and failure labels.
- * @param report - native operation feedback shared with the application controls.
- */
-export function registerSessionReveal(
- ctx: Context,
- controller: OpenInAppController,
- t: PropsLocale<'open-in-app'>['t'],
- report: OpenInAppToastSource['report'],
-): void {
- ctx.effect(() => ctx.contextMenus.register({
- id: 'open-in-app.reveal', target: 'session', order: 500,
- observe: () => [controller.apps, ctx.sessions.list, ctx.locale],
- resolve: ({ sessionId }) => {
- const directory = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
- const applications = controller.apps.getSnapshot()
- const manager = applications?.find(id => ['finder', 'explorer', 'filemanager'].includes(id))
- return [{
- id: 'reveal', label: t(applications?.includes('finder') ? 'menu.finder' : 'menu.reveal'), icon: { key: 'open-in-app.reveal' },
- disabled: !directory || manager === undefined, failureLabel: t('path.revealError'),
- ...(directory && manager !== undefined ? { run: async () => {
- const outcome = await controller.launch(manager, directory)
- report(outcome.kind === 'failed' ? { kind: 'failed', failure: 'revealError' } : outcome)
- } } : {}),
- }]
- },
- }), 'open-in-app: Session directory menu')
-}
diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
index e0457da73f..ca27f69e50 100644
--- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts
@@ -10,7 +10,6 @@ import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
* proving removal (HMR safety) and the injected controller faces.
*/
-import type { PropsRuntime, SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -118,6 +117,11 @@ describe('open-in-app browser half', () => {
it('renders captured application images and the default glyph without an image URL', () => {
const view = render(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/captured.png' } }))
expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/captured.png')
+ fireEvent.error(view.container.querySelector('img')!)
+ expect(view.container.querySelector('img')).toBeNull()
+ expect(view.container.querySelector('svg')).not.toBeNull()
+ view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application', src: '/new.png' } }))
+ expect(view.container.querySelector('img')?.getAttribute('src')).toBe('/new.png')
view.rerender(createElement(ContextMenuApplicationIcon, { icon: { key: 'open-in-app.application' } }))
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
@@ -141,14 +145,14 @@ describe('open-in-app browser half', () => {
'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.float', 'open-in-app.application',
])
expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0)
- await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) })
- menu.select('open-in-app.reveal/reveal')
+ await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false }) })
+ menu.select(menu.source.getSnapshot()[0]!.children![0]!.id)
menu.dispose()
- const face: Partial = ctx.slots.entries('conversation.session.header.utilities')[0]!.inject!()
+ const face: Partial = ctx.slots.entries('sidebar.right.tab.files.actions')[0]!.inject!()
await vi.waitFor(() => { expect(face.hooks?.openInAppLaunch.getSnapshot().phase).toBe(success ? 'idle' : 'error') })
expect(toast.hooks.toast.getSnapshot()).toEqual(success
? mobile ? { kind: 'opened', seq: 1 } : null
- : { kind: 'failed', failure: 'revealError', seq: 1 })
+ : { kind: 'failed', failure: 'openError', seq: 1 })
expect(notify).not.toHaveBeenCalled()
expect(fetcher).toHaveBeenCalledWith('open-in-app/open', expect.objectContaining({
body: JSON.stringify({ app: 'finder', path: '/captured' }),
@@ -196,7 +200,7 @@ describe('open-in-app browser half', () => {
const select = (cwd: string) => { list.set({ ...list.getSnapshot(), ids: [id], byId: {
[id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } },
} }) }
- const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+ const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const face = (entry?.inject as unknown as () => OpenInAppActionInjected)()
await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor']) })
select('/first')
@@ -281,7 +285,7 @@ describe('open-in-app browser half', () => {
list.set({ ...list.getSnapshot(), ids: [id], byId: {
[id]: { id, displayTitle: 'Main', cwd: '/workspace', running: false, blank: false, updatedAt: 0, retainedBy: { mainView: 1 } },
} })
- const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+ const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const face = (entry?.inject as unknown as () => OpenInAppActionInjected)()
await vi.waitFor(() => { expect(face.hooks.openInAppApps.getSnapshot()).toEqual(['finder']) })
const command = commands.get('workspace.openLocal')!
@@ -343,30 +347,13 @@ describe('open-in-app browser half', () => {
expect(ctx.slots.entries('shell.overlay')).toHaveLength(0)
})
- it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => {
+ it('keeps the file-tree control without an independent Session header opener and removes it on teardown', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
- const { ctx, fiber, list } = await bench()
- try {
- const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
- const files = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
- expect(files?.component).toBe(OpenInAppAction)
- expect(files?.inject).toBe(entry?.inject)
- expect(entry?.options).toMatchObject({ id: 'open-in-app' })
- const header = entry?.component as SlotComponent, 'sessionId' | 'useSessions'
- >>
- const id = 'main' as SessionId
- const props = { sessionId: id, useSessions: (select: (state: SessionListState) => T) => select(list.getSnapshot()) }
- expect(header(props)).toBeNull()
- for (const cwd of ['', '/first', '/second']) {
- list.set({ ...list.getSnapshot(), ids: [id], byId: {
- [id]: { id, displayTitle: 'Main', cwd, running: false, blank: false, updatedAt: 0, retainedBy: {} },
- } })
- if (cwd === '') expect(header(props)).toBeNull()
- else expect(header(props)).toMatchObject({ type: OpenInAppAction, props: { absolutePath: cwd } })
- }
- } finally { await fiber.dispose() }
- expect(headerEntryIds(ctx)).not.toContain('open-in-app')
+ const { ctx, fiber } = await bench()
+ expect(headerEntryIds(ctx)).toEqual([])
+ expect(ctx.slots.entries('sidebar.right.tab.files.actions')[0]?.component).toBe(OpenInAppAction)
+ await fiber.dispose()
+ expect(headerEntryIds(ctx)).toEqual([])
expect(ctx.slots.entries('sidebar.right.tab.files.actions')).toHaveLength(0)
})
@@ -381,7 +368,7 @@ describe('open-in-app browser half', () => {
})
vi.stubGlobal('fetch', fetcher)
const { ctx, fiber } = await bench()
- const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+ const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const injected: Partial | undefined = entry?.inject?.()
if (injected?.hooks === undefined || injected.iconUrl === undefined
|| injected.choose === undefined || injected.launch === undefined || injected.report === undefined) {
@@ -417,7 +404,7 @@ describe('open-in-app browser half', () => {
return new Response('', { status: 502 })
}))
const { ctx, fiber } = await bench()
- const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
+ const entry = ctx.slots.entries('sidebar.right.tab.files.actions')[0]
const injected: Partial | undefined = entry?.inject?.()
if (injected?.hooks === undefined || injected.launch === undefined) throw new Error('expected the injected launch action')
const { hooks } = injected
diff --git a/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts
new file mode 100644
index 0000000000..b8db4a0154
--- /dev/null
+++ b/packages/client/ui-open-in-app/tests/session-open-with.client.spec.ts
@@ -0,0 +1,124 @@
+import { Context } from '@deepseek-ai/cordis'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { OpenInAppController } from '../src/client/controller.ts'
+import { registerSessionOpenWith } from '../src/client/session-open-with.ts'
+import { en, zh, NS } from '../src/client/locales.ts'
+
+function bench(apps: readonly string[] | null = ['finder', 'cursor'], cwd: string | undefined = '/captured') {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ const locale = new LocaleRuntime(ctx)
+ ctx.provide('locale', locale)
+ locale.register(NS, { en, zh }); locale.setLocale('en')
+ const failed = vi.fn(), report = vi.fn()
+ const registry = new MenuRegistry(failed)
+ onTestFinished(() => { registry.dispose() })
+ ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never)
+ const sessionId = SessionId('captured'), mainId = SessionId('main')
+ const row = { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} }
+ const list = createSnapshotStore({ ids: [sessionId, mainId], byId: {
+ [sessionId]: row, [mainId]: { ...row, id: mainId, cwd: '/main', retainedBy: { mainView: 1 } },
+ }, phase: 'ready', projectionsBySession: {} })
+ ctx.provide('sessions', { list } as never)
+ const gate = Promise.withResolvers()
+ const fetcher = vi.fn(() => gate.promise)
+ const controller = new OpenInAppController(fetcher)
+ controller.choice.set(''); controller.apps.set(apps)
+ const choose = vi.spyOn(controller, 'choose')
+ const fiber = ctx.plugin((scope) => { registerSessionOpenWith(scope, controller, locale.bind(NS), report) })
+ const resolve = (kind: 'session' | 'sessionHeader' = 'session') => registry.resolve(kind === 'session'
+ ? { kind, value: { sessionId, displayTitle: 'Captured' } }
+ : { kind, value: { sessionId } })
+ return { ctx, registry, locale, controller, list, row, sessionId, fetcher, gate, choose, report, failed, fiber, resolve }
+}
+
+it.each([null, [], ['unlabelled']])('omits unavailable catalog %j from both menu targets', async (apps) => {
+ const b = bench(apps)
+ await b.fiber.await()
+ for (const kind of ['session', 'sessionHeader'] as const) expect(b.resolve(kind).source.getSnapshot()).toEqual([])
+})
+
+it('omits unknown directories and follows metadata, locale and applications without labeling preferences', async () => {
+ const b = bench()
+ await b.fiber.await()
+ b.list.set({ ...b.list.getSnapshot(), byId: {} })
+ const menu = b.resolve(), header = b.resolve('sessionHeader')
+ expect(menu.source.getSnapshot()).toEqual([])
+ b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: b.row } })
+ expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Open in', disabled: false })
+ expect(menu.source.getSnapshot()[0]?.separatorBefore).toBeUndefined()
+ expect(header.source.getSnapshot()[0]?.separatorBefore).toBe(true)
+ expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor'])
+ b.controller.choose('cursor')
+ expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Finder', 'Cursor'])
+ b.locale.setLocale('zh')
+ expect(menu.source.getSnapshot()[0]?.label).toBe('在应用中打开')
+ expect(header.source.getSnapshot()[0]?.children?.[1]?.label).toBe('Cursor')
+ b.controller.apps.set(['cursor', 'unknown', 'finder'])
+ expect(menu.source.getSnapshot()[0]?.children?.map(row => row.label)).toEqual(['Cursor', '访达'])
+ b.controller.apps.set(['finder'])
+ expect(menu.source.getSnapshot()[0]?.children).toHaveLength(1)
+ expect(menu.source.getSnapshot()[0]?.children?.[0]).toMatchObject({
+ label: '访达', icon: { key: 'open-in-app.application', src: 'open-in-app/icon/finder' },
+ })
+ b.controller.apps.set([])
+ expect(menu.source.getSnapshot()).toEqual([])
+ expect(header.source.getSnapshot()).toEqual([])
+})
+
+it.each(['session', 'sessionHeader'] as const)('launches the captured %s directory, disables all openers, and remembers only after success', async (kind) => {
+ const b = bench()
+ await b.fiber.await()
+ const menu = b.resolve(kind), observing = b.resolve(kind === 'session' ? 'sessionHeader' : 'session')
+ const action = menu.source.getSnapshot()[0]!.children![1]!
+ menu.select(action.id)
+ expect(b.fetcher).toHaveBeenCalledExactlyOnceWith('open-in-app/open', expect.objectContaining({ body: JSON.stringify({ app: 'cursor', path: '/captured' }) }))
+ expect(b.choose).not.toHaveBeenCalled()
+ expect(observing.source.getSnapshot()[0]).toMatchObject({ disabled: true })
+ expect(observing.source.getSnapshot()[0]?.children?.every(row => row.disabled)).toBe(true)
+ observing.select(observing.source.getSnapshot()[0]!.children![0]!.id)
+ expect(b.fetcher).toHaveBeenCalledOnce()
+ b.list.set({ ...b.list.getSnapshot(), byId: { [b.sessionId]: { ...b.row, cwd: '/changed' } } })
+ menu.dispose()
+ b.gate.resolve(new Response())
+ await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith({ kind: 'opened' }) })
+ expect(b.choose).toHaveBeenCalledExactlyOnceWith('cursor')
+ expect(b.controller.choice.getSnapshot()).toBe('cursor')
+ expect(observing.source.getSnapshot()[0]?.disabled).toBe(false)
+ expect(b.failed).not.toHaveBeenCalled()
+})
+
+it.each([{ kind: 'failed', failure: 'openError' }, { kind: 'skipped' }] as const)('does not remember a %j launch or duplicate its feedback', async (outcome) => {
+ const b = bench()
+ await b.fiber.await()
+ vi.spyOn(b.controller, 'launch').mockResolvedValue(outcome)
+ const menu = b.resolve()
+ menu.select(menu.source.getSnapshot()[0]!.children![1]!.id)
+ await vi.waitFor(() => { expect(b.report).toHaveBeenCalledExactlyOnceWith(outcome) })
+ expect(b.choose).not.toHaveBeenCalled()
+ expect(b.controller.choice.getSnapshot()).toBe('')
+ expect(b.failed).not.toHaveBeenCalled()
+})
+
+it('withdraws both registrations and their observers on unload and reinstalls without duplicates', async () => {
+ const b = bench()
+ await b.fiber.await()
+ const menu = b.resolve(), header = b.resolve('sessionHeader')
+ const id = menu.source.getSnapshot()[0]!.children![0]!.id
+ await b.fiber.dispose()
+ expect(menu.source.getSnapshot()).toEqual([])
+ expect(header.source.getSnapshot()).toEqual([])
+ menu.select(id)
+ expect(b.fetcher).not.toHaveBeenCalled()
+ b.controller.apps.set(['cursor'])
+ expect(b.resolve().source.getSnapshot()).toEqual([])
+ const replacement = b.ctx.plugin((scope) => { registerSessionOpenWith(scope, b.controller, b.locale.bind(NS), b.report) })
+ await replacement.await()
+ expect(b.resolve().source.getSnapshot()).toHaveLength(1)
+ expect(b.resolve('sessionHeader').source.getSnapshot()).toHaveLength(1)
+})
diff --git a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts b/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts
deleted file mode 100644
index 1638722be5..0000000000
--- a/packages/client/ui-open-in-app/tests/session-reveal.client.spec.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Context } from '@deepseek-ai/cordis'
-import { expect, it, onTestFinished, vi } from 'vitest'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
-import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
-import { SessionId } from '@deepseek-ai/dsh-session/types'
-import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { OpenInAppController } from '../src/client/controller.ts'
-import { registerSessionReveal } from '../src/client/session-reveal.tsx'
-import { en, zh, NS } from '../src/client/locales.ts'
-
-it.each([
- [['finder'], '/workspace', 'Show in Finder', false],
- [['explorer'], '/workspace', 'Show in folder', false],
- [['filemanager'], '/workspace', 'Show in folder', false],
- [['vscode'], '/workspace', 'Show in folder', true],
- [null, '/workspace', 'Show in folder', true],
- [['finder'], undefined, 'Show in Finder', true],
-] as const)('reveals the captured directory with catalog %j and path %s', async (apps, cwd, label, disabled) => {
- const ctx = new Context()
- onTestFinished(() => ctx.fiber.dispose())
- const locale = new LocaleRuntime(ctx)
- ctx.provide('locale', locale)
- locale.register(NS, { en, zh }); locale.setLocale('en')
- const registry = new MenuRegistry(vi.fn())
- onTestFinished(() => { registry.dispose() })
- ctx.provide('contextMenus', { register: registry.register.bind(registry) } as never)
- const sessionId = SessionId('captured')
- const list = createSnapshotStore({ ids: [sessionId], byId: {
- [sessionId]: { id: sessionId, ...(cwd === undefined ? {} : { cwd }), displayTitle: 'Captured', running: false, blank: false, updatedAt: 1, retainedBy: {} },
- }, phase: 'ready', projectionsBySession: {} })
- ctx.provide('sessions', { list } as never)
- const controller = new OpenInAppController()
- controller.apps.set(apps)
- const launch = vi.spyOn(controller, 'launch').mockResolvedValue({ kind: 'opened' })
- const report = vi.fn()
- registerSessionReveal(ctx, controller, locale.bind(NS), report)
- const menu = registry.resolve({ kind: 'session', value: { sessionId, displayTitle: 'Captured' } })
- expect(menu.source.getSnapshot()[0]).toMatchObject({ label, disabled })
- menu.select('open-in-app.reveal/reveal')
- if (disabled) {
- expect(launch).not.toHaveBeenCalled()
- expect(report).not.toHaveBeenCalled()
- } else {
- expect(launch).toHaveBeenCalledWith(apps?.[0], cwd)
- await vi.waitFor(() => { expect(report).toHaveBeenCalledWith({ kind: 'opened' }) })
- }
-})
diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json
index 9e6a381554..80b13b76e1 100644
--- a/packages/client/ui-open-in-app/tsconfig.json
+++ b/packages/client/ui-open-in-app/tsconfig.json
@@ -23,6 +23,9 @@
{
"path": "../../host/open-in-app"
},
+ {
+ "path": "../../session-query/session-log-export/tsconfig.client.json"
+ },
{
"path": "../locale/tsconfig.client.json"
},
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 8bb6988406..5b96268749 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -18,8 +18,8 @@
en: ead6be55c7c9d7cf
zh: 125ad36db7328551
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
- en: 289fe5c01b026e81
- zh: 5680066a8443fba9
+ en: 088b6d7b57b307e4
+ zh: 1a0f4085124d217e
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: f13b4ba09521d300
zh: a6093993d8fd0cdb
@@ -30,8 +30,8 @@
en: 81e58c7b342baf3c
zh: 6cd47b16124b215c
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
- en: 6fbd9df222b41e09
- zh: 3bdb4a6b9b6bff9e
+ en: 7edcce1f9fde5341
+ zh: 6e56fddb617aef5f
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: fd510d34404eb090
zh: d05b0890db929ae6
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 4e538ecaeb..d0913fac73 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
| `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. |
-| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
+| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Shift+Tab closes back to the anchor; Escape returns from an open submenu before closing the root. Selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
@@ -141,10 +141,12 @@ The atoms cannot read the application locale, so every piece of user-facing copy
`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
-Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted.
+Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. Pointer presses on that control or its descendants stay inside the menu interaction, so its click handler can toggle the popup. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted.
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
+Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight enters the child; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and clamps vertically with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there.
+
`Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur.
`MenuGroup` renders a `role="group"` section named by its localized heading, with an instance-owned heading id. It shares heading typography, spacing, and sticky positioning between custom menus and listboxes. Headings are transparent at rest; only `data-stuck` enables the theme's 94%-opaque group-header fill in either palette. Outside `data-platform="darwin"`, headings use `--dsw-radius-md` corners; the enclosing menu keeps its translucent material.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 5e0262cdc2..094f5baff1 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -49,7 +49,7 @@ kind: "package-library"
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
| `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 |
| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 |
-| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
+| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Shift+Tab 关闭并把焦点还给锚点;Escape 先退出已打开的子菜单,再关闭根菜单。选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 |
@@ -141,10 +141,12 @@ kind: "package-library"
`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
-锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。
+锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。该控件及其子元素上的指针按下仍属于菜单内部交互,让控件的点击处理器可以切换弹窗。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
+子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。ArrowRight 进入子级;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并在垂直方向限制位置、内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。
+
`Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。
`MenuGroup` 渲染以本地化标题命名的 `role="group"` 区段,标题 id 由各实例独立持有。自定义菜单与列表框共用其标题字体、间距和吸顶定位。标题原位透明;只有 `data-stuck` 才启用主题在浅/深色模式下的 94% 不透明分组标题填充。`data-platform="darwin"` 以外的标题使用 `--dsw-radius-md` 圆角;外围菜单保留半透明材质。
diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css
index d3c24aad2b..6c5fc1d185 100644
--- a/packages/client/ui-primitives/src/Menu.module.css
+++ b/packages/client/ui-primitives/src/Menu.module.css
@@ -31,7 +31,7 @@
left: 0;
z-index: 100;
min-width: 144px;
- max-width: 360px;
+ max-width: min(360px, calc(100vw - 24px));
}
/* Legacy slots can render no rows; keep empty context popups out of layout. */
@@ -64,9 +64,8 @@
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
* (the portal MARGIN in Menu.tsx; the top side widens to the frame's published
* overlay inset) and taller content scrolls inside
- * .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
- * this class — the overflow clip would crop the side card, so they rely on
- * staying short. */
+ * .viewport, so a pinned .footer stays visible. Submenus escape the scroll
+ * clip through their own body portal. */
.scrollable {
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-overlay-top, 12px)));
}
@@ -290,24 +289,22 @@
display: block;
}
-/* The submenu aligns with the parent's bottom edge. Its hit bridge covers
- * the menu inset and the 6px gap so pointer movement keeps it open. */
+/* Child cards remain mounted while resizing between side and single-panel views. */
.submenu {
- position: absolute;
- top: auto;
- bottom: -4px;
- left: calc(100% + 10px);
- z-index: 101;
+ position: fixed;
+ z-index: 1101;
min-width: 163px;
+ max-width: min(360px, calc(100vw - 24px));
}
-.submenu::before {
- content: '';
- position: absolute;
- top: 0;
- bottom: 0;
- left: -10px;
- width: 10px;
+/* Retain the parent's geometry and rows for placement and focus restoration. */
+.parentPage {
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.submenu > .item {
+ flex: none;
}
.list[data-menu-phase='dismiss'],
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
index 753de26f95..f27e728005 100644
--- a/packages/client/ui-primitives/src/Menu.tsx
+++ b/packages/client/ui-primitives/src/Menu.tsx
@@ -2,7 +2,8 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode, SyntheticEvent } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
-import { IconCheckOutlineRegular } from './icons/index.tsx'
+import { IconCheckOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular } from './icons/index.tsx'
+import { useMenuSubmenuPosition } from './menu-submenu-position.ts'
import { overlayTopMargin } from './overlay-top-margin.ts'
import { usePointerGrace } from './pointer-grace.ts'
import { isBehindModal } from './useModalLayer.ts'
@@ -23,7 +24,7 @@ export interface MenuItem {
icon?: ReactNode
/** Destructive row: error-colored text/icon and danger hover fill. */
danger?: boolean
- /** Nested card opened to the right on hover/focus. */
+ /** Child choices opened by hover, focus, click, or ArrowRight; placement follows available space. */
submenu?: readonly MenuEntry[]
}
@@ -143,14 +144,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* list instead — and Escape or Shift+Tab close it and return focus to the
* anchor's first button, and selecting a row does the same — the rows unmount
* with the list. Only a keyboard on the trigger or inside the list is
- * intercepted; Tab presses elsewhere on the page stay the browser's.
+ * intercepted; Tab presses elsewhere on the page stay the browser's. Escape
+ * first returns from a visible child, while ArrowRight enters and ArrowLeft returns.
+ * When neither side fits, click or ArrowRight opens a single-panel child page;
+ * focus alone retains the parent page.
* @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment.
* @param props.exiting - retain an inert popup during dismissal or replacement fade.
- * @param props.returnFocus - original control captured before asynchronous menu preparation.
+ * @param props.returnFocus - original control captured before asynchronous menu preparation;
+ * pointer presses on it or its descendants stay inside the menu interaction.
* @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way.
* @param props.open - whether the list is showing (owner-controlled).
* @param props.anchor - the trigger element (rendered in place).
* @param props.items - selectable data rows and optional separators (default none; with no `children` either, the list is empty).
+ * @param props.submenuBackLabel - localized single-panel return label; omitted uses the caller's
+ * parent-row label beside a back arrow, without selecting that row.
* @param props.selectedId - row shown as selected.
* @param props.selectedIds - rows shown as selected when a menu contains independent option groups.
* @param props.onSelect - data-row activation callback (not called for disabled rows or submenu parents that only open children).
@@ -192,7 +199,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* document.body outside the owner's DOM subtree.
* @returns anchor wrapper with the conditional list.
*/
-export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: {
+export function Menu({ open, anchor, items = [], children, submenuBackLabel, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: {
open: boolean
/** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */
contextual?: boolean
@@ -203,6 +210,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
autoFocus?: boolean
anchor: ReactNode
items?: readonly MenuEntry[]
+ /** Localized return action; omitted uses the caller's parent label with a back arrow. */
+ submenuBackLabel?: string | undefined
children?: ReactNode
footer?: readonly MenuEntry[]
selectedId?: string | undefined
@@ -272,14 +281,88 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
queueMicrotask(() => {
if (openRef.current && exitingRef.current === undefined) return
const active = document.activeElement
- if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation)
+ if (active === null || active === document.body || listRef.current?.contains(active) === true
+ || submenuRef.current?.contains(active) === true) refocusAnchor(navigation)
})
}
const exitingRef = useRef(exiting)
exitingRef.current = exiting
const openRef = useRef(open)
openRef.current = open
- const [openSubmenuId, setOpenSubmenuId] = useState(null)
+ const [openSubmenu, setOpenSubmenu] = useState<{ id: string; activated: boolean } | null>(null)
+ const liveSubmenu = [...items, ...(footer ?? [])].find((entry): entry is MenuItem =>
+ !isSeparator(entry) && !isLabel(entry) && entry.id === openSubmenu?.id
+ && entry.disabled !== true && entry.submenu !== undefined && entry.submenu.length > 0)
+ const openSubmenuId = liveSubmenu?.id ?? null
+ const submenuInvalidated = openSubmenu !== null && liveSubmenu === undefined
+ const submenuRef = useRef(null)
+ const submenuTrigger = useRef(null)
+ // Capture focus ownership before React removes an invalidated child from the DOM.
+ const restoreInvalidatedFocus = submenuInvalidated && open && exiting === undefined
+ && (submenuRef.current?.contains(document.activeElement) === true || submenuTrigger.current === document.activeElement)
+ const enterSubmenu = useRef(false)
+ const submenuBackFocused = useRef(false)
+ const focusFirstChild = (): void => {
+ const child = submenuRef.current
+ const first = child?.querySelector('button[role="menuitem"]:not(:disabled):not([data-menu-back])')
+ ?? child?.querySelector('button:not(:disabled)')
+ first?.focus()
+ }
+ const returningFromSubmenu = useRef(false)
+ const submenuPosition = useMenuSubmenuPosition(
+ open && exiting === undefined ? openSubmenuId : null, listRef, submenuTrigger, submenuRef, contextual,
+ )
+ const submenuPage = openSubmenuId !== null && openSubmenu?.activated === true && submenuPosition?.page === true
+ const submenuHidden = submenuPosition === null || (submenuPosition.page && !submenuPage)
+ const submenuState = useRef({ id: openSubmenuId, page: submenuPage })
+ submenuState.current = { id: submenuHidden ? null : openSubmenuId, page: submenuPage }
+ const { arm: armSubmenuClose, cancel: cancelSubmenuClose } = usePointerGrace(() => {
+ if (submenuState.current.page) return
+ if (submenuRef.current?.contains(document.activeElement)) returnToParent()
+ else setOpenSubmenu(null)
+ })
+ const openChild = (id: string, trigger: HTMLButtonElement, enter = false): void => {
+ cancelSubmenuClose()
+ submenuTrigger.current = trigger
+ enterSubmenu.current = enter
+ setOpenSubmenu(current => current?.id === id
+ ? enter && !current.activated ? { id, activated: true } : current
+ : { id, activated: enter })
+ if (enter) focusFirstChild()
+ }
+ const returnToParent = (): void => {
+ cancelSubmenuClose()
+ setOpenSubmenu(null)
+ returningFromSubmenu.current = true
+ }
+ useLayoutEffect(() => {
+ if (submenuInvalidated) {
+ cancelSubmenuClose()
+ enterSubmenu.current = false
+ setOpenSubmenu(null)
+ if (restoreInvalidatedFocus
+ && (document.activeElement === document.body || document.activeElement === submenuTrigger.current)) {
+ const trigger = submenuTrigger.current
+ const target = trigger?.isConnected === true && !trigger.disabled ? trigger
+ : listRef.current?.querySelector('button:not(:disabled)')
+ returningFromSubmenu.current = true
+ if (target != null) target.focus()
+ else refocusAnchor()
+ returningFromSubmenu.current = false
+ }
+ }
+ if (openSubmenuId === null) submenuBackFocused.current = false
+ if (openSubmenuId === null && returningFromSubmenu.current) {
+ submenuTrigger.current?.focus()
+ returningFromSubmenu.current = false
+ }
+ if (openSubmenuId === null || submenuHidden) return
+ if (enterSubmenu.current || (submenuPage && listRef.current?.contains(document.activeElement))
+ || (!submenuPage && submenuBackFocused.current)) {
+ focusFirstChild()
+ enterSubmenu.current = false
+ }
+ }, [openSubmenuId, submenuPosition, submenuPage, submenuHidden, submenuInvalidated, restoreInvalidatedFocus, cancelSubmenuClose])
const [fixedPos, setFixedPos] = useState(null)
const close = useRef(onClose)
close.current = onClose
@@ -368,7 +451,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
useEffect(() => {
if (!open || exiting !== undefined || (portal && fixedPos === null) || !autoFocus
- || listRef.current?.contains(document.activeElement)) return
+ || listRef.current?.contains(document.activeElement) || submenuRef.current?.contains(document.activeElement)) return
const first = listRef.current?.querySelector('button:not(:disabled)')
walkIndex.current = first === undefined || first === null ? null : 0
if (first != null) focusWithoutRing(first)
@@ -376,7 +459,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
useEffect(() => {
if (!open || exiting !== undefined) {
- setOpenSubmenuId(null)
+ setOpenSubmenu(null)
walkIndex.current = null
return
}
@@ -384,8 +467,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const onPointerDown = (e: PointerEvent) => {
if (!(e.target instanceof Node) || (contextual && e.button === 2)) return
// The portaled list is outside the anchor subtree; check both.
- if (rootRef.current?.contains(e.target) === true) return
- if (listRef.current?.contains(e.target) === true) return
+ if (rootRef.current?.contains(e.target) === true || returnFocus?.()?.contains(e.target) === true) return
+ if (listRef.current?.contains(e.target) === true || submenuRef.current?.contains(e.target) === true) return
onClose()
}
const onKeyDown = (e: KeyboardEvent) => {
@@ -394,23 +477,36 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
// Where the keyboard is, computed once: the menu owns it when it holds a
// row or sits on its anchor region.
const focused = document.activeElement
- const insideList = listRef.current?.contains(focused) === true
+ const insideChild = submenuRef.current?.contains(focused) === true
+ const insideList = listRef.current?.contains(focused) === true || insideChild
const anchored = focused === returnFocus?.() || rootRef.current?.contains(focused) === true || insideList
if (e.key === 'Escape' && !e.shiftKey) {
e.preventDefault()
if (e.repeat) return
+ if (submenuState.current.id !== null) { returnToParent(); return }
// Closing hands the keyboard back when the menu had it — and, as this
// primitive always did for autoFocus menus, when it held the keyboard
// and lost it again (a row that unmounted under it).
onClose()
if (anchored || autoFocus) refocusAnchor()
}
+ if (e.key === 'ArrowLeft' && anchored && submenuState.current.id !== null) {
+ e.preventDefault()
+ returnToParent()
+ return
+ }
+ if (e.key === 'ArrowRight' && anchored && focused instanceof HTMLButtonElement
+ && focused.getAttribute('aria-haspopup') === 'menu') {
+ e.preventDefault()
+ focused.click()
+ return
+ }
// Tab settles like Enter and Shift+Tab leaves like Escape, so a menu's
// keys mean what they mean in the composer. Only a keyboard already on
// the trigger or inside the list is intercepted: Tab elsewhere on the
// page keeps the browser's traversal even while a menu is open.
if (e.key === 'Tab') {
- const list = listRef.current
+ const list = submenuState.current.page ? submenuRef.current : listRef.current
if (list === null || !anchored) return
if (e.shiftKey) {
e.preventDefault()
@@ -447,7 +543,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
// otherwise re-enter at the near end on every press and the walk would
// alternate between two rows.
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
- const list = listRef.current
+ const list = insideChild || submenuState.current.page ? submenuRef.current : listRef.current
if (list === null || !anchored) return
const buttons = Array.from(list.querySelectorAll('button:not(:disabled)'))
if (buttons.length === 0) return
@@ -481,7 +577,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
document.removeEventListener('keydown', onEscape, true)
window.removeEventListener('blur', onWindowBlur)
}
- }, [open, onClose, autoFocus, contextual, exiting])
+ }, [open, onClose, autoFocus, contextual, exiting, returnFocus])
useLayoutEffect(() => {
if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor()
@@ -492,12 +588,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
// effect, not the listener effect above: that one re-runs on every `onClose`
// identity change and would cancel the grace mid-transit.
useEffect(() => {
- if (!open) cancelClose()
- }, [open, cancelClose])
+ if (!open) { cancelClose(); cancelSubmenuClose() }
+ }, [open, cancelClose, cancelSubmenuClose])
- // The submenu card is absolutely positioned outside the list box; the
- // scroll clip would crop it, so only submenu-free menus get the height cap.
- const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
+ const hoverAllowed = (event: { pointerType: string }): boolean => event.pointerType !== 'touch'
+ && window.matchMedia('(hover: hover)').matches
const itemColumns = iconColumns(items, contextual)
const footerColumns = iconColumns(footer ?? [], contextual)
@@ -516,8 +611,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
{ setOpenSubmenuId(entry.id) } : undefined}
- onMouseLeave={() => { setOpenSubmenuId(null) }}
+ onPointerEnter={hasSub ? (event) => {
+ if (!hoverAllowed(event) || entry.disabled) return
+ const trigger = event.currentTarget.querySelector
('button')
+ if (trigger !== null) openChild(entry.id, trigger)
+ } : undefined}
+ onPointerLeave={hasSub ? (event) => {
+ if (hoverAllowed(event) && !submenuState.current.page) armSubmenuClose()
+ } : undefined}
>
{ setOpenSubmenuId(entry.id) } : undefined}
- onClick={() => {
+ aria-expanded={hasSub ? subOpen && !submenuHidden : undefined}
+ onFocus={hasSub ? (event) => {
+ if (!returningFromSubmenu.current) openChild(entry.id, event.currentTarget)
+ } : undefined}
+ onClick={(event) => {
if (hasSub) {
- setOpenSubmenuId(entry.id)
+ openChild(entry.id, event.currentTarget, true)
return
}
onSelect?.(entry.id)
@@ -541,27 +644,53 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
{entry.shortcut !== undefined && }
{/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */}
{selected && selection === 'check' && }
+ {hasSub && }
- {subOpen && entry.submenu !== undefined && (
-
- {entry.submenu.map(sub => isSeparator(sub)
- ?
- : isLabel(sub) ? {sub.text}
: (
- { onSelect?.(sub.id); refocusAfterSelection() }}
- >
- {(sub.icon !== undefined || subColumns.has(sub)) && {sub.icon} }
- {sub.label}
- {sub.shortcut !== undefined && }
-
- ))}
-
+ {subOpen && entry.submenu !== undefined && exiting === undefined && createPortal(
+ { submenuBackFocused.current = event.target.hasAttribute('data-menu-back') }}
+ onBlur={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget)) submenuBackFocused.current = false
+ }}
+ onClick={(event) => {
+ event.stopPropagation()
+ refocusAfterSelection()
+ }}
+ >
+ {submenuPage && <>
+
+
+ {submenuBackLabel ?? entry.label}
+
+
+ >}
+
+ {entry.submenu.map(sub => isSeparator(sub)
+ ?
+ : isLabel(sub) ?
{sub.text}
: (
+
{ onSelect?.(sub.id) }}
+ >
+ {(sub.icon !== undefined || subColumns.has(sub)) && {sub.icon} }
+ {sub.label}
+ {sub.shortcut !== undefined && }
+
+ ))}
+
+ , document.body,
)}
)
@@ -575,7 +704,8 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const row = e.target instanceof Element ? e.target.closest('button[role="menuitem"]') : null
if (row === null || row.getAttribute('aria-haspopup') === 'menu') return
if (row.closest('[role="menu"]') !== e.currentTarget) return
- setOpenSubmenuId(null)
+ cancelSubmenuClose()
+ setOpenSubmenu(null)
}
// Portal lists render hidden until placed: the placement effect measures
@@ -585,10 +715,10 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
const list = open && (
{
event.preventDefault(); event.stopPropagation(); onClose()
} : undefined}
- onMouseOver={collapseSubmenuFrom}
+ onPointerOver={(event) => { if (hoverAllowed(event)) collapseSubmenuFrom(event) }}
onFocus={collapseSubmenuFrom}
>
@@ -629,7 +759,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
ref={rootRef}
className={clsx(css.root, className)}
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
- onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
+ onPointerLeave={closeOnPointerLeave ? (event) => { if (open && hoverAllowed(event)) armClose() } : undefined}
>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
diff --git a/packages/client/ui-primitives/src/menu-submenu-position.ts b/packages/client/ui-primitives/src/menu-submenu-position.ts
new file mode 100644
index 0000000000..df958d87a8
--- /dev/null
+++ b/packages/client/ui-primitives/src/menu-submenu-position.ts
@@ -0,0 +1,78 @@
+/** Viewport placement for Menu's portaled child card, independent of input device. */
+import { useLayoutEffect, useState, type RefObject } from 'react'
+import { overlayTopMargin } from './overlay-top-margin.ts'
+
+interface SubmenuPosition {
+ left: number
+ top: number
+ maxHeight: number
+ width?: number
+ page: boolean
+}
+
+/**
+ * Fit a child beside its parent, or over the parent's panel when neither side fits.
+ * CSS cannot choose a sibling portal's placement from both measured card widths.
+ * @param active - The mounted parent's row ID, or null while closed.
+ * @param parent - Parent card, including its scroll region.
+ * @param trigger - The parent row opening the child.
+ * @param child - Child card, measured before its first paint.
+ * @param contextual - Whether frame clearance is excluded for a context menu.
+ * @returns Viewport coordinates and the single-panel navigation mode, or null before placement.
+ */
+export function useMenuSubmenuPosition(
+ active: string | null,
+ parent: RefObject
,
+ trigger: RefObject,
+ child: RefObject,
+ contextual: boolean,
+): SubmenuPosition | null {
+ const [position, setPosition] = useState(null)
+ useLayoutEffect(() => {
+ if (active === null) { setPosition(null); return }
+ const place = () => {
+ if (parent.current === null || trigger.current === null || child.current === null) return
+ const panel = parent.current.getBoundingClientRect()
+ const row = trigger.current.getBoundingClientRect()
+ const margin = 12
+ const gap = 4
+ const vw = window.innerWidth
+ const vh = window.innerHeight
+ const topMargin = contextual ? margin : overlayTopMargin(margin, row, gap)
+ const maxHeight = Math.max(0, vh - topMargin - margin)
+ // Keep natural width measurable after returning from a single-panel page.
+ child.current.style.width = ''
+ const width = child.current.offsetWidth
+ const right = panel.right + gap
+ const left = panel.left - gap - width
+ const page = right + width > vw - margin && left < margin
+ const pageWidth = Math.min(Math.max(panel.width, width), vw - margin * 2)
+ const actualWidth = page ? pageWidth : width
+ const x = page ? panel.left : right + width <= vw - margin ? right : left
+ const height = Math.min(child.current.offsetHeight, maxHeight)
+ const y = page ? panel.top : row.top
+ const next: SubmenuPosition = {
+ left: Math.max(margin, Math.min(x, vw - actualWidth - margin)),
+ top: Math.max(topMargin, Math.min(y, vh - height - margin)),
+ maxHeight,
+ ...(page ? { width: pageWidth } : {}),
+ page,
+ }
+ if (page) child.current.style.width = `${pageWidth}px`
+ setPosition(current => current?.left === next.left && current.top === next.top
+ && current.maxHeight === next.maxHeight && current.width === next.width && current.page === next.page ? current : next)
+ }
+ place()
+ let frame: number
+ const track = () => { place(); frame = requestAnimationFrame(track) }
+ frame = requestAnimationFrame(track)
+ window.addEventListener('resize', place)
+ window.addEventListener('scroll', place, true)
+ return () => {
+ cancelAnimationFrame(frame)
+ window.removeEventListener('resize', place)
+ window.removeEventListener('scroll', place, true)
+ }
+ }, [active, parent, trigger, child, contextual])
+ return position
+}
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
index 8b198e4365..c83f54d899 100644
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
@@ -173,6 +173,10 @@ describe('MenuItemButton', () => {
})
describe('Menu', () => {
+ beforeEach(() => {
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
+ })
+ afterEach(() => { vi.unstubAllGlobals() })
const items = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta', disabled: true },
@@ -534,9 +538,9 @@ describe('Menu', () => {
expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined()
fireEvent.focus(screen.getByRole('menuitem', { name: 'Row' }))
expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull()
- fireEvent.mouseEnter(parent.parentElement as HTMLElement)
+ fireEvent.pointerEnter(parent.parentElement as HTMLElement)
expect(screen.getByRole('menuitem', { name: 'Sub' })).toBeDefined()
- fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Row' }))
+ fireEvent.pointerOver(screen.getByRole('menuitem', { name: 'Row' }))
expect(screen.queryByRole('menuitem', { name: 'Sub' })).toBeNull()
})
@@ -568,13 +572,13 @@ describe('Menu', () => {
fireEvent.click(parent)
expect(onSelect).not.toHaveBeenCalled()
fireEvent.focus(parent)
- fireEvent.mouseEnter(wrap)
+ fireEvent.pointerEnter(wrap)
expect(screen.getByTestId('sub-ic')).toBeDefined()
expect(screen.getByRole('menuitem', { name: 'Create ok' }).getAttribute('aria-keyshortcuts')).toBe('Meta+N')
expect(screen.getByRole('menuitem', { name: 'Create ok' }).querySelectorAll('kbd')).toHaveLength(2)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
expect(onSelect).toHaveBeenCalledWith('ok')
- fireEvent.mouseLeave(wrap)
+ fireEvent.focus(plain)
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
})
@@ -689,7 +693,7 @@ describe('Menu', () => {
expect(onSelect).toHaveBeenCalledWith('new')
})
- it('caps the list height for internal scrolling unless a submenu row is present', () => {
+ it('caps the list height for internal scrolling including menus with submenus', () => {
const { rerender } = render(
trigger} items={items} onSelect={() => {}} onClose={() => {}} />)
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
@@ -701,7 +705,7 @@ describe('Menu', () => {
onSelect={() => {}}
onClose={() => {}}
/>)
- expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
+ expect(screen.getByRole('menu').className).toMatch(/scrollable/)
})
})
diff --git a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx
new file mode 100644
index 0000000000..64145f8439
--- /dev/null
+++ b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx
@@ -0,0 +1,363 @@
+// @vitest-environment jsdom
+/** Submenu interactions use explicit geometry and pointer capabilities, not the host screen. */
+import { useRef, useState } from 'react'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Menu, MenuItemButton, type MenuEntry } from '../src/Menu.tsx'
+import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
+
+let panel: DOMRect
+let row: DOMRect
+let childHeight: number
+let hover: boolean
+
+beforeEach(() => {
+ panel = new DOMRect(100, 100, 200, 300)
+ row = new DOMRect(104, 140, 192, 34)
+ childHeight = 100
+ hover = true
+ vi.stubGlobal('innerWidth', 1024)
+ vi.stubGlobal('innerHeight', 768)
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: hover })))
+ vi.useFakeTimers()
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ return this.tagName === 'BUTTON' ? row : panel
+ })
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(200)
+ vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
+ return this.className.includes('submenu') ? childHeight : panel.height
+ })
+})
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+})
+
+function pointer(
+ target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'touch', relatedTarget: EventTarget | null = null,
+) {
+ const event = new MouseEvent(type, { bubbles: true, relatedTarget })
+ Object.defineProperty(event, 'pointerType', { value: pointerType })
+ fireEvent(target, event)
+}
+
+const entries = [
+ { id: 'parent', label: 'Applications', submenu: [
+ { id: 'disabled', label: 'Unavailable', disabled: true },
+ { id: 'first', label: 'First app' },
+ { id: 'second', label: 'Second app' },
+ ] },
+ { id: 'other', label: 'Other action' },
+]
+
+function Fixture({ onSelect = () => {}, backLabel = 'Go back' }: { onSelect?: (id: string) => void; backLabel?: string }) {
+ const [open, setOpen] = useState(false)
+ return { setOpen(true) }}>More}
+ items={entries} onClose={() =>{ setOpen(false) }}
+ onSelect={(id) => { onSelect(id); setOpen(false) }}>
+ { setOpen(false) }}>Plugin action
+
+}
+
+function ExternalTriggerFixture({ onClose }: { onClose: () => void }) {
+ const trigger = useRef(null)
+ const [open, setOpen] = useState(false)
+ return <>
+ { setOpen(value => !value) }}>External more
+ trigger.current}
+ getAnchorRect={() => trigger.current?.getBoundingClientRect() ?? null}
+ items={entries} onClose={() => { onClose(); setOpen(false) }} />
+ >
+}
+
+function openMenu() {
+ const trigger = screen.getByRole('button', { name: 'More' })
+ act(() =>{ trigger.focus() })
+ fireEvent.click(trigger)
+ return trigger
+}
+
+function childMenu() {
+ return screen.getByRole('menuitem', { name: 'First app' }).closest('[role="menu"]')!
+}
+
+describe('Menu submenus', () => {
+ it('lets an external return-focus trigger toggle closed through pointerdown then click', () => {
+ const close = vi.fn()
+ render( )
+ const target = screen.getByText('External more')
+ fireEvent.pointerDown(target)
+ fireEvent.click(target)
+ expect(screen.getByRole('menu')).toBeDefined()
+ fireEvent.pointerDown(target)
+ expect(close).not.toHaveBeenCalled()
+ fireEvent.click(target)
+ expect(screen.queryByRole('menu')).toBeNull()
+ })
+
+ it.each(['removed', 'disabled', 'empty'] as const)('restores the root page when the active parent becomes %s', (change) => {
+ vi.stubGlobal('innerWidth', 390)
+ const props = { open: true, anchor: null, submenuBackLabel: 'Go back', onClose: vi.fn() }
+ const view = render( )
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
+ const changed: MenuEntry[] = change === 'removed' ? [entries[1]!]
+ : [{ ...entries[0]!, ...(change === 'disabled' ? { disabled: true } : { submenu: [] }) }, entries[1]!]
+ view.rerender( )
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ expect(screen.getByRole('menuitem', { name: 'Other action' })).toBeDefined()
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: change === 'empty' ? 'Applications' : 'Other action' }))
+ view.rerender( )
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ expect(screen.getByRole('menuitem', { name: 'Applications' }).getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it.each([390, 1024])('restores focus when an active footer submenu is removed at %ipx', (width) => {
+ vi.stubGlobal('innerWidth', width)
+ const props = { open: true, anchor: null, items: [entries[1]!], onClose: vi.fn() }
+ const view = render( )
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ view.rerender( )
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' }))
+ })
+
+ it('preserves focus already moved outside an invalidated submenu', () => {
+ vi.stubGlobal('innerWidth', 390)
+ const props = { open: true, anchor: Outside control , onClose: vi.fn() }
+ const view = render( )
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ const outside = screen.getByRole('button', { name: 'Outside control' })
+ act(() => { outside.focus() })
+ view.rerender( )
+ expect(screen.getByRole('menuitem', { name: 'Other action' })).toBeDefined()
+ expect(document.activeElement).toBe(outside)
+ })
+
+ it('returns focus to the anchor when invalidation leaves no enabled root row', () => {
+ vi.stubGlobal('innerWidth', 390)
+ const props = { open: true, anchor: More , onClose: vi.fn() }
+ const view = render( )
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ view.rerender( )
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'More' }))
+ })
+
+ it('shows a chevron, isolates the child keyboard walk, and returns before closing the root', () => {
+ render( )
+ const trigger = openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ expect(parent.querySelector('[data-icon="dsw-icon-chevron-right-outline-regular"]')).not.toBeNull()
+ act(() =>{ parent.focus() })
+ expect(parent.getAttribute('aria-expanded')).toBe('true')
+ expect(document.activeElement).toBe(parent)
+ fireEvent.keyDown(parent, { key: 'ArrowRight' })
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Second app' }))
+ fireEvent.keyDown(document.activeElement!, { key: 'Home' })
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' })
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ expect(document.activeElement).toBe(parent)
+ fireEvent.keyDown(parent, { key: 'ArrowRight' })
+ fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
+ expect(screen.getByRole('menu')).toBeDefined()
+ expect(document.activeElement).toBe(parent)
+ fireEvent.keyDown(parent, { key: 'Escape' })
+ expect(screen.queryByRole('menu')).toBeNull()
+ expect(document.activeElement).toBe(trigger)
+ })
+
+ it.each(['click', 'Tab'])('selects the child with %s once and restores the original trigger', async (method) => {
+ const select = vi.fn()
+ render( )
+ const trigger = openMenu()
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ const child = screen.getByRole('menuitem', { name: 'First app' })
+ fireEvent.pointerDown(child)
+ expect(screen.getAllByRole('menu')).toHaveLength(2)
+ if (method === 'click') fireEvent.click(child)
+ else fireEvent.keyDown(child, { key: 'Tab' })
+ await act(async () => { await Promise.resolve() })
+ expect(select).toHaveBeenCalledExactlyOnceWith('first')
+ expect(screen.queryByRole('menu')).toBeNull()
+ expect(document.activeElement).toBe(trigger)
+ })
+
+ it('portals the child outside parent scrolling and flips left with a viewport height cap', () => {
+ panel = new DOMRect(780, 620, 200, 120)
+ row = new DOMRect(784, 680, 192, 34)
+ childHeight = 1000
+ render( )
+ openMenu()
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ const menu = childMenu()
+ expect(menu.parentElement).toBe(document.body)
+ expect(menu.style.left).toBe('576px')
+ expect(menu.style.top).toBe('12px')
+ expect(menu.style.maxHeight).toBe('744px')
+ expect(screen.getByRole('menuitem', { name: 'Applications' }).closest('[role="menu"]')?.contains(menu)).toBe(false)
+ })
+
+ it('uses one visible panel at 390px and a localized non-selecting return action', () => {
+ vi.stubGlobal('innerWidth', 390)
+ panel = new DOMRect(170, 100, 200, 300)
+ const select = vi.fn()
+ render( )
+ openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ fireEvent.click(parent)
+ const menu = childMenu()
+ expect(screen.getAllByRole('menu')).toHaveLength(1)
+ expect(menu.style.left).toBe('170px')
+ expect(menu.style.width).toBe('200px')
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '返回上级' }))
+ expect(select).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(parent)
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ })
+
+ it('keeps a narrow menu on its parent page until explicit submenu activation', () => {
+ vi.stubGlobal('innerWidth', 390)
+ const close = vi.fn()
+ render( )
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ expect(document.activeElement).toBe(parent)
+ expect(parent.getAttribute('aria-expanded')).toBe('false')
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ fireEvent.keyDown(parent, { key: 'Escape' })
+ expect(close).toHaveBeenCalledOnce()
+ fireEvent.keyDown(parent, { key: 'ArrowRight' })
+ expect(screen.getByRole('menuitem', { name: 'Go back' })).toBeDefined()
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
+ })
+
+ it('uses the caller parent label when no explicit return label was supplied', () => {
+ vi.stubGlobal('innerWidth', 390)
+ render( {}} />)
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ const back = screen.getByRole('menuitem', { name: 'Applications' })
+ expect(back.querySelector('[data-icon="dsw-icon-chevron-left-outline-regular"]')).not.toBeNull()
+ fireEvent.click(back)
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ })
+
+ it('preserves the focused child DOM node while resizing between side and single-panel placement', () => {
+ render( )
+ openMenu()
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ const child = screen.getByRole('menuitem', { name: 'Second app' })
+ act(() =>{ child.focus() })
+ vi.stubGlobal('innerWidth', 390)
+ fireEvent(window, new Event('resize'))
+ expect(screen.getAllByRole('menu')).toHaveLength(1)
+ expect(document.activeElement).toBe(child)
+ vi.stubGlobal('innerWidth', 1024)
+ fireEvent(window, new Event('resize'))
+ expect(screen.getAllByRole('menu')).toHaveLength(2)
+ expect(document.activeElement).toBe(child)
+ })
+
+ it('keeps wide touch devices in side placement and ignores touch and compatibility mouse hover', () => {
+ hover = false
+ render( )
+ openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ pointer(parent, 'pointerover', 'touch')
+ fireEvent.mouseOver(parent)
+ pointer(parent, 'pointerover', 'mouse')
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ pointer(parent, 'pointerdown', 'touch')
+ fireEvent.click(parent)
+ expect(screen.getAllByRole('menu')).toHaveLength(2)
+ expect(childMenu().style.left).toBe('304px')
+ expect(screen.queryByRole('menuitem', { name: 'Go back' })).toBeNull()
+ hover = true
+ pointer(screen.getByRole('menuitem', { name: 'Other action' }), 'pointerover', 'touch')
+ fireEvent.mouseOver(screen.getByRole('menuitem', { name: 'Other action' }))
+ expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
+ })
+
+ it('returns child focus after pointer grace so root arrow navigation can continue', () => {
+ render( )
+ openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ fireEvent.click(parent)
+ const menu = childMenu()
+ pointer(menu, 'pointerover', 'mouse', parent)
+ pointer(menu, 'pointerout', 'mouse', document.body)
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ expect(document.activeElement).toBe(parent)
+ fireEvent.keyDown(parent, { key: 'ArrowDown' })
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Other action' }))
+ })
+
+ it.each(['More', 'Other action'])('preserves focus moved to %s before pointer grace ends', (name) => {
+ render( )
+ openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ fireEvent.click(parent)
+ const menu = childMenu()
+ pointer(menu, 'pointerover', 'mouse', parent)
+ pointer(menu, 'pointerout', 'mouse', document.body)
+ const target = screen.getByRole(name === 'More' ? 'button' : 'menuitem', { name })
+ act(() => { target.focus() })
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ expect(document.activeElement).toBe(target)
+ })
+
+ it('allows crossing the hover gap and cancels stale grace after keyboard return', () => {
+ render( )
+ openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ pointer(parent, 'pointerover', 'mouse')
+ pointer(parent, 'pointerout', 'mouse')
+ pointer(childMenu(), 'pointerover', 'mouse')
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
+ expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
+ pointer(parent, 'pointerout', 'mouse')
+ fireEvent.keyDown(document, { key: 'Escape' })
+ fireEvent.click(parent)
+ act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
+ expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
+ pointer(screen.getByRole('menuitem', { name: 'Plugin action' }), 'pointerover', 'mouse')
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ })
+
+ it('moves focus from a removed return row to the first child on width expansion', () => {
+ vi.stubGlobal('innerWidth', 390)
+ render( )
+ openMenu()
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ act(() =>{ screen.getByRole('menuitem', { name: 'Go back' }).focus() })
+ vi.stubGlobal('innerWidth', 1024)
+ fireEvent(window, new Event('resize'))
+ expect(screen.queryByRole('menuitem', { name: 'Go back' })).toBeNull()
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'First app' }))
+ })
+
+ it('keeps the return row reachable when a narrow child has no enabled choices', () => {
+ vi.stubGlobal('innerWidth', 390)
+ render( {}}
+ items={[{ id: 'parent', label: 'Applications', submenu: [{ id: 'disabled', label: 'Unavailable', disabled: true }] }]} />)
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Applications' }))
+ expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Go back' }))
+ })
+
+ it('does not open disabled submenu parents and dismisses on outside pointerdown', () => {
+ const close = vi.fn()
+ render( )
+ pointer(screen.getByRole('menuitem'), 'pointerover', 'mouse')
+ fireEvent.click(screen.getByRole('menuitem'))
+ expect(screen.getAllByRole('menu')).toHaveLength(1)
+ fireEvent.pointerDown(document.body)
+ expect(close).toHaveBeenCalledOnce()
+ })
+})
diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml
index b4cbc02951..0e613d6a4c 100644
--- a/packages/session-query/session-log-export/README.i18n.yaml
+++ b/packages/session-query/session-log-export/README.i18n.yaml
@@ -18,8 +18,8 @@
en: bb12bdeafa8571b5
zh: e7df75c03478f7ed
/deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown:
- en: 55c163a4ae5b8d5d
- zh: c1dac154ed1fa56d
+ en: acbc8a46167881c5
+ zh: 24fe629ef43db153
/deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it:
en: 4011f686afd24f09
zh: 2710b9995c80ba1f
@@ -27,8 +27,8 @@
en: 252bc78a7850557a
zh: e695658a94c8c2bf
/deepseek-ai-dsh-session-log-export/use-this-package/composition:
- en: 4168a2d3e653ab73
- zh: 8bf955cfab7b08e2
+ en: 096b8a2a62ebebfb
+ zh: 74799e9746c3564d
/deepseek-ai-dsh-session-log-export/use-this-package/configuration:
en: 377a0d8860c46f63
zh: 9eb7698f813b2fa3
@@ -45,8 +45,8 @@
en: 7cb82a6851405f95
zh: b8c525170d19dc6c
/deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split:
- en: 3bb8e18b107c353f
- zh: 3cc45ce2b5699066
+ en: 29c6e2eec2f51a6f
+ zh: 87a067d4de78b8e8
/deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow:
en: bd46d2fa1273a6a4
zh: 4d3eb2cf0a9bf12b
diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md
index 58cf4006df..3b91dde81d 100644
--- a/packages/session-query/session-log-export/README.md
+++ b/packages/session-query/session-log-export/README.md
@@ -35,7 +35,7 @@ The formatter reads the committed raw log, so a compaction surface replacement n
The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back.
-When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently.
+When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin’s availability; both export actions remain independently available. [`ui-open-in-app`](../../client/ui-open-in-app/README.md) contributes its application submenu in a separated group after download and before feedback.
### When to choose it
@@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?:
name: '@deepseek-ai/dsh-session-log-export'
```
-The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, and `dsh-client-ui-layout`. The layout package declares the `shell.overlay` seat used by copy feedback; the other client packages provide the Header seat, locale, and command surface.
+The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, `dsh-client-ui-context-menu`, and `dsh-client-ui-layout`. The context-menu service renders the shared Header popup; layout declares the copy-feedback `shell.overlay` seat. The other client packages provide Header, locale, and command services.
### Configuration
@@ -100,7 +100,7 @@ This section explains how the package wires the export control and points at the
The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download.
-The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control.
+The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. Its captured `sessionHeader` target is declaration-merged by this package and remains independent of Session-row and title context actions. Export and optional feedback contributions observe their controller, availability, and locale sources; the button observes the shared active target for expanded state. The Session-scoped download dialog remains mounted beside the trigger, while menu focus and dismissal belong to the shared overlay.
### Download flow
diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md
index 01df07877f..d8182eca6d 100644
--- a/packages/session-query/session-log-export/README.zh.md
+++ b/packages/session-query/session-log-export/README.zh.md
@@ -35,7 +35,7 @@ kind: "package-reference"
剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。
-挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。
+挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。[`ui-open-in-app`](../../client/ui-open-in-app/README.zh.md) 在下载之后、反馈之前的独立分隔组中贡献应用子菜单。
### 何时选择
@@ -61,7 +61,7 @@ export function convertTranscript(events: readonly SessionEvent[], sourcePath?:
name: '@deepseek-ai/dsh-session-log-export'
```
-Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation` 和 `dsh-client-ui-layout` 一起挂载。layout 包声明复制反馈使用的 `shell.overlay` 座位;其他客户端包提供 Header 座位、locale 与命令界面。
+Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation`、`dsh-client-ui-context-menu` 和 `dsh-client-ui-layout` 一起挂载。右键菜单服务渲染共享的头部菜单;layout 声明复制反馈使用的 `shell.overlay` 插槽。其他客户端包提供 Header、locale 与命令服务。
### 配置
@@ -100,7 +100,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。
-Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。
+Header 的更多入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。本包通过声明合并定义被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。
### 下载流程
diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json
index 083b0e2ba7..ae91500d68 100644
--- a/packages/session-query/session-log-export/package.json
+++ b/packages/session-query/session-log-export/package.json
@@ -57,6 +57,7 @@
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-commands": "workspace:*",
+ "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
"@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*",
@@ -76,6 +77,7 @@
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-commands",
+ "@deepseek-ai/dsh-client-ui-context-menu",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-renderer",
diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx
index fcf0cfd862..2404a2b18c 100644
--- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx
+++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx
@@ -1,92 +1,62 @@
-import { useState } from 'react'
+/** Session Header trigger for the shared menu, retaining its Session-scoped download dialog. */
import type { ReactNode } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import {
- Button, IconCopyOutlineRegular, IconDownloadOutlineRegular, IconEllipsisOutlineRegular,
- IconPaperPlaneOutlineRegular, Menu,
-} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ContextMenuTarget, MenuPoint } from '@deepseek-ai/dsh-client-ui-context-menu/client'
+import { Button, IconEllipsisOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx'
import type { SessionLogDownloadDialogInjected } from './Dialog.tsx'
import type { SessionMarkdownCopyState } from './copy-controller.ts'
import css from './HeaderAction.module.css'
-/** Markdown copy state and action bound into the Session Header contribution. */
-export interface SessionMarkdownCopyHeaderInjected {
- hooks: { sessionMarkdownCopy: ObservableSnapshot }
+/** Session download controls and observable menu/copy state for the Header trigger. */
+export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected {
+ hooks: SessionLogDownloadDialogInjected['hooks'] & {
+ sessionMarkdownCopy: ObservableSnapshot
+ headerMenu: ObservableSnapshot
+ }
/**
- * Copy one complete Session transcript to the clipboard.
- * @param sessionId - Session captured by the menu click.
+ * Toggle the shared menu for the captured Header Session.
+ * @param sessionId - Session displayed by this Header.
+ * @param point - trigger's viewport anchor.
*/
- copyMarkdown: (sessionId: SessionId) => Promise
+ openMenu: (sessionId: SessionId, point: MenuPoint) => void
}
-/** Session download controls plus Markdown copy and observable feedback availability. */
-export interface SessionLogDownloadHeaderInjected
- extends SessionLogDownloadDialogInjected, SessionMarkdownCopyHeaderInjected {
- hooks: SessionLogDownloadDialogInjected['hooks']
- & SessionMarkdownCopyHeaderInjected['hooks']
- & { feedbackAvailable: ObservableSnapshot }
- /**
- * Open the existing Session feedback draft without recording feedback; no-op after the feedback plugin unloads.
- * @param sessionId - Session whose feedback form to open.
- */
- openFeedback: (sessionId: SessionId) => void
-}
-
-/** Session download props plus the optional feedback action. */
+/** Session runtime, localized copy, and injected Header operations. */
export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace
/**
- * Render the Session Header menu with Markdown copy, download, and optional
- * feedback actions.
+ * Render the Session Header's shared-menu trigger and persistent download dialog.
* @param props - Session runtime, download and copy state, and localized copy.
- * @returns the persistent Header action and Session-scoped dialog.
+ * @returns the Header action and Session-scoped dialog.
*/
export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode {
- const {
- sessionId, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable,
- request, copyMarkdown, openFeedback, t,
- } = props
- const feedbackAvailable = useFeedbackAvailable(value => value)
+ const { sessionId, useSessionLogDownload, useSessionMarkdownCopy, useHeaderMenu, openMenu, t } = props
const entry = useSessionLogDownload(state => state.bySession[String(sessionId)])
const copying = useSessionMarkdownCopy(state => state.copying)
+ const open = useHeaderMenu(target => target?.kind === 'sessionHeader' && target.value.sessionId === sessionId)
const busy = entry?.status === 'downloading'
- const [open, setOpen] = useState(false)
-
return (
<>
- { setOpen(false) }}
- items={[
- { id: 'copy', label: t('menu.copyMarkdown'), icon: , disabled: copying },
- { id: 'download', label: t('menu.download'), icon: , disabled: busy },
- ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [],
- ]}
- onSelect={(id) => {
- setOpen(false)
- if (id === 'feedback') openFeedback(sessionId)
- else if (id === 'copy') void copyMarkdown(sessionId)
- else void request(sessionId)
+ {
+ const button = event.currentTarget
+ button.focus()
+ const rect = button.getBoundingClientRect()
+ openMenu(sessionId, { x: rect.left, y: rect.bottom })
}}
- anchor={(
- { setOpen(value => !value) }}
- >
-
-
- )}
- />
+ >
+
+
>
)
diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts
index 74808d1751..f914a5427c 100644
--- a/packages/session-query/session-log-export/src/client/index.ts
+++ b/packages/session-query/session-log-export/src/client/index.ts
@@ -9,6 +9,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
+import { IconCopyOutlineRegular, IconDownloadOutlineRegular, IconPaperPlaneOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { SessionLogDownloadController } from './controller.ts'
import { SessionMarkdownCopyController } from './copy-controller.ts'
import { SessionMarkdownCopyToast, type SessionMarkdownCopyToastInjected } from './CopyToast.tsx'
@@ -21,6 +23,13 @@ declare module '@deepseek-ai/cordis' {
}
}
+declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
+ interface ContextMenuTargetMap {
+ /** Header utilities, independent of Session-row and title context actions. */
+ sessionHeader: { readonly sessionId: SessionId }
+ }
+}
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
'session-log-download': SessionLogDownloadKey
@@ -29,11 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
export type { SessionLogDownloadEntry, SessionLogDownloadState } from './controller.ts'
-export const inject = ['slots', 'locale']
+export const inject = ['slots', 'locale', 'contextMenus']
/**
* Provide the download controller and mount its modal, the Markdown copy
- * controller and its root toast, and both Header actions.
+ * controller and its root toast, and the shared Header menu actions.
* @param ctx - browser context carrying slots and locale services.
*/
export function apply(ctx: ClientContext): void {
@@ -50,6 +59,34 @@ export function apply(ctx: ClientContext): void {
return () => { feedbackAvailable.set(false) }
}, 'session-log-download: feedback availability')
})
+ const t = ctx.locale.bind(NS)
+ ctx.slots.inject('contextMenu.icon', function* () {
+ for (const [key, Icon] of Object.entries({
+ 'session-log-download.copy': IconCopyOutlineRegular,
+ 'session-log-download.download': IconDownloadOutlineRegular,
+ 'session-log-download.feedback': IconPaperPlaneOutlineRegular,
+ })) yield ctx.slots.register({ name: 'contextMenu.icon', key }, Icon)
+ })
+ ctx.effect(() => ctx.contextMenus.register({
+ id: 'session-log-download.export', target: 'sessionHeader', order: 100,
+ observe: () => [controller.store, copyController.store, ctx.locale],
+ resolve: ({ sessionId }) => [
+ { id: 'copy', label: t('menu.copyMarkdown'), icon: { key: 'session-log-download.copy' },
+ disabled: copyController.store.getSnapshot().copying, run: () => copyController.copy(sessionId) },
+ { id: 'download', label: t('menu.download'), icon: { key: 'session-log-download.download' },
+ disabled: controller.store.getSnapshot().bySession[sessionId]?.status === 'downloading',
+ run: () => controller.download(sessionId) },
+ ],
+ }), 'session-log-download: Header exports')
+ ctx.effect(() => ctx.contextMenus.register({
+ id: 'session-log-download.feedback', target: 'sessionHeader', order: 600,
+ observe: () => [feedbackAvailable, ctx.locale],
+ resolve: ({ sessionId }) => feedbackAvailable.getSnapshot() ? [{
+ id: 'feedback', label: t('menu.feedback'), icon: { key: 'session-log-download.feedback' }, separatorBefore: true,
+ // The feedback plugin can unload between menu preparation and selection.
+ run: () => { ctx.get('feedbackUi')?.openSession(sessionId) },
+ }] : [],
+ }), 'session-log-download: Header feedback')
ctx.on('command/executed', (sessionId, commandName, result) => {
if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId)
})
@@ -70,13 +107,15 @@ export function apply(ctx: ClientContext): void {
hooks: {
sessionLogDownload: controller.store,
sessionMarkdownCopy: copyController.store,
- feedbackAvailable,
+ headerMenu: ctx.contextMenus.active,
},
request: (sessionId: SessionId) => controller.download(sessionId),
- copyMarkdown: (sessionId: SessionId) => copyController.copy(sessionId),
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
- // The feedback plugin can unload between the menu render and this click.
- openFeedback: (sessionId: SessionId) => { ctx.get('feedbackUi')?.openSession(sessionId) },
+ openMenu: (sessionId, point) => {
+ const active = ctx.contextMenus.active.getSnapshot()
+ if (active?.kind === 'sessionHeader' && active.value.sessionId === sessionId) ctx.contextMenus.close()
+ else ctx.contextMenus.open({ kind: 'sessionHeader', value: { sessionId } }, point)
+ },
}),
}, SessionLogDownloadHeaderAction))
}
diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx
index f8ed7792bf..58f0f1d333 100644
--- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx
+++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx
@@ -1,5 +1,8 @@
import { Context } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { MenuRegistry } from '@deepseek-ai/dsh-client-ui-context-menu/src/client/registry.ts'
+import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -24,6 +27,7 @@ function declare(slots: SlotRegistry): () => void {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
'shell.overlay': { kind: 'list', scope: 'root' },
+ 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true },
},
} as never, () => null)
}
@@ -35,40 +39,67 @@ function injectedOf(entry: { inject?: ((...args: never[]) => object) | undefined
async function bench() {
const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
const declaration = declare(slots)
ctx.provide('locale', new LocaleRuntime(ctx))
+ ctx.locale.setLocale('en')
+ const failed = vi.fn(), registry = new MenuRegistry(failed)
+ onTestFinished(() => { registry.dispose() })
+ const active = createSnapshotStore(null)
+ const open = vi.fn((target: ContextMenuTarget) => { active.set(target) })
+ const close = vi.fn(() => { active.set(null) })
+ ctx.provide('contextMenus', { active, open, close, register: registry.register.bind(registry) } as never)
+ const menu = () => registry.resolve({ kind: 'sessionHeader', value: { sessionId: SID } })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
- return { ctx, slots, declaration, fiber }
+ return { ctx, slots, declaration, fiber, registry, menu, active, open, close, failed }
}
describe('session-log-download browser plugin', () => {
- it('follows feedback plugin availability and opens the selected Session form', async () => {
+ it('follows feedback plugin availability without adding Header actions to Session context menus', async () => {
const b = await bench()
- const entry = b.slots.entries('conversation.session.header.utilities')[0]!
- const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected
- expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false)
- injected.openFeedback(SID)
-
+ const menu = b.menu()
+ expect(menu.source.getSnapshot().map(row => row.label)).toEqual(['Copy Session as Markdown', 'Download session log'])
const openSession = vi.fn()
const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) })
await feedback.await()
- await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(true) })
- injected.openFeedback(SID)
+ await vi.waitFor(() => { expect(menu.source.getSnapshot().map(row => row.label)).toEqual(['Copy Session as Markdown', 'Download session log', 'Feedback']) })
+ b.menu().select('session-log-download.feedback/feedback')
expect(openSession).toHaveBeenCalledWith(SID)
-
await feedback.dispose()
- await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) })
- expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(1)
+ await vi.waitFor(() => { expect(menu.source.getSnapshot()).toHaveLength(2) })
+ const session = b.registry.resolve({ kind: 'session', value: { sessionId: SID, displayTitle: 'Captured' } })
+ expect(session.source.getSnapshot()).toEqual([])
+ const reloaded = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) })
+ await reloaded.await()
+ await vi.waitFor(() => { expect(menu.source.getSnapshot()).toHaveLength(3) })
+ b.menu().select('session-log-download.feedback/feedback')
+ expect(openSession).toHaveBeenCalledTimes(2)
await b.fiber.dispose()
+ expect(menu.source.getSnapshot()).toEqual([])
+ })
+
+ it('toggles only its captured Header menu and replaces an unrelated target', async () => {
+ const b = await bench()
+ const face = injectedOf(b.slots.entries('conversation.session.header.utilities')[0]) as SessionLogDownloadHeaderInjected
+ const point = { x: 20, y: 30 }
+ face.openMenu(SID, point)
+ expect(b.open).toHaveBeenCalledExactlyOnceWith({ kind: 'sessionHeader', value: { sessionId: SID } }, point)
+ face.openMenu(SID, point)
+ expect(b.close).toHaveBeenCalledOnce()
+ b.active.set({ kind: 'session', value: { sessionId: SID, displayTitle: 'Same' } })
+ face.openMenu(SID, point)
+ expect(b.open).toHaveBeenCalledTimes(2)
+ face.openMenu('another' as SessionId, point)
+ expect(b.active.getSnapshot()).toEqual({ kind: 'sessionHeader', value: { sessionId: 'another' } })
})
it('provides one controller and removes its Header and overlay contributions on disposal', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })))
const b = await bench()
- expect(inject).toEqual(['slots', 'locale'])
+ expect(inject).toEqual(['slots', 'locale', 'contextMenus'])
expect(b.ctx.sessionLogDownload).toBeDefined()
expect(b.slots.entries('conversation.session.header.actions')).toHaveLength(0)
const entry = b.slots.entries('conversation.session.header.utilities')[0]
@@ -84,16 +115,49 @@ describe('session-log-download browser plugin', () => {
expect(overlay?.component).toBe(SessionMarkdownCopyToast)
expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' })
const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected
- await injected.copyMarkdown(SID)
- expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed')
+ b.menu().select('session-log-download.export/copy')
+ await vi.waitFor(() => { expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') })
overlayInjected.dismiss()
expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull()
+ const retained = b.menu()
await b.fiber.dispose()
+ expect(retained.source.getSnapshot()).toEqual([])
+ expect(b.menu().source.getSnapshot()).toEqual([])
+ expect(b.slots.entries('contextMenu.icon')).toHaveLength(0)
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
expect(b.slots.entries('shell.overlay')).toHaveLength(0)
})
+ it('updates only occupied export rows and keeps feedback available during downloads and copy', async () => {
+ const download = Promise.withResolvers(), copy = Promise.withResolvers()
+ const fetcher = vi.fn((input: string | URL) => String(input).includes('session.markdown') ? copy.promise : download.promise)
+ vi.stubGlobal('fetch', fetcher)
+ const b = await bench()
+ const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession: vi.fn() }) })
+ await feedback.await()
+ const menu = b.menu()
+ b.menu().select('session-log-download.export/download')
+ expect(menu.source.getSnapshot().map(row => [row.label, row.disabled === true])).toEqual([
+ ['Copy Session as Markdown', false], ['Download session log', true], ['Feedback', false],
+ ])
+ b.menu().select('session-log-download.export/download')
+ expect(fetcher).toHaveBeenCalledOnce()
+ b.menu().select('session-log-download.export/copy')
+ expect(menu.source.getSnapshot().map(row => row.disabled === true)).toEqual([true, true, false])
+ b.menu().select('session-log-download.export/copy')
+ expect(fetcher).toHaveBeenCalledTimes(2)
+ download.resolve(new Response('', { status: 500 }))
+ copy.resolve(new Response('', { status: 500 }))
+ await vi.waitFor(() => { expect(menu.source.getSnapshot().every(row => !row.disabled)).toBe(true) })
+ expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]).toMatchObject({ open: true, status: 'error' })
+ const face = injectedOf(b.slots.entries('conversation.session.header.utilities')[0]) as SessionLogDownloadHeaderInjected
+ expect(face.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed')
+ expect(b.failed).not.toHaveBeenCalled()
+ b.ctx.locale.setLocale('zh')
+ expect(menu.source.getSnapshot()[0]?.label).not.toBe('Copy Session as Markdown')
+ })
+
it('downloads only for an export execution acknowledged by this browser client', async () => {
const fetcher = vi.fn(async () => new Response('', { status: 500 }))
vi.stubGlobal('fetch', fetcher)
diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
index 4a64ffb70d..583dda9ab8 100644
--- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
+++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx
@@ -1,25 +1,19 @@
// @vitest-environment jsdom
-import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
+import { contextMenuHook, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { useSyncExternalStore } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
-import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { ContextMenuTarget } from '@deepseek-ai/dsh-client-ui-context-menu/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { SessionLogDownloadController } from '../src/client/controller.ts'
-import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts'
+import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx'
import { en } from '../src/client/locales.ts'
const SID = 'session-export-header' as SessionId
-const clipboard: SessionMarkdownClipboard = {
- supportsDeferredWrite: () => false,
- writeDeferred: async () => {},
- writeText: async () => true,
-}
-const dictionary: Record = en
-const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key
+const translate: SessionLogDownloadHeaderProps['t'] = makeTranslate(en)
const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') }
const standard: GlobalStandardProps & SessionStandardProps = {
sessionId: SID, useSession: unused, useProjection: unused, useAccess: () => true,
@@ -31,169 +25,74 @@ const standard: GlobalStandardProps & SessionStandardProps = {
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused },
}
-function bindSnapshot(store: ObservableSnapshot) {
- return function useSnapshot(selector: (state: State) => T): T {
- return useSyncExternalStore(
- listener => store.subscribe(listener),
- () => selector(store.getSnapshot()),
- )
- }
-}
-
-function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController(
- async () => new Response('markdown'),
- clipboard,
-)) {
- const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn())
- const request = vi.fn((sessionId: SessionId) => controller.download(sessionId))
- const copyMarkdown = vi.fn((sessionId: SessionId) => copyController.copy(sessionId))
- const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
- const openFeedback = vi.fn()
- const feedback = createSnapshotStore(feedbackAvailable)
- const useSessionLogDownload = bindSnapshot(controller.store)
- const useSessionMarkdownCopy = bindSnapshot(copyController.store)
+function bench() {
+ const gate = Promise.withResolvers()
+ const controller = new SessionLogDownloadController(() => gate.promise, vi.fn())
+ const copyController = new SessionMarkdownCopyController(async () => new Response('markdown'))
+ const active = createSnapshotStore(null)
+ const openMenu = vi.fn()
const props: SessionLogDownloadHeaderProps = {
...standard,
- useSessionLogDownload,
- useSessionMarkdownCopy,
- useFeedbackAvailable: bindSnapshot(feedback),
- openFeedback,
- request,
- copyMarkdown,
- dismiss,
+ useSessionLogDownload: bindSnapshotSelector(controller.store),
+ useSessionMarkdownCopy: bindSnapshotSelector(copyController.store),
+ useHeaderMenu: bindSnapshotSelector(active),
+ openMenu,
+ request: sessionId => controller.download(sessionId),
+ dismiss: (sessionId) => { controller.dismiss(sessionId) },
t: translate,
}
const view = render( )
- return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view }
+ return { controller, copyController, active, openMenu, props, view, gate }
}
afterEach(cleanup)
-describe('Session export Header action', () => {
- it('opens Session feedback and closes the menu without starting a download', () => {
- const b = bench(true)
- fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
- fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' }))
- expect(b.openFeedback).toHaveBeenCalledWith(SID)
- expect(b.request).not.toHaveBeenCalled()
- expect(b.view.queryByRole('menu')).toBeNull()
- })
-
- it('keeps export available without the feedback plugin', () => {
+describe('Session export Header trigger', () => {
+ it('captures its Session and button anchor for the shared menu and restores a focusable trigger', () => {
const b = bench()
- fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
- expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull()
- expect(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })).toBeTruthy()
- expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy()
- })
-
- it('updates the open menu when feedback becomes available, unloads, and reloads', () => {
- const b = bench()
- fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
- expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull()
-
- act(() => { b.feedback.set(true) })
- expect(b.view.getByRole('menuitem', { name: 'Feedback' })).toBeTruthy()
- act(() => { b.feedback.set(false) })
- expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull()
- expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy()
-
- act(() => { b.feedback.set(true) })
- fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' }))
- expect(b.openFeedback).toHaveBeenCalledWith(SID)
- expect(b.request).not.toHaveBeenCalled()
- expect(b.view.queryByRole('menu')).toBeNull()
- })
-
- it('copies Markdown from the menu and closes it without starting a download', async () => {
- const b = bench()
- fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
- expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
- 'Copy Session as Markdown',
- 'Download session log',
- ])
- fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }))
- await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) })
- expect(b.request).not.toHaveBeenCalled()
- expect(b.view.queryByRole('menu')).toBeNull()
- expect(b.copyController.store.getSnapshot().notice?.kind).toBe('success')
- })
-
- it('disables only the copy row while a copy is pending', async () => {
- const gate = Promise.withResolvers()
- const copyController = new SessionMarkdownCopyController(() => gate.promise, clipboard)
- const b = bench(true, copyController)
- const pending = copyController.copy(SID)
- expect(copyController.store.getSnapshot().copying).toBe(true)
-
const button = b.view.getByRole('button', { name: 'More actions' })
- await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
+ vi.spyOn(button, 'getBoundingClientRect').mockReturnValue(new DOMRect(40, 50, 28, 28))
fireEvent.click(button)
- const copyItem = b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })
- await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) })
- expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false)
- expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false)
- expect(button.getAttribute('aria-busy')).toBe('true')
+ expect(b.openMenu).toHaveBeenCalledExactlyOnceWith(SID, { x: 40, y: 78 })
+ expect(document.activeElement).toBe(button)
+ expect(button.getAttribute('aria-haspopup')).toBe('menu')
+ expect(button.hasAttribute('data-context-menu-trigger')).toBe(true)
+ expect(b.view.queryByRole('menu')).toBeNull()
+ })
- gate.resolve(new Response('markdown'))
- await pending
- expect(b.copyController.store.getSnapshot().copying).toBe(false)
- await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) })
+ it('derives expanded state from only the matching Header target and preserves the button across changes', () => {
+ const b = bench()
+ const button = b.view.getByRole('button', { name: 'More actions' })
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ act(() => { b.active.set({ kind: 'sessionHeader', value: { sessionId: SID } }) })
+ expect(button.getAttribute('aria-expanded')).toBe('true')
+ act(() => { b.active.set({ kind: 'session', value: { sessionId: SID, displayTitle: 'Same Session' } }) })
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ act(() => { b.active.set({ kind: 'sessionHeader', value: { sessionId: 'another' as SessionId } }) })
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ b.view.rerender( )
+ expect(b.view.getByRole('button', { name: 'More actions' })).toBe(button)
+ expect(button.getAttribute('aria-expanded')).toBe('true')
+ act(() => { b.active.set(null) })
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ })
+
+ it('retains the Session-scoped download dialog and busy state after the shared menu closes', async () => {
+ const b = bench()
+ const button = b.view.getByRole('button', { name: 'More actions' })
+ let pending!: Promise
+ act(() => { pending = b.controller.download(SID) })
+ expect(button.getAttribute('aria-busy')).toBe('true')
+ expect(button.hasAttribute('disabled')).toBe(false)
+ expect(b.view.getByRole('dialog')).toBeTruthy()
+ await act(async () => { b.gate.resolve(new Response('zip')); await pending })
+ await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') })
+ expect(b.view.getByRole('dialog', { name: 'Session download started' })).toBeTruthy()
+ act(() => { b.controller.dismiss(SID) })
+ expect(b.view.queryByRole('dialog')).toBeNull()
+ act(() => { b.copyController.store.set({ ...b.copyController.store.getSnapshot(), copying: true }) })
+ expect(button.getAttribute('aria-busy')).toBe('true')
+ act(() => { b.copyController.store.set({ ...b.copyController.store.getSnapshot(), copying: false }) })
expect(button.getAttribute('aria-busy')).toBe('false')
})
-
- it('opens the more-actions menu and downloads through the shared controller', async () => {
- const b = bench()
- const button = b.view.getByRole('button', { name: 'More actions' })
- expect(button.querySelector('svg')).not.toBeNull()
- expect(button.getAttribute('aria-expanded')).toBe('false')
- fireEvent.click(button)
- expect(button.getAttribute('aria-expanded')).toBe('true')
- fireEvent.click(b.view.getByRole('menuitem', { name: 'Download session log' }))
- await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) })
- expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull()
- expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
- })
-
- it('closes the menu on Escape without downloading', () => {
- const b = bench()
- fireEvent.click(b.view.getByRole('button', { name: 'More actions' }))
- expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy()
- fireEvent.keyDown(document, { key: 'Escape' })
- expect(b.view.queryByRole('menuitem', { name: 'Download session log' })).toBeNull()
- expect(b.request).not.toHaveBeenCalled()
- })
-
- it('disables the download row while either entry path downloads this Session', async () => {
- const b = bench(true)
- let release!: (response: Response) => void
- const pending = new Promise((resolve) => { release = resolve })
- const controller = new SessionLogDownloadController(() => pending, vi.fn())
- const useSessionLogDownload = bindSnapshot(controller.store)
- const rerendered: SessionLogDownloadHeaderProps = {
- ...standard,
- useSessionLogDownload,
- useSessionMarkdownCopy: bindSnapshot(b.copyController.store),
- useFeedbackAvailable: bindSnapshot(b.feedback),
- openFeedback: b.openFeedback,
- request: (sessionId: SessionId) => controller.download(sessionId),
- copyMarkdown: b.copyMarkdown,
- dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
- t: translate,
- }
- b.view.rerender( )
-
- const download = controller.download(SID)
- const button = b.view.getByRole('button', { name: 'More actions' })
- await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') })
- fireEvent.click(button)
- const item = b.view.getByRole('menuitem', { name: 'Download session log' })
- expect((item as HTMLButtonElement).disabled).toBe(true)
- expect((b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false)
- expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false)
- release(new Response('zip'))
- await download
- await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') })
- expect((item as HTMLButtonElement).disabled).toBe(false)
- })
})
diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json
index c78dbf6893..8104395dfd 100644
--- a/packages/session-query/session-log-export/tsconfig.client.json
+++ b/packages/session-query/session-log-export/tsconfig.client.json
@@ -21,6 +21,7 @@
{ "path": "../../client/locale/tsconfig.client.json" },
{ "path": "../../client/store" },
{ "path": "../../client/ui-commands" },
+ { "path": "../../client/ui-context-menu" },
{ "path": "../../client/ui-conversation/tsconfig.client.json" },
{ "path": "../../client/ui-layout" },
{ "path": "../../client/ui-message-feedback" },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 443827158f..c11ca79e91 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4944,6 +4944,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
+ '@deepseek-ai/dsh-session-log-export':
+ specifier: workspace:*
+ version: link:../../session-query/session-log-export
'@deepseek-ai/dsh-util-workspace-path':
specifier: workspace:*
version: link:../../util/workspace-path
@@ -14306,6 +14309,9 @@ importers:
'@deepseek-ai/dsh-client-ui-commands':
specifier: workspace:*
version: link:../../client/ui-commands
+ '@deepseek-ai/dsh-client-ui-context-menu':
+ specifier: workspace:*
+ version: link:../../client/ui-context-menu
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:*
version: link:../../client/ui-conversation
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 53a03661e9..81ba20071a 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -173,6 +173,7 @@
"apps/web/tests/file-upload-round.e2e.ts",
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/open-in-app-ssh.e2e.ts",
+ "apps/web/tests/open-in-app-menus.e2e.ts",
"apps/web/tests/fork-mid-turn.e2e.ts",
"apps/web/tests/message-feedback.e2e.ts",
"apps/web/tests/markdown-images.e2e.ts",
From 8504351cc31bed328e19e3a980748c7f35d4f697 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Thu, 29 Oct 2026 20:34:30 +0800
Subject: [PATCH 021/196] test(web): isolate context gestures from native app
discovery
---
apps/web/tests/context-menus.e2e.ts | 6 +++++-
apps/web/tests/context-menus.overlay.yml | 4 ++++
2 files changed, 9 insertions(+), 1 deletion(-)
create mode 100644 apps/web/tests/context-menus.overlay.yml
diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts
index dfa506b6bc..f5cc9e4b42 100644
--- a/apps/web/tests/context-menus.e2e.ts
+++ b/apps/web/tests/context-menus.e2e.ts
@@ -1,6 +1,7 @@
/** Context gestures against built plugins, seeded Sessions, and original files. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Locator, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
@@ -38,7 +39,10 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s',
let scaffold: WebScaffold, browser: Browser, page: Page
let tripwire: ReturnType
beforeAll(async () => {
- scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) })
+ scaffold = await launchWebScaffold({
+ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
+ extraOverlayPath: fileURLToPath(new URL('./context-menus.overlay.yml', import.meta.url)),
+ })
await mkdir(join(scaffold.workspaceCwd, 'folder'))
await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace')
await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n')
diff --git a/apps/web/tests/context-menus.overlay.yml b/apps/web/tests/context-menus.overlay.yml
new file mode 100644
index 0000000000..ca2581fa55
--- /dev/null
+++ b/apps/web/tests/context-menus.overlay.yml
@@ -0,0 +1,4 @@
+# Context gestures do not query the runner's native file associations.
+- id: session-controller
+ config:
+ nativeOpen: false
From 16f54f5ecf9e07fa267898b7d1c9fb5eac548856 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Thu, 29 Oct 2026 21:57:21 +0800
Subject: [PATCH 022/196] fix(client): address Session menu review and coverage
failures
---
.../session-menu.expected.md | 2 +-
.../session-header-menu/guide.i18n.yaml | 4 +-
.../session-header-menu/guide.md | 2 +-
.../session-header-menu/guide.zh.md | 2 +-
.../ui-context-menu/src/client/types.ts | 2 +
.../client/ui-open-in-app/README.i18n.yaml | 8 +-
packages/client/ui-open-in-app/README.md | 4 +-
packages/client/ui-open-in-app/README.zh.md | 4 +-
packages/client/ui-open-in-app/package.json | 1 -
.../ui-open-in-app/src/client/locales.ts | 16 +-
.../src/client/session-open-with.ts | 3 +-
packages/client/ui-open-in-app/tsconfig.json | 3 -
.../client/ui-primitives/README.i18n.yaml | 4 +-
packages/client/ui-primitives/README.md | 4 +-
packages/client/ui-primitives/README.zh.md | 4 +-
packages/client/ui-primitives/src/Menu.tsx | 11 +-
.../src/menu-submenu-position.ts | 98 ++++++-
.../menu-submenu-position.client.spec.tsx | 259 ++++++++++++++++++
.../tests/menu-submenu.client.spec.tsx | 50 +++-
.../session-log-export/README.i18n.yaml | 4 +-
.../session-log-export/README.md | 2 +-
.../session-log-export/README.zh.md | 2 +-
.../src/client/HeaderAction.tsx | 2 +-
.../session-log-export/src/client/index.ts | 8 +-
.../tests/client-apply.client.spec.tsx | 13 +
pnpm-lock.yaml | 3 -
26 files changed, 449 insertions(+), 66 deletions(-)
create mode 100644 packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx
diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md
index 6724e2373d..f3836b2d55 100644
--- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md
+++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md
@@ -4,6 +4,6 @@
- menuitem "Fork session"
- separator
- menuitem "Copy Session ID"
- - menuitem "Show in Finder"
+ - menuitem "Open in"
- separator
- menuitem "Archive session"
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml
index 8ff580f16d..3ebafc1c0f 100644
--- a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.i18n.yaml
@@ -9,8 +9,8 @@
en: 835e8e217c9ac011
zh: 2d97e450247f0c78
/session-export-requires-context-menus/change:
- en: 9041761cf940965b
- zh: ee5051b2c7d5f3db
+ en: 9ccf2bdb5f5e79f8
+ zh: 2773ad83b62705a7
/session-export-requires-context-menus/migration:
en: e8a0ea0a7f7e333b
zh: 30d2541e92b6f6a8
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
index b2312568aa..fb52efbb85 100644
--- a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.md
@@ -9,7 +9,7 @@ English | [中文](guide.zh.md)
## Change
-The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to render its Header More menu. Custom Web compositions that previously mounted session-log-export without a context-menu provider must add one; otherwise the browser plugin waits for the missing service. The default Web composition already includes the provider. Host export routes and the `/export` command are unchanged.
+The browser half of `@deepseek-ai/dsh-session-log-export` requires `contextMenus` to render its Header More menu. Custom Web compositions that previously mounted session-log-export without a context-menu provider must add one; otherwise the entire Header More button disappears, including access to copy, download, and feedback actions. The default Web composition already includes the provider. Host export routes and the `/export` command are unchanged.
## Migration
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md
index 47cce55be7..017ef81c48 100644
--- a/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/session-header-menu/guide.zh.md
@@ -9,7 +9,7 @@ description: "使用 session-log-export 的自定义 Web 组合必须提供右
## 变更
-`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 来渲染头部更多菜单。此前挂载 session-log-export 但没有右键菜单提供者的自定义 Web 组合必须补充该提供者,否则浏览器插件会等待缺失的服务。默认 Web 组合已包含提供者。Host 导出路由及 `/export` 命令不变。
+`@deepseek-ai/dsh-session-log-export` 的浏览器半包需要 `contextMenus` 来渲染头部更多菜单。此前挂载 session-log-export 但没有右键菜单提供者的自定义 Web 组合必须补充该提供者,否则整个头部更多按钮都会消失,复制、下载和反馈操作的入口也随之消失。默认 Web 组合已包含提供者。Host 导出路由及 `/export` 命令不变。
## 迁移
diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts
index d32a788fb4..d479a1e7b1 100644
--- a/packages/client/ui-context-menu/src/client/types.ts
+++ b/packages/client/ui-context-menu/src/client/types.ts
@@ -27,6 +27,8 @@ export type UseContextMenu = () => ContextMenuBindings
/** External plugins extend this map with their own target families. */
export interface ContextMenuTargetMap {
session: { readonly sessionId: SessionId; readonly displayTitle: string }
+ /** Header utilities, independent of Session-row and title context actions. */
+ sessionHeader: { readonly sessionId: SessionId }
file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId }
directory: { readonly sessionId: SessionId; readonly path: string }
url: { readonly sessionId: SessionId; readonly url: string }
diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml
index 437a2b672c..f4c24732e4 100644
--- a/packages/client/ui-open-in-app/README.i18n.yaml
+++ b/packages/client/ui-open-in-app/README.i18n.yaml
@@ -15,14 +15,14 @@
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package:
- en: 812131f17abf1bd7
- zh: 84481ac1622a2762
+ en: 02d96b9f09937c83
+ zh: f5190f9beef3cfe2
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
en: e07d3a607d54cb47
zh: 9a7342f05b64db38
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
- en: 522e07823c4cbb5f
- zh: 042ffeb95f569cc3
+ en: 22da59203ca974e6
+ zh: f288bd6a193bccef
/deepseek-ai-dsh-client-ui-open-in-app/further-exploration:
en: 81c9d99c0a1a1dd0
zh: 446056602f245293
diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md
index 0944b9b7fa..37ee0ca200 100644
--- a/packages/client/ui-open-in-app/README.md
+++ b/packages/client/ui-open-in-app/README.md
@@ -27,6 +27,8 @@ Open a Session’s directory through **Open in** in its Header More menu or its
Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); this row takes no config. A known Session directory and at least one installed, localized catalog application enable its Open in submenu. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) shows file controls when the Host reports a desktop through `session.canOpenWorkspacePath`; removing this row removes all its controls and menu contributions.
+The Header entry uses the existing More button owned by [`dsh-session-log-export`](../../session-query/session-log-export/README.md). Without that plugin, this package contributes only to the other mounted entries, such as Session-row/title context menus and file-tree controls; it does not create a Header button.
+
### What to expect
The Session Header places **Open in** in a separated group after download and before feedback. Session-row More, row right-click, and title right-click place it after Copy session ID and before Archive. Even one application stays in a submenu. The file-tree and document headers retain their 24px-high, 9px-radius icon-only split buttons. Their tooltips name the default application or reveal action, and their menus retain the default marker. All application icons fall back to a native-open glyph when loading fails. The file-tree opener targets its owner-supplied `absolutePath` and shares application choice and launch occupancy with Session menus. Directory choices persist in `dsh.open-in-app.choice` only after successful launches; unavailable choices fall back to the first eligible application. A busy directory launch disables both the submenu parent and all application rows.
@@ -55,7 +57,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
Implementation internals — click to expand
-[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across `session` and the Header owner’s `sessionHeader` target; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing.
+[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across the shared context-menu package’s `session` and `sessionHeader` targets; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing.
The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md
index a5290914b8..16277ed614 100644
--- a/packages/client/ui-open-in-app/README.zh.md
+++ b/packages/client/ui-open-in-app/README.zh.md
@@ -27,6 +27,8 @@ kind: "package-reference"
把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂载到 Web 组合;本行不接受配置。会话目录已知且至少有一个已安装、已本地化的目录应用时,显示在应用中打开子菜单。Host 通过 `session.canOpenWorkspacePath` 报告桌面可用时,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览显示文件控件;删除本行会移除其全部控件与菜单贡献。
+头部入口使用 [`dsh-session-log-export`](../../session-query/session-log-export/README.zh.md) 持有的现有更多按钮。未挂载该插件时,本包仅向其他已挂载的入口提供功能,例如会话行、标题右键菜单和文件树控件;不会创建头部按钮。
+
### 预期行为
会话头部把**在应用中打开**放在下载之后、反馈之前的独立分隔组。会话行更多、行右键及标题右键菜单把它放在复制 Session ID 之后、归档之前。只有一个应用时仍保留子菜单。文件树与文档标题栏保留高 24px、圆角 9px 的纯图标分体按钮。这些按钮的悬停提示显示默认应用或定位动作,其菜单保留默认标记。所有应用图标加载失败时均使用原生打开图标。文件树按钮以所属组件传入的 `absolutePath` 为目标,并与会话菜单共享应用选择及启动占用状态。目录选择仅在启动成功后持久化到 `dsh.open-in-app.choice`;原选择不可用时回退到首个符合条件的应用。目录启动忙碌时,禁用子菜单父项和全部应用行。
@@ -55,7 +57,7 @@ kind: "package-reference"
实现内幕——点击展开
-[`session-open-with.ts`](src/client/session-open-with.ts) 为 `session` 和头部所属包声明的 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。
+[`session-open-with.ts`](src/client/session-open-with.ts) 为共享右键菜单包声明的 `session` 和 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。
目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json
index 88bf9d219c..7f91893dc0 100644
--- a/packages/client/ui-open-in-app/package.json
+++ b/packages/client/ui-open-in-app/package.json
@@ -79,7 +79,6 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-host-open-in-app": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
- "@deepseek-ai/dsh-session-log-export": "workspace:*",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:*",
"@deepseek-ai/dsh-native-command": "workspace:*",
diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts
index 966834479c..d4d3c48548 100644
--- a/packages/client/ui-open-in-app/src/client/locales.ts
+++ b/packages/client/ui-open-in-app/src/client/locales.ts
@@ -39,7 +39,7 @@ const PRODUCT_NAMES = {
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
- 'menu.openWith': '在应用中打开',
+ 'menu.openIn': '在应用中打开',
'menu.openInSidebar': '在侧边栏打开',
'menu.openInFloat': '在浮窗中打开',
'menu.finder': '在 Finder 中显示',
@@ -74,7 +74,7 @@ export const zh = {
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record = {
- 'menu.openWith': 'Open in',
+ 'menu.openIn': 'Open in',
'menu.openInSidebar': 'Open in sidebar',
'menu.openInFloat': 'Open in floating panel',
'menu.finder': 'Show in Finder',
@@ -112,7 +112,7 @@ export type OpenInAppKey = keyof typeof zh
/** Japanese copy, checked against the English keys. */
export const ja = {
- 'menu.openWith': 'アプリケーションで開く',
+ 'menu.openIn': 'アプリケーションで開く',
'menu.openInSidebar': 'サイドバーで開く',
'menu.openInFloat': 'フローティングパネルで開く',
'menu.finder': 'Finder に表示',
@@ -147,7 +147,7 @@ export const ja = {
/** Spanish dictionary, checked against the English keys. */
export const es = {
- 'menu.openWith': 'Abrir con',
+ 'menu.openIn': 'Abrir con',
'menu.openInSidebar': 'Abrir en la barra lateral',
'menu.openInFloat': 'Abrir en un panel flotante',
'menu.finder': 'Mostrar en Finder',
@@ -181,7 +181,7 @@ export const es = {
/** French dictionary, checked against the English keys. */
export const fr = {
- 'menu.openWith': 'Ouvrir avec',
+ 'menu.openIn': 'Ouvrir avec',
'menu.openInSidebar': 'Ouvrir dans la barre latérale',
'menu.openInFloat': 'Ouvrir dans un panneau flottant',
'menu.finder': 'Afficher dans le Finder',
@@ -215,7 +215,7 @@ export const fr = {
/** German dictionary, checked against the English keys. */
export const de = {
- 'menu.openWith': 'Öffnen mit',
+ 'menu.openIn': 'Öffnen mit',
'menu.openInSidebar': 'In der Seitenleiste öffnen',
'menu.openInFloat': 'In schwebendem Panel öffnen',
'menu.finder': 'Im Finder anzeigen',
@@ -249,7 +249,7 @@ export const de = {
/** Russian dictionary, checked against the English keys. */
export const ru = {
- 'menu.openWith': 'Открыть с помощью',
+ 'menu.openIn': 'Открыть с помощью',
'menu.openInSidebar': 'Открыть на боковой панели',
'menu.openInFloat': 'Открыть на плавающей панели',
'menu.finder': 'Показать в Finder',
@@ -283,7 +283,7 @@ export const ru = {
/** Portuguese dictionary, checked against the English keys. */
export const pt = {
- 'menu.openWith': 'Abrir com',
+ 'menu.openIn': 'Abrir com',
'menu.openInSidebar': 'Abrir na barra lateral',
'menu.openInFloat': 'Abrir em painel flutuante',
'menu.finder': 'Mostrar no Finder',
diff --git a/packages/client/ui-open-in-app/src/client/session-open-with.ts b/packages/client/ui-open-in-app/src/client/session-open-with.ts
index 07f794d581..7119958e9e 100644
--- a/packages/client/ui-open-in-app/src/client/session-open-with.ts
+++ b/packages/client/ui-open-in-app/src/client/session-open-with.ts
@@ -2,7 +2,6 @@
import type { Context } from '@deepseek-ai/cordis'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextMenuTargetMap, MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client'
-import type {} from '@deepseek-ai/dsh-session-log-export/client'
import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared'
import { APP_LABEL_KEY } from './applications.ts'
import type { OpenInAppController } from './controller.ts'
@@ -30,7 +29,7 @@ export function registerSessionOpenWith(
if (!directory || applications.length === 0) return []
const disabled = controller.operation.getSnapshot().phase === 'busy'
return [{
- id: 'openWith', label: t('menu.openWith'), icon: { key: 'open-in-app.application' }, disabled,
+ id: 'openWith', label: t('menu.openIn'), icon: { key: 'open-in-app.application' }, disabled,
children: applications.map(({ id, name }) => ({
id: `app:${id}`, label: name, disabled,
icon: { key: 'open-in-app.application', src: `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${id}` },
diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json
index 80b13b76e1..9e6a381554 100644
--- a/packages/client/ui-open-in-app/tsconfig.json
+++ b/packages/client/ui-open-in-app/tsconfig.json
@@ -23,9 +23,6 @@
{
"path": "../../host/open-in-app"
},
- {
- "path": "../../session-query/session-log-export/tsconfig.client.json"
- },
{
"path": "../locale/tsconfig.client.json"
},
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 5688a2cfe0..38ff83a289 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -30,8 +30,8 @@
en: 81e58c7b342baf3c
zh: 6cd47b16124b215c
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
- en: 7edcce1f9fde5341
- zh: 6e56fddb617aef5f
+ en: cc054a335753c1bf
+ zh: b994c17124cb7a51
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: fd510d34404eb090
zh: d05b0890db929ae6
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index d7c4715e43..5371a39017 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -141,11 +141,11 @@ The atoms cannot read the application locale, so every piece of user-facing copy
`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
-Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. Pointer presses on that control or its descendants stay inside the menu interaction, so its click handler can toggle the popup. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted.
+Portalled menus close when their anchor becomes inert or hidden; contextual menus use the original control supplied by `returnFocus`. For contextual menus, external popup controls carry `data-context-menu-trigger`, so their pointer presses reach the owner's toggle handler without outside dismissal; ordinary dropdowns still dismiss on these external presses. An unmarked `returnFocus` target only receives restored focus; its subtree remains outside the popup. While open, a contextual menu adds its ID to that control's `aria-controls` and restores the previous value on close. Their owner’s retained content stays mounted.
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
-Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight enters the child; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and clamps vertically with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there.
+Submenu parents show a right chevron and open through focus, click, or hover-capable non-touch pointer events. ArrowRight on a menu row enters its child, never activates the external popup trigger; ArrowLeft and Escape return focus to the parent. Each child portals outside the parent's scrolling area, prefers the right side, flips left when it fits, and clamps vertically with internal scrolling. When neither side fits, click or ArrowRight replaces the visible parent panel with the child and a return row; focus alone keeps the parent panel visible. `submenuBackLabel` supplies its localized text; when omitted, the return row uses the caller's parent label beside a left arrow and never selects the parent. Placement depends on available space, not the device's user agent; touch clicks open children on wide screens too. Resizing retains focused child nodes. JavaScript hover requires `(any-hover: hover)` and a non-touch pointer event, so secondary mice and pens can open children even when the primary pointer is touch. Compatibility mouse hover events do not open or collapse children. The pointer grace permits crossing the gap between cards; sibling rows close the previous child. Grace dismissal returns focus left in the child to its parent. Removing or disabling the parent, or emptying its children, restores the root panel and cancels that submenu. Focus left in the removed child returns to its enabled parent, the first enabled root row, or the anchor; focus already moved elsewhere stays there.
`Menu` delegates its card material to `MenuSurface`; custom menus use the same component. `MenuSurface` forwards div props and refs, uses translucent fill and blur, and accepts `compact` for the smaller radius. Its default relative positioning contains the material layer; caller classes can supply fixed or absolute placement. On macOS, its non-interactive backing follows the card through CSS anchors and unmounts with it; the backing requires the Web shell’s isolated body. Feature classes control layout and elevation, while the component owns material and outer radius ([menu rules](../../../docs/web-styling.md#component-rules)). Modal masks retain their dark translucent fill without background blur.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 3919e14704..7021d9c86f 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -141,11 +141,11 @@ kind: "package-library"
`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
-锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。该控件及其子元素上的指针按下仍属于菜单内部交互,让控件的点击处理器可以切换弹窗。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。
+锚点变为 inert 或隐藏时,portal 菜单会关闭;右键菜单使用 `returnFocus` 提供的原始控件。对于右键菜单,外部弹窗控件带有 `data-context-menu-trigger`,其指针按下事件不会触发外部关闭,因此拥有者的点击处理器可以切换弹窗;普通下拉菜单仍会在这些外部按下事件中关闭。未标记的 `returnFocus` 目标仅用于恢复焦点,其子树仍属于弹窗外部。右键菜单打开期间会将自身 ID 加入该控件的 `aria-controls`,关闭时恢复原值。其所属区域保留的内容继续挂载。
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
-子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。ArrowRight 进入子级;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并在垂直方向限制位置、内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。
+子菜单父项显示向右箭头,可通过聚焦、点击或支持悬停的非触摸指针事件展开。菜单行上的 ArrowRight 进入其子级,不激活外部弹窗入口;ArrowLeft 与 Escape 将焦点还给父项。每个子级通过 portal 脱离父级滚动区域,优先向右展开,左侧能容纳时向左翻转,并在垂直方向限制位置、内部限高滚动。两侧均放不下时,点击或 ArrowRight 用子级及返回行替换当前可见的父菜单面板;仅聚焦时仍显示父面板。`submenuBackLabel` 提供该行的本地化文案;省略时使用调用方的父项标签配合向左箭头,返回操作不会选定父项。布局取决于可用空间,不取决于设备 UA;宽屏触控设备也可通过点击展开子级。调整窗口大小时保留已聚焦子项的 DOM 节点。JavaScript 悬停要求 `(any-hover: hover)` 与非触摸指针事件,所以主指针为触摸时,外接鼠标或笔仍可展开子级。兼容鼠标悬停事件不会展开或收起子级。指针宽限允许跨越卡片间隙;同级其他行会关闭原子级。宽限结束关闭子级时,仍在子级中的焦点会回到父项。父项被移除、禁用或其子项清空时,恢复根面板并取消该子菜单。留在被移除子级中的焦点会回到仍可用的父项、首个可用根菜单行或锚点;已经移到其他位置的焦点保持不变。
`Menu` 将卡片材质交给 `MenuSurface`,自定义菜单也使用该组件。`MenuSurface` 转发 div 属性和 ref,采用透明填充及模糊,`compact` 使用较小圆角。默认相对定位使材质层限制在容器内;调用方的类可以设置 fixed 或 absolute 定位。macOS 上,不接收交互的底层通过 CSS 锚点跟随卡片,并随卡片卸载;该底层要求 Web 外壳隔离 body 的层叠上下文。功能类控制布局和层级,组件负责材质和外圆角([菜单规则](../../../docs/web-styling.zh.md#component-rules))。 模态遮罩保留黑色半透明填充,不模糊背景。
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
index b5ca9d4ea1..4eecb0b244 100644
--- a/packages/client/ui-primitives/src/Menu.tsx
+++ b/packages/client/ui-primitives/src/Menu.tsx
@@ -169,7 +169,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment.
* @param props.exiting - retain an inert popup during dismissal or replacement fade.
* @param props.returnFocus - original control captured before asynchronous menu preparation;
- * pointer presses on it or its descendants stay inside the menu interaction.
+ * contextual menus keep presses on external `data-context-menu-trigger` controls inside the menu interaction.
* @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way.
* @param props.open - whether the list is showing (owner-controlled).
* @param props.anchor - the trigger element (rendered in place).
@@ -490,7 +490,8 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel
const onPointerDown = (e: PointerEvent) => {
if (!(e.target instanceof Node) || (contextual && e.button === 2)) return
// The portaled list is outside the anchor subtree; check both.
- if (rootRef.current?.contains(e.target) === true || returnFocus?.()?.contains(e.target) === true) return
+ if (rootRef.current?.contains(e.target) === true) return
+ if (contextual && e.target instanceof Element && e.target.closest('[data-context-menu-trigger]') !== null) return
if (listRef.current?.contains(e.target) === true || submenuRef.current?.contains(e.target) === true) return
onClose()
}
@@ -518,7 +519,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel
returnToParent()
return
}
- if (e.key === 'ArrowRight' && anchored && focused instanceof HTMLButtonElement
+ if (e.key === 'ArrowRight' && insideList && focused instanceof HTMLButtonElement
&& focused.getAttribute('aria-haspopup') === 'menu') {
e.preventDefault()
focused.click()
@@ -620,7 +621,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel
}, [open, cancelClose, cancelSubmenuClose])
const hoverAllowed = (event: { pointerType: string }): boolean => event.pointerType !== 'touch'
- && window.matchMedia('(hover: hover)').matches
+ && window.matchMedia('(any-hover: hover)').matches
const itemColumns = iconColumns(items, contextual)
const footerColumns = iconColumns(footer ?? [], contextual)
@@ -679,7 +680,7 @@ export function Menu({ open, anchor, items = [], children, submenuBackLabel, sel
className={clsx(css.submenu, css.scrollable, dense && css.denseList, compact && css.compactList)}
style={submenuPosition === null ? MEASURE_STYLE : {
left: submenuPosition.left, top: submenuPosition.top,
- maxHeight: submenuPosition.maxHeight, width: submenuPosition.width,
+ maxHeight: submenuPosition.maxHeight,
visibility: submenuHidden ? 'hidden' : undefined,
}} role="menu"
onPointerEnter={cancelSubmenuClose}
diff --git a/packages/client/ui-primitives/src/menu-submenu-position.ts b/packages/client/ui-primitives/src/menu-submenu-position.ts
index df958d87a8..c9b4867e14 100644
--- a/packages/client/ui-primitives/src/menu-submenu-position.ts
+++ b/packages/client/ui-primitives/src/menu-submenu-position.ts
@@ -1,4 +1,4 @@
-/** Viewport placement for Menu's portaled child card, independent of input device. */
+/** Event-driven viewport placement and width ownership for Menu's portaled child card. */
import { useLayoutEffect, useState, type RefObject } from 'react'
import { overlayTopMargin } from './overlay-top-margin.ts'
@@ -13,6 +13,9 @@ interface SubmenuPosition {
/**
* Fit a child beside its parent, or over the parent's panel when neither side fits.
* CSS cannot choose a sibling portal's placement from both measured card widths.
+ * This hook owns the child's inline width; callers must not also render that style.
+ * Scroll and viewport resize place immediately. Observer and font changes share one
+ * requested frame; relevant running animations track frames until they settle.
* @param active - The mounted parent's row ID, or null while closed.
* @param parent - Parent card, including its scroll region.
* @param trigger - The parent row opening the child.
@@ -30,26 +33,59 @@ export function useMenuSubmenuPosition(
const [position, setPosition] = useState(null)
useLayoutEffect(() => {
if (active === null) { setPosition(null); return }
+ const parentElement = parent.current
+ const triggerElement = trigger.current
+ const childElement = child.current
+ /* v8 ignore next -- Menu mounts all three refs before activating this layout effect; cleanup disconnects their observers. */
+ if (parentElement === null || triggerElement === null || childElement === null) return
+ const initialWidth = childElement.style.width
+ let disposed = false
+ let frame: number | null = null
+ let movement: IntersectionObserver[] = []
+ let previousBoxes = ''
+ const motionTargets: HTMLElement[] = [parentElement, triggerElement]
+ // Shrinking transforms can stay wholly inside an intersection root. Track
+ // their active animation only; a settled menu schedules no idle frame.
+ const moving = () => motionTargets.some(element => typeof element.getAnimations === 'function'
+ && element.getAnimations().some(animation => animation.playState === 'running'))
+ const schedule = () => {
+ if (disposed || frame !== null) return
+ frame = requestAnimationFrame(() => {
+ frame = null
+ place()
+ if (!disposed && moving()) schedule()
+ })
+ }
+ // A tight observer root detects position changes, including transforms and
+ // layout shifts that change neither the element's size nor scroll offsets.
+ const observeMovement = (element: Element, rect: DOMRect) => {
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some(entry => entry.boundingClientRect.left !== rect.left || entry.boundingClientRect.top !== rect.top
+ || entry.boundingClientRect.width !== rect.width || entry.boundingClientRect.height !== rect.height)) schedule()
+ }, { rootMargin: `${-rect.top}px ${rect.right - window.innerWidth}px ${rect.bottom - window.innerHeight}px ${-rect.left}px`, threshold: 1 })
+ observer.observe(element)
+ movement.push(observer)
+ }
const place = () => {
- if (parent.current === null || trigger.current === null || child.current === null) return
- const panel = parent.current.getBoundingClientRect()
- const row = trigger.current.getBoundingClientRect()
+ if (disposed) return
+ const panel = parentElement.getBoundingClientRect()
+ const row = triggerElement.getBoundingClientRect()
const margin = 12
const gap = 4
const vw = window.innerWidth
const vh = window.innerHeight
const topMargin = contextual ? margin : overlayTopMargin(margin, row, gap)
const maxHeight = Math.max(0, vh - topMargin - margin)
- // Keep natural width measurable after returning from a single-panel page.
- child.current.style.width = ''
- const width = child.current.offsetWidth
+ // Intrinsic width is refreshed only on placement invalidation, not idle frames.
+ if (childElement.style.width !== '') childElement.style.width = ''
+ const width = childElement.offsetWidth
const right = panel.right + gap
const left = panel.left - gap - width
const page = right + width > vw - margin && left < margin
const pageWidth = Math.min(Math.max(panel.width, width), vw - margin * 2)
const actualWidth = page ? pageWidth : width
const x = page ? panel.left : right + width <= vw - margin ? right : left
- const height = Math.min(child.current.offsetHeight, maxHeight)
+ const height = Math.min(childElement.offsetHeight, maxHeight)
const y = page ? panel.top : row.top
const next: SubmenuPosition = {
left: Math.max(margin, Math.min(x, vw - actualWidth - margin)),
@@ -58,20 +94,56 @@ export function useMenuSubmenuPosition(
...(page ? { width: pageWidth } : {}),
page,
}
- if (page) child.current.style.width = `${pageWidth}px`
+ if (page) childElement.style.width = `${pageWidth}px`
setPosition(current => current?.left === next.left && current.top === next.top
&& current.maxHeight === next.maxHeight && current.width === next.width && current.page === next.page ? current : next)
+ const boxes = [panel.left, panel.top, panel.width, panel.height, row.left, row.top, row.width, row.height, vw, vh].join(',')
+ if (typeof IntersectionObserver !== 'undefined' && boxes !== previousBoxes) {
+ for (const observer of movement) observer.disconnect()
+ movement = []
+ previousBoxes = boxes
+ observeMovement(parentElement, panel)
+ observeMovement(triggerElement, row)
+ }
}
place()
- let frame: number
- const track = () => { place(); frame = requestAnimationFrame(track) }
- frame = requestAnimationFrame(track)
+ const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)
+ resize?.observe(parentElement)
+ resize?.observe(triggerElement)
+ resize?.observe(childElement)
+ const mutation = new MutationObserver((records) => {
+ if (records.some(record => record.target !== childElement || record.attributeName !== 'style')) schedule()
+ })
+ const attributes = { attributes: true, attributeFilter: ['class', 'style', 'hidden'], childList: true }
+ mutation.observe(parentElement, { ...attributes, subtree: true, characterData: true })
+ mutation.observe(childElement, { ...attributes, subtree: true, characterData: true })
+ for (let ancestor = parentElement.parentElement; ancestor !== null; ancestor = ancestor.parentElement) {
+ mutation.observe(ancestor, attributes)
+ motionTargets.push(ancestor)
+ }
+ const onMotion = (event: Event) => {
+ if (event.target instanceof HTMLElement && motionTargets.includes(event.target)) schedule()
+ }
+ const motionEvents = ['animationstart', 'animationend', 'animationcancel', 'transitionrun', 'transitionend', 'transitioncancel']
+ for (const event of motionEvents) document.addEventListener(event, onMotion, true)
+ if (moving()) schedule()
+ const fonts = 'fonts' in document ? document.fonts : undefined
+ fonts?.addEventListener('loadingdone', schedule)
+ fonts?.addEventListener('loadingerror', schedule)
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
return () => {
- cancelAnimationFrame(frame)
+ disposed = true
+ if (frame !== null) cancelAnimationFrame(frame)
+ resize?.disconnect()
+ mutation.disconnect()
+ for (const event of motionEvents) document.removeEventListener(event, onMotion, true)
+ for (const observer of movement) observer.disconnect()
+ fonts?.removeEventListener('loadingdone', schedule)
+ fonts?.removeEventListener('loadingerror', schedule)
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
+ childElement.style.width = initialWidth
}
}, [active, parent, trigger, child, contextual])
return position
diff --git a/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx
new file mode 100644
index 0000000000..6f1030e72f
--- /dev/null
+++ b/packages/client/ui-primitives/tests/menu-submenu-position.client.spec.tsx
@@ -0,0 +1,259 @@
+// @vitest-environment jsdom
+/** Isolated submenu placement work counts exclude Menu's separate root-position tracker. */
+import { useRef } from 'react'
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
+import { useMenuSubmenuPosition } from '../src/menu-submenu-position.ts'
+
+let panelRect: DOMRect
+let triggerRect: DOMRect
+let childWidth: number
+let childHeight: number
+const intersections: ControlledIntersectionObserver[] = []
+const resizes: ControlledResizeObserver[] = []
+
+class ControlledIntersectionObserver implements IntersectionObserver {
+ readonly root = null
+ readonly rootMargin: string
+ readonly scrollMargin = '0px 0px 0px 0px'
+ readonly thresholds = [1]
+ private target: Element | null = null
+ constructor(private readonly callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
+ this.rootMargin = options?.rootMargin ?? ''
+ intersections.push(this)
+ }
+ observe = vi.fn((element: Element) => { this.target = element })
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+ takeRecords = () => []
+ emit(rect: DOMRect) {
+ this.callback([{ target: this.target!, boundingClientRect: rect, intersectionRect: rect,
+ rootBounds: rect, intersectionRatio: 1, isIntersecting: true, time: 0 }], this)
+ }
+}
+
+class ControlledResizeObserver implements ResizeObserver {
+ constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) }
+ observe = vi.fn()
+ unobserve = vi.fn()
+ disconnect = vi.fn()
+ emit() { this.callback([], this) }
+}
+
+function observers() {
+ vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver)
+ vi.stubGlobal('ResizeObserver', ControlledResizeObserver)
+}
+
+async function flushPlacement() {
+ await act(async () => { await Promise.resolve() })
+ act(() => { vi.advanceTimersToNextFrame() })
+}
+
+function Placement({ active = true, contextual = false }: { active?: boolean; contextual?: boolean }) {
+ const parent = useRef(null)
+ const trigger = useRef(null)
+ const child = useRef(null)
+ const position = useMenuSubmenuPosition(active ? 'parent' : null, parent, trigger, child, contextual)
+ return <>
+ Parent
+ {active && Choice
}
+ >
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ panelRect = new DOMRect(100, 100, 200, 100)
+ triggerRect = new DOMRect(100, 100, 200, 100)
+ childWidth = 200
+ childHeight = 100
+ intersections.length = 0
+ resizes.length = 0
+ vi.stubGlobal('innerWidth', 390)
+ vi.stubGlobal('innerHeight', 768)
+ vi.stubGlobal('ResizeObserver', undefined)
+ vi.stubGlobal('IntersectionObserver', undefined)
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ return this.tagName === 'BUTTON' ? triggerRect : panelRect
+ })
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => childWidth)
+ vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => childHeight)
+})
+afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
+
+it('does no geometry reads or width writes across 100 idle animation frames', async () => {
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ const width = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
+ const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
+ const view = render( )
+ const writes = vi.spyOn(view.getByTestId('child').style, 'width', 'set')
+ await flushPlacement()
+ rect.mockClear(); width.mockClear(); height.mockClear(); writes.mockClear()
+ act(() => { for (let frame = 0; frame < 100; frame++) vi.advanceTimersToNextFrame() })
+ const counts = {
+ rect: rect.mock.calls.length, width: width.mock.calls.length, height: height.mock.calls.length, widthWrites: writes.mock.calls.length,
+ }
+ console.info('submenu idle 100 frames', counts)
+ expect(counts).toEqual({ rect: 0, width: 0, height: 0, widthWrites: 0 })
+})
+
+it('tracks parent and row movement without idle polling and coalesces observer notifications', async () => {
+ observers()
+ vi.stubGlobal('innerWidth', 1024)
+ const view = render( )
+ const card = view.getByTestId('child')
+ const request = vi.spyOn(window, 'requestAnimationFrame')
+ expect(card.style.left).toBe('304px')
+ intersections[0]!.emit(panelRect)
+ expect(request).not.toHaveBeenCalled()
+ const original = [...intersections]
+ panelRect = new DOMRect(400, 100, 200, 100)
+ intersections[0]!.emit(panelRect)
+ intersections[0]!.emit(panelRect)
+ expect(request).toHaveBeenCalledTimes(1)
+ await flushPlacement()
+ expect(card.style.left).toBe('604px')
+ expect(original.every(observer => observer.disconnect.mock.calls.length === 1)).toBe(true)
+ triggerRect = new DOMRect(404, 740, 192, 34)
+ intersections.at(-1)!.emit(triggerRect)
+ await flushPlacement()
+ expect(card.style.top).toBe('656px')
+ expect(resizes[0]!.observe).toHaveBeenCalledTimes(3)
+})
+
+it('remeasures content and font changes while retaining page width and flipping when space becomes available', async () => {
+ observers()
+ const fonts = new EventTarget()
+ const previousFonts = Object.getOwnPropertyDescriptor(document, 'fonts')
+ Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
+ onTestFinished(() => {
+ if (previousFonts === undefined) Reflect.deleteProperty(document, 'fonts')
+ else Object.defineProperty(document, 'fonts', previousFonts)
+ })
+ panelRect = new DOMRect(40, 100, 300, 100)
+ const view = render( )
+ const card = view.getByTestId('child')
+ expect(card.style.width).toBe('300px')
+ childWidth = 350
+ card.textContent = 'A longer localized choice'
+ await flushPlacement()
+ expect(card.style.width).toBe('350px')
+ expect(card.style.left).toBe('28px')
+ childWidth = 200
+ fonts.dispatchEvent(new Event('loadingdone'))
+ await flushPlacement()
+ expect(card.style.width).toBe('300px')
+ childWidth = 320
+ fonts.dispatchEvent(new Event('loadingerror'))
+ await flushPlacement()
+ expect(card.style.width).toBe('320px')
+ vi.stubGlobal('innerWidth', 1024)
+ act(() => { window.dispatchEvent(new Event('resize')) })
+ expect(card.style.width).toBe('')
+ expect(card.style.left).toBe('344px')
+ panelRect = new DOMRect(700, 100, 300, 100)
+ act(() => { window.dispatchEvent(new Event('scroll')) })
+ expect(card.style.left).toBe('376px')
+ childHeight = 1000
+ resizes[0]!.emit()
+ await flushPlacement()
+ expect(card.style.maxHeight).toBe('744px')
+ expect(card.style.top).toBe('12px')
+ view.unmount()
+ expect(resizes[0]!.disconnect).toHaveBeenCalledOnce()
+ expect(intersections.every(observer => observer.disconnect.mock.calls.length > 0)).toBe(true)
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ rect.mockClear()
+ fonts.dispatchEvent(new Event('loadingdone'))
+ resizes[0]!.emit()
+ intersections[0]!.emit(new DOMRect())
+ await flushPlacement()
+ expect(rect).not.toHaveBeenCalled()
+})
+
+it.each([true, false])('uses the contextual=%s top clearance for a tall child', (contextual) => {
+ const root = document.documentElement
+ const previous = root.style.getPropertyValue('--dsh-frame-top-clearance')
+ root.style.setProperty('--dsh-frame-top-clearance', '96px')
+ onTestFinished(() => {
+ if (previous === '') root.style.removeProperty('--dsh-frame-top-clearance')
+ else root.style.setProperty('--dsh-frame-top-clearance', previous)
+ })
+ childHeight = 1000
+ const view = render( )
+ expect(view.getByTestId('child').style.top).toBe(contextual ? '12px' : '116px')
+ expect(view.getByTestId('child').style.maxHeight).toBe(contextual ? '744px' : '640px')
+})
+
+it('observes ancestor attributes and ignores its own width writes', async () => {
+ const view = render( )
+ const card = view.getByTestId('child')
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ await flushPlacement()
+ rect.mockClear()
+ card.style.width = '200px'
+ await flushPlacement()
+ expect(rect).not.toHaveBeenCalled()
+ panelRect = new DOMRect(20, 150, 200, 100)
+ view.container.style.transform = 'translateY(50px)'
+ await flushPlacement()
+ expect(card.style.top).toBe('150px')
+ card.className = 'compact'
+ await flushPlacement()
+ expect(rect).toHaveBeenCalled()
+})
+
+it.each([true, false])('tracks a finite ancestor animation alreadyRunning=%s, then stops scheduling frames', (alreadyRunning) => {
+ observers()
+ vi.stubGlobal('innerWidth', 1024)
+ let running = alreadyRunning
+ const body = document.body
+ const original = Object.getOwnPropertyDescriptor(body, 'getAnimations')
+ Object.defineProperty(body, 'getAnimations', { configurable: true, value: () => [{ playState: running ? 'running' : 'finished' }] })
+ onTestFinished(() => {
+ if (original === undefined) Reflect.deleteProperty(body, 'getAnimations')
+ else Object.defineProperty(body, 'getAnimations', original)
+ })
+ const view = render( )
+ const card = view.getByTestId('child')
+ running = true
+ act(() => { body.dispatchEvent(new Event('transitionrun', { bubbles: true })) })
+ panelRect = new DOMRect(100, 100, 180, 100)
+ act(() => { vi.advanceTimersToNextFrame() })
+ expect(card.style.left).toBe('284px')
+ panelRect = new DOMRect(100, 100, 160, 100)
+ act(() => { vi.advanceTimersToNextFrame() })
+ expect(card.style.left).toBe('264px')
+ running = false
+ act(() => { body.dispatchEvent(new Event('transitionend', { bubbles: true })); vi.advanceTimersToNextFrame() })
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ rect.mockClear()
+ act(() => {
+ document.dispatchEvent(new Event('animationstart'))
+ card.dispatchEvent(new Event('animationstart', { bubbles: true }))
+ for (let frame = 0; frame < 100; frame++) vi.advanceTimersToNextFrame()
+ })
+ expect(rect).not.toHaveBeenCalled()
+ view.unmount()
+ body.dispatchEvent(new Event('animationstart', { bubbles: true }))
+ act(() => { vi.advanceTimersToNextFrame() })
+ expect(rect).not.toHaveBeenCalled()
+})
+
+it('cancels pending placement and ignores already queued frames after close', () => {
+ observers()
+ const callbacks: FrameRequestCallback[] = []
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { callbacks.push(callback); return callbacks.length })
+ const cancel = vi.spyOn(window, 'cancelAnimationFrame')
+ const view = render( )
+ resizes[0]!.emit()
+ view.rerender( )
+ expect(cancel).toHaveBeenCalledWith(1)
+ const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ rect.mockClear()
+ act(() => { callbacks[0]!(0) })
+ expect(rect).not.toHaveBeenCalled()
+ expect(view.queryByTestId('child')).toBeNull()
+})
diff --git a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx
index df4acb094e..ce3db963b0 100644
--- a/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/menu-submenu.client.spec.tsx
@@ -36,7 +36,7 @@ afterEach(() => {
})
function pointer(
- target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'touch', relatedTarget: EventTarget | null = null,
+ target: Element, type: 'pointerover' | 'pointerout' | 'pointerdown', pointerType: 'mouse' | 'pen' | 'touch', relatedTarget: EventTarget | null = null,
) {
const event = new MouseEvent(type, { bubbles: true, relatedTarget })
Object.defineProperty(event, 'pointerType', { value: pointerType })
@@ -66,7 +66,8 @@ function ExternalTriggerFixture({ onClose }: { onClose: () => void }) {
const trigger = useRef(null)
const [open, setOpen] = useState(false)
return <>
- { setOpen(value => !value) }}>External more
+ { setOpen(value => !value) }}>External more
trigger.current}
getAnchorRect={() => trigger.current?.getBoundingClientRect() ?? null}
items={entries} onClose={() => { onClose(); setOpen(false) }} />
@@ -85,6 +86,51 @@ function childMenu() {
}
describe('Menu submenus', () => {
+ it('dismisses a non-contextual menu when an external marked trigger is pressed', () => {
+ const close = vi.fn()
+ render(<>
+ Header more
+
+ >)
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'Header more' }))
+ expect(close).toHaveBeenCalledOnce()
+ })
+
+ it('does not activate an external popup trigger on ArrowRight', () => {
+ render( )
+ const trigger = screen.getByRole('button', { name: 'External more' })
+ act(() => { trigger.focus() })
+ fireEvent.click(trigger)
+ fireEvent.keyDown(trigger, { key: 'ArrowRight' })
+ expect(screen.getByRole('menu')).toBeDefined()
+ expect(document.activeElement).toBe(trigger)
+ })
+
+ it('dismisses on a return-focus subtree that is not marked as a menu trigger', () => {
+ const close = vi.fn()
+ const restore = document.createElement('div')
+ const target = document.createElement('button')
+ restore.append(target)
+ document.body.append(restore)
+ try {
+ render( restore} items={entries} onClose={close} />)
+ fireEvent.pointerDown(target)
+ expect(close).toHaveBeenCalledOnce()
+ } finally { restore.remove() }
+ })
+
+ it.each(['mouse', 'pen'] as const)('accepts a secondary %s when the primary pointer cannot hover', (pointerType) => {
+ vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(any-hover: hover)' })))
+ render( )
+ openMenu()
+ const parent = screen.getByRole('menuitem', { name: 'Applications' })
+ pointer(parent, 'pointerover', 'touch')
+ fireEvent.mouseOver(parent)
+ expect(screen.queryByRole('menuitem', { name: 'First app' })).toBeNull()
+ pointer(parent, 'pointerover', pointerType)
+ expect(screen.getByRole('menuitem', { name: 'First app' })).toBeDefined()
+ })
+
it('lets an external return-focus trigger toggle closed through pointerdown then click', () => {
const close = vi.fn()
render( )
diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml
index 0e613d6a4c..c3f106de81 100644
--- a/packages/session-query/session-log-export/README.i18n.yaml
+++ b/packages/session-query/session-log-export/README.i18n.yaml
@@ -45,8 +45,8 @@
en: 7cb82a6851405f95
zh: b8c525170d19dc6c
/deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split:
- en: 29c6e2eec2f51a6f
- zh: 87a067d4de78b8e8
+ en: a348c97530fa75f8
+ zh: 268a28d324be015a
/deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow:
en: bd46d2fa1273a6a4
zh: 4d3eb2cf0a9bf12b
diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md
index 3b91dde81d..ae0acadd12 100644
--- a/packages/session-query/session-log-export/README.md
+++ b/packages/session-query/session-log-export/README.md
@@ -100,7 +100,7 @@ This section explains how the package wires the export control and points at the
The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download.
-The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. Its captured `sessionHeader` target is declaration-merged by this package and remains independent of Session-row and title context actions. Export and optional feedback contributions observe their controller, availability, and locale sources; the button observes the shared active target for expanded state. The Session-scoped download dialog remains mounted beside the trigger, while menu focus and dismissal belong to the shared overlay.
+The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. The shared context-menu package declares its captured `sessionHeader` target, independent of Session-row and title context actions; this package owns the Header trigger. Export and optional feedback contributions observe their controller, availability, and locale sources; the button observes the shared active target for expanded state. The Session-scoped download dialog remains mounted beside the trigger, while menu focus and dismissal belong to the shared overlay.
### Download flow
diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md
index d8182eca6d..a7726c9500 100644
--- a/packages/session-query/session-log-export/README.zh.md
+++ b/packages/session-query/session-log-export/README.zh.md
@@ -100,7 +100,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。
-Header 的更多入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。本包通过声明合并定义被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。
+Header 的更多入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。共享右键菜单包声明被捕获的 `sessionHeader` 目标,独立于会话行及标题右键操作;本包持有 Header 入口。导出及可选反馈贡献观察各自的控制器、可用性与语言源;按钮通过共享活动目标显示展开状态。会话范围的下载弹窗保持挂载在按钮旁,菜单焦点和关闭由共享浮层管理。
### 下载流程
diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx
index 2404a2b18c..0d3f81a5d1 100644
--- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx
+++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx
@@ -17,7 +17,7 @@ export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDial
headerMenu: ObservableSnapshot
}
/**
- * Toggle the shared menu for the captured Header Session.
+ * Toggle the shared menu by Header Session identity, regardless of anchor movement.
* @param sessionId - Session displayed by this Header.
* @param point - trigger's viewport anchor.
*/
diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts
index f914a5427c..bb59479367 100644
--- a/packages/session-query/session-log-export/src/client/index.ts
+++ b/packages/session-query/session-log-export/src/client/index.ts
@@ -23,13 +23,6 @@ declare module '@deepseek-ai/cordis' {
}
}
-declare module '@deepseek-ai/dsh-client-ui-context-menu/client' {
- interface ContextMenuTargetMap {
- /** Header utilities, independent of Session-row and title context actions. */
- sessionHeader: { readonly sessionId: SessionId }
- }
-}
-
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
'session-log-download': SessionLogDownloadKey
@@ -113,6 +106,7 @@ export function apply(ctx: ClientContext): void {
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
openMenu: (sessionId, point) => {
const active = ctx.contextMenus.active.getSnapshot()
+ // A second button activation dismisses its menu even if layout moved the anchor.
if (active?.kind === 'sessionHeader' && active.value.sessionId === sessionId) ctx.contextMenus.close()
else ctx.contextMenus.open({ kind: 'sessionHeader', value: { sessionId } }, point)
},
diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx
index 58f0f1d333..73a338caf8 100644
--- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx
+++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx
@@ -96,6 +96,19 @@ describe('session-log-download browser plugin', () => {
expect(b.active.getSnapshot()).toEqual({ kind: 'sessionHeader', value: { sessionId: 'another' } })
})
+ it('dismisses the same Header menu after its button anchor moves more than four pixels', async () => {
+ const b = await bench()
+ const face = injectedOf(b.slots.entries('conversation.session.header.utilities')[0]) as SessionLogDownloadHeaderInjected
+ face.openMenu(SID, { x: 20, y: 30 })
+ const moved = { x: 120, y: 80 }
+ face.openMenu(SID, moved)
+ expect(b.close).toHaveBeenCalledOnce()
+ expect(b.open).toHaveBeenCalledOnce()
+ expect(b.active.getSnapshot()).toBeNull()
+ face.openMenu(SID, moved)
+ expect(b.open).toHaveBeenLastCalledWith({ kind: 'sessionHeader', value: { sessionId: SID } }, moved)
+ })
+
it('provides one controller and removes its Header and overlay contributions on disposal', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })))
const b = await bench()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1544e576a8..32d3c7ce0d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4944,9 +4944,6 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
- '@deepseek-ai/dsh-session-log-export':
- specifier: workspace:*
- version: link:../../session-query/session-log-export
'@deepseek-ai/dsh-util-workspace-path':
specifier: workspace:*
version: link:../../util/workspace-path
From 83a8d0a0bf24739d07ceb979af3052558f287c04 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 01:23:04 +0800
Subject: [PATCH 023/196] fix(llm-pi-ai): omit Anthropic thinking whose request
prefix changed
Anthropic binds signed thinking to the system prompt, tools, and earlier
messages. Compaction and tool-result pruning rewrite that prefix, so
retained responses replayed stale signatures and enforcing endpoints
rejected every later request. Record a prefix digest with each Anthropic
Messages response and drop thinking from replayed responses whose prefix
digest changed or was never recorded.
Fixes #7582
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/README.i18n.yaml | 16 +-
packages/llm/llm-pi-ai/README.md | 7 +-
packages/llm/llm-pi-ai/README.zh.md | 7 +-
packages/llm/llm-pi-ai/src/adapter.ts | 19 +-
packages/llm/llm-pi-ai/src/context.ts | 22 ++-
packages/llm/llm-pi-ai/src/index.ts | 3 +
packages/llm/llm-pi-ai/src/prefix-binding.ts | 93 +++++++++
packages/llm/llm-pi-ai/src/replay.ts | 30 ++-
packages/llm/llm-pi-ai/src/stream.ts | 4 +-
packages/llm/llm-pi-ai/tests/convert.spec.ts | 1 +
.../llm-pi-ai/tests/prefix-binding.spec.ts | 178 ++++++++++++++++++
11 files changed, 352 insertions(+), 28 deletions(-)
create mode 100644 packages/llm/llm-pi-ai/src/prefix-binding.ts
create mode 100644 packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index 635adc8d9f..a1dbdb1b1d 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -51,8 +51,8 @@
en: 615ca9834bf6544f
zh: 2d70b548f99000d1
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/source-map:
- en: 64912b8a3a336419
- zh: 33a245969b4194f6
+ en: 01520c709ee2202c
+ zh: de919d3f2ff61046
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/registration-and-directory:
en: 0c6df3e8b6a75372
zh: 476666a6f209542c
@@ -60,8 +60,8 @@
en: 477d277e9ac35c8f
zh: 96870ca333fb6510
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary:
- en: e64173763fdbcfae
- zh: 73ed2c2af54b62f6
+ en: 507ceb2856849646
+ zh: 4f54925b52a1c190
/deepseek-ai-dsh-llm-pi-ai/further-exploration:
en: a2a2ccdeae59d408
zh: c47ee091aa90ad91
@@ -72,14 +72,14 @@
en: e7348db3f7115960
zh: 5fd5fb09d7d09982
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/what-the-model-sees:
- en: a1c686712365d7ae
- zh: f28c4e2373013a2a
+ en: 5a721ff77ea17329
+ zh: a6939df9086ffbe4
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/token-effect:
en: 820221e8ae37aa61
zh: 7e6ff4db10067fc0
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/kv-cache-effect:
- en: 7a2595060340d7d5
- zh: e4ba753dcdaa89fc
+ en: f12ebd22715827ea
+ zh: 8f006b1be832bcc7
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-response:
en: 802d7ff60a1d03ff
zh: c41c56e28f3e8680
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 1021dea71d..68cf9c77d3 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -162,6 +162,7 @@ This event covers inference calls with or without a Session id, including auxili
| [`src/context.ts`](src/context.ts) | Harness-to-pi-ai context conversion, image handling, replay restore |
| [`src/stream.ts`](src/stream.ts) | pi-ai event conversion into harness `StreamChunk` values |
| [`src/replay.ts`](src/replay.ts) | Versioned `ReplayEnvelope` storage and validation |
+| [`src/prefix-binding.ts`](src/prefix-binding.ts) | Anthropic thinking omission after a request-prefix change |
| [`src/discovery.ts`](src/discovery.ts) | Endpoint interrogation for configuration surfaces |
### Registration and directory
@@ -191,6 +192,8 @@ The `pre-commit` hook runs `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and uses it to retain same-model signatures; provider-reported response models remain diagnostic metadata. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks. Protocol conversion operates on request-local copies. Switching from A to B and back to A preserves the stored content, including empty text blocks, tool ids, original block order, and native replay metadata; requests to a different provider, API, or model omit unsupported signatures and reasoning blocks. Degraded replay without native metadata also omits reasoning. Valid native replay retains the stored reasoning when the original model resumes.
+Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's thinking and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text and tool calls remain, and omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues keeps its thinking. The digest covers the pi-ai context only; switching authentication or compat settings that change the wire request mid-conversation is not detected.
+
-----
@@ -216,7 +219,7 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
-The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order when the catalog model declares `supportsMidConvoSystemMessages`. Otherwise, it converts each non-empty later system message to a user message wrapped in `` before passing it to pi-ai. The wrapper does not grant system-role priority. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source.
+The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order when the catalog model declares `supportsMidConvoSystemMessages`. Otherwise, it converts each non-empty later system message to a user message wrapped in `` before passing it to pi-ai. The wrapper does not grant system-role priority. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. On Anthropic Messages routes, a replayed response whose request prefix changed since generation, for example through compaction, loses its thinking blocks; its text and tool calls remain. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source.
#### Token effect
@@ -224,7 +227,7 @@ Provider tokenization governs exact input. Retained images add the stable attach
#### KV Cache effect
-Later system messages supplied to the adapter stay out of the initial prompt, preserving the preceding request prefix. This does not change the agent loop’s prompt admission, which consolidates updates on routes without native system-update support. Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards.
+Later system messages supplied to the adapter stay out of the initial prompt, preserving the preceding request prefix. This does not change the agent loop’s prompt admission, which consolidates updates on routes without native system-update support. Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. Omitting stale Anthropic thinking follows a prefix change that already ended reuse at the changed message. The first request that omits thinking recorded without a digest ends reuse at the first such message once. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards.
### Provider response
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index 1526faa4b8..620fd428f6 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -162,6 +162,7 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
| [`src/context.ts`](src/context.ts) | Harness 到 pi-ai 的上下文转换、图片处理、回放恢复 |
| [`src/stream.ts`](src/stream.ts) | 把 pi-ai 事件转换为 harness `StreamChunk` 值 |
| [`src/replay.ts`](src/replay.ts) | 带版本的 `ReplayEnvelope` 存储与校验 |
+| [`src/prefix-binding.ts`](src/prefix-binding.ts) | 请求前缀变化后省略 Anthropic thinking |
| [`src/discovery.ts`](src/discovery.ts) | 面向配置界面的端点询问 |
### 注册与目录
@@ -191,6 +192,8 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并用它保留同模型签名;提供方报告的响应模型仅作为诊断元数据。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。 协议转换仅作用于请求副本。从 A 切换到 B 再切回 A 时,存储的内容保持完整,包括空文本块、工具 id、原始块顺序和原生回放元数据;发给不同提供方、API 或模型的请求会省略不受支持的签名和推理块。缺少原生元数据的降级回放也会省略推理。切回原模型时,有效的原生回放会保留已存储的推理。
+Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本和工具调用保留,已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当模型的 compat 未设置 `forceAdaptiveThinking` 时,请求所延续的工具轮次对应的 assistant 消息会保留其 thinking。摘要只覆盖 pi-ai 上下文;对话中途切换认证或 compat 设置而改变线上请求时,不会被检测到。
+
-----
@@ -216,7 +219,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
#### 模型看到什么
-所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。
+所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。在 Anthropic Messages 路由上,如果回放响应的请求前缀自生成后发生变化(例如经过压缩),该响应会丢失 thinking 块;其文本和工具调用保留。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。
#### Token 影响
@@ -224,7 +227,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
#### KV Cache 影响
-传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。
+传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。省略过期的 Anthropic thinking 发生在前缀变化之后,而该变化已在变化的消息处结束复用。第一个省略无摘要记录的 thinking 的请求会在第一条此类消息处结束复用,且仅发生一次。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。
### 提供方响应
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index 4c3bdce845..0f8c53f25d 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -62,6 +62,8 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
import { requestImageTarget, toPiContext } from './context.ts'
import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts'
import { toStreamChunks } from './stream.ts'
+import { bindThinkingToPrefix } from './prefix-binding.ts'
+import type { PrefixDigests } from './replay.ts'
import type { PiAiRequestHeaderContext } from './types.ts'
/**
@@ -126,6 +128,12 @@ export interface PiAiAdapterOptions {
* conversion because its stored replay state is unusable by this build.
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
+ /**
+ * Observe one request omitting replayed Anthropic thinking whose request
+ * prefix changed or was never recorded; stale messages stay omitted, so
+ * every later request of the conversation reports them again.
+ */
+ onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void
}
/** The two auth injectables a pi-ai collection is built with. */
@@ -444,8 +452,9 @@ export class PiAiAdapter extends LlmAdapter {
const onReplayDegrade = (reason: string): void => {
this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
}
- const context = attachments === undefined
- ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history')
+ const prefixDigests: PrefixDigests = new Map()
+ const converted = attachments === undefined
+ ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
: await toPiContext({ ...request, signal: watchdog.signal }, {
attachments,
resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
@@ -454,7 +463,9 @@ export class PiAiAdapter extends LlmAdapter {
maxPixels: profile.requestImagePixelBudget,
maxBytes: profile.requestImageMaxBytes,
},
- }, onReplayDegrade, updates.systemPromptUpdate === 'in-history')
+ }, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
+ const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests)
+ if (omitted > 0) this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
@@ -463,7 +474,7 @@ export class PiAiAdapter extends LlmAdapter {
signal: watchdog.signal,
headers: requestHeaders(profile.headers, options, model.baseUrl, this.config.prepareHeaders),
})
- const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]()
+ const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id, digest)[Symbol.asyncIterator]()
let exhausted = false
try {
while (true) {
diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts
index f884bb049a..3d5f9d91b7 100644
--- a/packages/llm/llm-pi-ai/src/context.ts
+++ b/packages/llm/llm-pi-ai/src/context.ts
@@ -16,6 +16,7 @@ import type {
} from '@deepseek-ai/dsh-attachment'
import type { Context as PiContext, ImageContent, Message as PiMessage, SystemMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
import { toPiAssistant } from './replay.ts'
+import type { PrefixDigests } from './replay.ts'
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts'
@@ -205,8 +206,9 @@ function appendAssistant(
messages: PiMessage[],
toolNames: Map,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): void {
- const assistant = toPiAssistant(message, onReplayDegrade)
+ const assistant = toPiAssistant(message, onReplayDegrade, prefixDigests)
for (const block of assistant.content) {
if (block.type === 'toolCall') toolNames.set(brandString(block.id), block.name)
}
@@ -221,6 +223,7 @@ function appendSharedHistory(
toolNames: Map,
supportsMidConvoSystemMessages: boolean,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): boolean {
if (message.role === 'developer') {
appendDeveloper(message, options, messages)
@@ -239,7 +242,7 @@ function appendSharedHistory(
return true
}
if (message.role === 'assistant') {
- appendAssistant(message, messages, toolNames, onReplayDegrade)
+ appendAssistant(message, messages, toolNames, onReplayDegrade, prefixDigests)
return true
}
return false
@@ -249,6 +252,7 @@ function textOnlyContext(
options: GenerateOptions,
supportsMidConvoSystemMessages: boolean,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): PiContext {
assertSupportedHistory(options.messages)
const split = splitSystemPrompt(options)
@@ -258,7 +262,7 @@ function textOnlyContext(
if (contentHasImage(message.content)) {
throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
- if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue
+ if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)) continue
if (message.role === 'tool') {
messages.push(toolResultOf(message, toolNames, flattenText(message)))
continue
@@ -305,6 +309,7 @@ export function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageReque
* @param images - absent; selects the synchronous conversion.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder.
+ * @param prefixDigests - receives the recorded request-prefix digest of each replayed assistant message that has one.
* @returns the pi-ai context; `tools` is omitted when the request declares none.
* @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message.
*/
@@ -313,6 +318,7 @@ export function toPiContext(
images?: undefined,
onReplayDegrade?: (reason: string) => void,
supportsMidConvoSystemMessages?: boolean,
+ prefixDigests?: PrefixDigests,
): PiContext
/**
* Convert harness history to a pi-ai Context while resolving durable images.
@@ -325,6 +331,7 @@ export function toPiContext(
* @param images - attachment provider, current path resolver, and request limits.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder.
+ * @param prefixDigests - receives the recorded request-prefix digest of each replayed assistant message that has one.
* @returns the asynchronously resolved pi-ai context.
*/
export function toPiContext(
@@ -332,16 +339,18 @@ export function toPiContext(
images: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
supportsMidConvoSystemMessages?: boolean,
+ prefixDigests?: PrefixDigests,
): Promise
export function toPiContext(
options: GenerateOptions,
images?: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
supportsMidConvoSystemMessages = false,
+ prefixDigests?: PrefixDigests,
): PiContext | Promise {
return images === undefined
- ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade)
- : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade)
+ ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)
+ : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)
}
async function toPiContextWithImages(
@@ -349,6 +358,7 @@ async function toPiContextWithImages(
images: PiImageRequestContext,
supportsMidConvoSystemMessages: boolean,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): Promise {
const { attachments, resolveImageAccess, maxRequestImageBytes } = images
const requestImagePolicy = images.requestImagePolicy ?? {
@@ -380,7 +390,7 @@ async function toPiContextWithImages(
const messages: PiMessage[] = []
for (const message of exactMessages) {
- if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue
+ if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)) continue
if (message.role === 'tool') {
messages.push(toolResultOf(message, toolNames, userContent(message.content, requestImages, resolveImageAccess)))
continue
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index 5a53e7106a..a671b7229a 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -252,6 +252,9 @@ export function apply(ctx: Context, config: Config): void {
+ ` sending that message as provider-neutral content (${reason})`,
)
},
+ onThinkingOmitted: ({ provider, model, count }) => {
+ ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
+ },
})
// Independent of the route set: signing in is what makes a route worth
// adding, so the flows are offered before any profile names their provider.
diff --git a/packages/llm/llm-pi-ai/src/prefix-binding.ts b/packages/llm/llm-pi-ai/src/prefix-binding.ts
new file mode 100644
index 0000000000..d5ed0451df
--- /dev/null
+++ b/packages/llm/llm-pi-ai/src/prefix-binding.ts
@@ -0,0 +1,93 @@
+/**
+ * Request-prefix binding for replayed Anthropic thinking.
+ *
+ * Anthropic can bind each signed thinking block to the request prefix it was
+ * generated under: system prompt, tools, and every earlier message. Compaction
+ * and tool-result pruning rewrite that prefix, so a retained signed block
+ * becomes invalid and endpoints that enforce the check reject the whole
+ * request. Each Anthropic Messages response records a digest of the context it
+ * was generated from; a later request replays its thinking only while the
+ * reconstructed prefix still has the same digest. Replay state without a
+ * digest predates this check and is treated as stale.
+ *
+ * Manual (non-adaptive) thinking requires the final assistant turn of a tool
+ * round to start with a thinking block, so that message keeps its thinking
+ * when the model does not force adaptive thinking.
+ *
+ * @module dsh-llm-pi-ai/prefix-binding
+ */
+
+import { createHash } from 'node:crypto'
+import type { Api, AssistantMessage, Context as PiContext, Model } from '@earendil-works/pi-ai'
+import type { PrefixDigests } from './replay.ts'
+
+/** Wire protocol whose thinking signatures bind to the request prefix. */
+const PREFIX_BOUND_API = 'anthropic-messages'
+
+/** Seed of every digest chain; changing the digest input requires a new seed. */
+const DIGEST_SEED = 'dsh-llm-pi-ai/prefix-binding/v1'
+
+function chain(previous: string, value: unknown): string {
+ return createHash('sha256').update(previous).update('\0').update(JSON.stringify(value)).digest('hex')
+}
+
+/** Index of the assistant message whose tool round the request continues, or -1. */
+function openToolRoundAssistant(messages: PiContext['messages']): number {
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const message = messages[index] as PiContext['messages'][number]
+ if (message.role === 'assistant') return index
+ if (message.role !== 'toolResult') return -1
+ }
+ return -1
+}
+
+/** Copy of an assistant message without its thinking blocks. */
+function withoutThinking(message: AssistantMessage): AssistantMessage {
+ return { ...message, content: message.content.filter(block => block.type !== 'thinking') }
+}
+
+/** Context whose stale thinking is removed, with the digest of the complete request. */
+export interface PrefixBoundContext {
+ /** Context to send; equal to the input when nothing was removed. */
+ context: PiContext
+ /** Digest of the sent context, recorded with the response; absent for other protocols. */
+ digest?: string
+ /** Number of replayed assistant messages whose thinking was removed. */
+ omitted: number
+}
+
+/**
+ * Remove thinking from replayed Anthropic Messages assistant messages whose
+ * request prefix changed since they were generated, or whose replay state
+ * records no prefix digest.
+ * @param context - converted request context.
+ * @param model - requested model.
+ * @param prefixDigests - recorded digests the context conversion collected.
+ * @returns the context to send, its digest for Anthropic Messages models, and the omission count.
+ */
+export function bindThinkingToPrefix(
+ context: PiContext,
+ model: Model,
+ prefixDigests: PrefixDigests,
+): PrefixBoundContext {
+ if (model.api !== PREFIX_BOUND_API) return { context, omitted: 0 }
+ const adaptive = (model as Model).compat?.forceAdaptiveThinking === true
+ const kept = adaptive ? -1 : openToolRoundAssistant(context.messages)
+ let digest = chain(DIGEST_SEED, { systemPrompt: context.systemPrompt ?? null, tools: context.tools ?? null })
+ let messages: PiContext['messages'] | undefined
+ let omitted = 0
+ for (const [index, original] of context.messages.entries()) {
+ let message = original
+ if (
+ message.role === 'assistant' && message.api === PREFIX_BOUND_API && index !== kept
+ && prefixDigests.get(message) !== digest && message.content.some(block => block.type === 'thinking')
+ ) {
+ message = withoutThinking(message)
+ messages ??= context.messages.slice()
+ messages[index] = message
+ omitted++
+ }
+ digest = chain(digest, message)
+ }
+ return { context: messages === undefined ? context : { ...context, messages }, digest, omitted }
+}
diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts
index 098f4a6301..8e5bcd3ca3 100644
--- a/packages/llm/llm-pi-ai/src/replay.ts
+++ b/packages/llm/llm-pi-ai/src/replay.ts
@@ -32,6 +32,8 @@ export interface PiAiReplayResponse {
/** Provider-native effort for historical replay; absence is preserved. */
providerThinkingLevel?: string
stopReason: AssistantMessage['stopReason']
+ /** Digest of the request context this response was generated from; see `prefix-binding.ts`. */
+ prefixDigest?: string
}
/** The validated halves of one pi-ai replay envelope. */
@@ -65,6 +67,9 @@ function emptyPiUsage(): PiUsage {
}
}
+/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */
+export type PrefixDigests = Map
+
/**
* Project a successful pi-ai response into the minimal durable replay state.
* The per-block half is index-aligned with the streamed blocks (pi-ai content
@@ -72,9 +77,10 @@ function emptyPiUsage(): PiUsage {
* removes one.
* @param message - completed native pi-ai assistant response.
* @param requestedModel - request identity stored in the assistant source; defaults to the native model.
+ * @param prefixDigest - digest of the request context, recorded when the protocol binds thinking to it.
* @returns the versioned lossless-JSON replay projection.
*/
-export function toPiReplayState(message: AssistantMessage, requestedModel = message.model): ReplayEnvelope {
+export function toPiReplayState(message: AssistantMessage, requestedModel = message.model, prefixDigest?: string): ReplayEnvelope {
const response: PiAiReplayResponse = {
kind: 'pi-ai',
version: 2,
@@ -85,6 +91,7 @@ export function toPiReplayState(message: AssistantMessage, requestedModel = mess
...message.responseId === undefined ? {} : { responseId: message.responseId },
...message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel },
stopReason: message.stopReason,
+ ...prefixDigest === undefined ? {} : { prefixDigest },
}
return {
response,
@@ -131,6 +138,7 @@ function readReplayState(value: unknown): PiAiReplayState {
if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string')
if (response['providerThinkingLevel'] !== undefined && typeof response['providerThinkingLevel'] !== 'string') return invalidReplay('providerThinkingLevel must be a string')
+ if (response['prefixDigest'] !== undefined && typeof response['prefixDigest'] !== 'string') return invalidReplay('prefixDigest must be a string')
const blocks = envelope['blocks']
if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array')
for (const [index, value] of blocks.entries()) {
@@ -184,7 +192,12 @@ function foreignAssistant(message: HarnessAssistantMessage): AssistantMessage {
}
/** Recombine durable Harness content with validated pi-ai replay metadata. */
-function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessageSource, rawState: unknown): AssistantMessage {
+function replayedAssistant(
+ message: HarnessAssistantMessage,
+ source: ModelMessageSource,
+ rawState: unknown,
+ prefixDigests?: PrefixDigests,
+): AssistantMessage {
const state = readReplayState(rawState)
if (state.response.provider !== source.provider) return invalidReplay('provider does not match assistant source')
if (state.response.model !== source.model) return invalidReplay('model does not match assistant source')
@@ -216,7 +229,7 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag
default: return invalidReplay(`block ${index} has an unsupported Harness type`)
}
})
- return {
+ const assistant: AssistantMessage = {
role: 'assistant',
content,
api: state.response.api,
@@ -229,6 +242,8 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag
stopReason: state.response.stopReason,
timestamp: 0,
}
+ if (state.response.prefixDigest !== undefined) prefixDigests?.set(assistant, state.response.prefixDigest)
+ return assistant
}
/**
@@ -242,13 +257,18 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag
* @param message - model-produced assistant content with provider, model, and optional adapter-owned replay metadata.
* @param onDegrade - called with the diagnostic reason when an unusable replay
* state falls back to provider-neutral conversion.
+ * @param prefixDigests - receives the recorded request-prefix digest of the returned message, when replay state has one.
* @returns a native pi-ai assistant message reconstructed from durable content.
*/
-export function toPiAssistant(message: HarnessAssistantMessage, onDegrade?: (reason: string) => void): AssistantMessage {
+export function toPiAssistant(
+ message: HarnessAssistantMessage,
+ onDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
+): AssistantMessage {
const source = message.source
if (source.replayState === undefined) return foreignAssistant(message)
try {
- return replayedAssistant(message, source, source.replayState)
+ return replayedAssistant(message, source, source.replayState, prefixDigests)
} catch (error: unknown) {
/* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors; the
guard keeps a future non-replay failure loud instead of silently degrading it */
diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts
index 17b7158135..a897a8e740 100644
--- a/packages/llm/llm-pi-ai/src/stream.ts
+++ b/packages/llm/llm-pi-ai/src/stream.ts
@@ -136,6 +136,7 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number)
* @param callerSignal - caller cancellation state; an aborted caller makes any
* in-band terminal error an aborted finish.
* @param requestedModel - request model identity recorded for durable replay.
+ * @param prefixDigest - request-context digest recorded for durable replay.
* @returns the harness chunks, ending with `usage` then `finish`; throws
* `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event, or
* `MALFORMED_RESPONSE` when completed tool arguments are invalid. Length-limited
@@ -146,6 +147,7 @@ export async function* toStreamChunks(
contextWindow?: number,
callerSignal?: AbortSignal,
requestedModel?: string,
+ prefixDigest?: string,
): AsyncGenerator {
// pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0
// in stream order), but we track ids per index for tool calls.
@@ -220,7 +222,7 @@ export async function* toStreamChunks(
yield {
type: 'finish',
reason: mapStopReason(event.message, contextWindow),
- replayState: toPiReplayState(event.message, requestedModel),
+ replayState: toPiReplayState(event.message, requestedModel, prefixDigest),
}
return
case 'error':
diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts
index a4b3e85955..fe6d72a2b4 100644
--- a/packages/llm/llm-pi-ai/tests/convert.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts
@@ -670,6 +670,7 @@ describe('toPiContext', () => {
['non-string response id', { ...validReplay, response: { ...validResponse, responseId: 1 } }, 'responseId must be a string'],
['non-string provider thinking level', { ...validReplay, response: { ...validResponse, providerThinkingLevel: 1 } }, 'providerThinkingLevel must be a string'],
['null provider thinking level', { ...validReplay, response: { ...validResponse, providerThinkingLevel: null } }, 'providerThinkingLevel must be a string'],
+ ['non-string prefix digest', { ...validReplay, response: { ...validResponse, prefixDigest: 1 } }, 'prefixDigest must be a string'],
['missing blocks', { response: validResponse }, 'blocks must be an array'],
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
new file mode 100644
index 0000000000..037a966d36
--- /dev/null
+++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
@@ -0,0 +1,178 @@
+/** Replayed Anthropic thinking is bound to the request prefix it was generated under. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import LlmRuntime, { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { Message, ToolCallId } from '@deepseek-ai/dsh-llm'
+import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
+import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
+import { resolveProfiles } from '../src/config.ts'
+import { memoryAuth } from './auth-double.ts'
+import { assemble } from './assemble.ts'
+import { anthropicFrame, closeMockServers, mockServer } from './mock-server.ts'
+
+const contexts: Context[] = []
+afterEach(async () => {
+ await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+ await closeMockServers()
+ vi.unstubAllEnvs()
+})
+
+const tool = { name: 'lookup', description: 'Look up a value.', parameters: { type: 'object', properties: { key: { type: 'number' } } } }
+
+/** One Anthropic response: signed and redacted thinking, then a tool call or a reply. */
+function frames(signature: string, toolUse: boolean): ReturnType[] {
+ const blocks = [
+ { type: 'thinking', thinking: `thinking ${signature}`, signature },
+ { type: 'redacted_thinking', data: `redacted-${signature}` },
+ toolUse ? { type: 'tool_use', id: `toolu_${signature}`, name: 'lookup', input: { key: 1 } } : { type: 'text', text: 'done' },
+ ]
+ return [
+ anthropicFrame({ type: 'message_start', message: {
+ id: `msg_${signature}`, type: 'message', role: 'assistant', model: 'model', content: [],
+ stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 1 },
+ } }),
+ ...blocks.flatMap((content_block, index) => [
+ anthropicFrame({ type: 'content_block_start', index, content_block }),
+ anthropicFrame({ type: 'content_block_stop', index }),
+ ]),
+ anthropicFrame({ type: 'message_delta', delta: { stop_reason: toolUse ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }),
+ anthropicFrame({ type: 'message_stop' }),
+ ]
+}
+
+async function setup(api: 'anthropic-messages' | 'openai-completions', script: Parameters[0], adaptive = true) {
+ vi.stubEnv('PREFIX_BINDING_KEY', 'test-key')
+ const server = await mockServer(script)
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(LlmPiAi, { providers: {
+ route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model' }],
+ ...api === 'anthropic-messages' ? { compat: { forceAdaptiveThinking: adaptive } } : {} },
+ } })
+ const run = async (messages: Message[]) => {
+ const result = await assemble(ctx, { provider: 'route', model: 'model', messages, tools: [tool], system: 'system' })
+ return JSON.parse(JSON.stringify(result.message)) as Message
+ }
+ return { server, run }
+}
+
+function user(text: string): Message {
+ return createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] })
+}
+
+function result(callId: ToolCallId, text: string): Message {
+ return JSON.parse(JSON.stringify(createToolResultMessage({ callId, content: [{ type: 'text', text }], isError: false }))) as Message
+}
+
+function callId(message: Message): ToolCallId {
+ const call = message.content.find(block => block.type === 'tool-call')
+ if (call === undefined) throw new Error('expected a tool call')
+ return call.id
+}
+
+function wire(request: unknown): string {
+ return JSON.stringify((request as { messages: unknown }).messages)
+}
+
+describe('Anthropic thinking prefix binding', () => {
+ it('keeps thinking while its prefix is unchanged and omits it after the prefix is rewritten', async () => {
+ const { server, run } = await setup('anthropic-messages', [
+ { events: frames('first', true) },
+ { events: frames('second', true) },
+ { events: frames('third', false) },
+ { events: frames('fourth', false) },
+ { events: frames('fifth', false) },
+ ])
+ const first = await run([user('Look up the value.')])
+ expect(first.source).toMatchObject({ replayState: { response: { prefixDigest: expect.stringMatching(/^[0-9a-f]{64}$/) } } })
+ const second = await run([user('Look up the value.'), first, result(callId(first), 'large output')])
+ expect(wire(server.requests[1])).toContain('"signature":"first"')
+ expect(wire(server.requests[1])).toContain('redacted-first')
+
+ // Pruning the first tool result rewrites the second response's prefix but not the first one's.
+ const tail = [first, result(callId(first), 'pruned'), second, result(callId(second), 'value')]
+ const third = await run([user('Look up the value.'), ...tail])
+ expect(wire(server.requests[2])).toContain('"signature":"first"')
+ expect(wire(server.requests[2])).not.toContain('"signature":"second"')
+ expect(wire(server.requests[2])).not.toContain('redacted-second')
+ expect(wire(server.requests[2])).toContain('toolu_second')
+
+ // Compaction replaces the head: every retained response loses its thinking, tool calls stay paired.
+ const compacted = [user(''), ...tail, third, user('Continue.')]
+ const fourth = await run(compacted)
+ const request = wire(server.requests[3])
+ for (const stale of ['"signature":"first"', 'redacted-first', '"signature":"second"', '"signature":"third"']) {
+ expect(request).not.toContain(stale)
+ }
+ expect(request).toContain('toolu_first')
+ expect(request).toContain('toolu_second')
+
+ // A response generated on the compacted prefix replays its own thinking.
+ await run([...compacted, fourth, user('Again.')])
+ expect(wire(server.requests[4])).toContain('"signature":"fourth"')
+ expect(wire(server.requests[4])).not.toContain('"signature":"third"')
+ })
+
+ it('treats a changed system prompt as a changed prefix', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }])
+ const first = await run([user('Look up the value.')])
+ const ctx = contexts[0] as Context
+ await assemble(ctx, {
+ provider: 'route', model: 'model', tools: [tool], system: 'changed system',
+ messages: [user('Look up the value.'), first, result(callId(first), 'value')],
+ })
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ expect(wire(server.requests[1])).toContain('toolu_first')
+ })
+
+ it('omits thinking whose replay state records no prefix digest', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }])
+ const first = await run([user('Look up the value.')])
+ const replayState = (first.source as { replayState: { response: Record } }).replayState
+ delete replayState.response['prefixDigest']
+ await run([user('Look up the value.'), first, result(callId(first), 'value')])
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ expect(wire(server.requests[1])).toContain('toolu_first')
+ })
+
+ it('keeps stale thinking only on the open tool round of a manual-thinking model', async () => {
+ const { server, run } = await setup('anthropic-messages', [
+ { events: frames('first', true) },
+ { events: frames('second', true) },
+ { events: frames('third', false) },
+ ], false)
+ const first = await run([user('Look up the value.')])
+ const second = await run([user('Look up the value.'), first, result(callId(first), 'value')])
+ await run([user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')])
+ expect(wire(server.requests[2])).not.toContain('"signature":"first"')
+ expect(wire(server.requests[2])).toContain('"signature":"second"')
+ })
+
+ it('omits stale thinking without an omission observer', async () => {
+ const { run } = await setup('anthropic-messages', [{ events: frames('first', true) }])
+ const first = await run([user('Look up the value.')])
+ const server = await mockServer([{ events: frames('second', false) }])
+ const adapter = new PiAiAdapter({
+ profiles: () => resolveProfiles({ route: {
+ api: 'anthropic-messages', baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none',
+ models: [{ id: 'model' }], compat: { forceAdaptiveThinking: true },
+ } }),
+ resolveApiKey: () => Promise.resolve('test-key'),
+ auth: memoryAuth(),
+ })
+ const messages = [user(''), first, result(callId(first), 'value')]
+ for await (const _chunk of adapter.stream({ provider: 'route', model: 'model', messages, tools: [tool], system: 'system' })) { /* drain */ }
+ expect(wire(server.requests[0])).not.toContain('"signature":"first"')
+ })
+
+ it('records no prefix digest for protocols without prefix binding', async () => {
+ const { run } = await setup('openai-completions', [{ events: [
+ JSON.stringify({ choices: [{ index: 0, delta: { role: 'assistant', content: 'reply' }, finish_reason: null }] }),
+ JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 1 } }),
+ '[DONE]',
+ ] }])
+ const reply = await run([user('Hello.')])
+ expect((reply.source as { replayState: { response: object } }).replayState.response).not.toHaveProperty('prefixDigest')
+ })
+})
From 052c6c9c536aa4b01dbeb14c02f0b1d7f214cbf1 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 01:37:17 +0800
Subject: [PATCH 024/196] test(llm-pi-ai): type the prefix digest matcher
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
index 037a966d36..f3c108c027 100644
--- a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
@@ -85,7 +85,8 @@ describe('Anthropic thinking prefix binding', () => {
{ events: frames('fifth', false) },
])
const first = await run([user('Look up the value.')])
- expect(first.source).toMatchObject({ replayState: { response: { prefixDigest: expect.stringMatching(/^[0-9a-f]{64}$/) } } })
+ const prefixDigest: unknown = expect.stringMatching(/^[0-9a-f]{64}$/)
+ expect(first.source).toMatchObject({ replayState: { response: { prefixDigest } } })
const second = await run([user('Look up the value.'), first, result(callId(first), 'large output')])
expect(wire(server.requests[1])).toContain('"signature":"first"')
expect(wire(server.requests[1])).toContain('redacted-first')
From 5824871706c500d4f15bb0a4be0791c0f7f34efa Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 01:52:38 +0800
Subject: [PATCH 025/196] fix(llm-pi-ai): report thinking omissions on every
Anthropic request
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/src/adapter.ts | 9 +++++----
packages/llm/llm-pi-ai/src/index.ts | 2 +-
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index 0f8c53f25d..dc27de4535 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -129,9 +129,10 @@ export interface PiAiAdapterOptions {
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
/**
- * Observe one request omitting replayed Anthropic thinking whose request
- * prefix changed or was never recorded; stale messages stay omitted, so
- * every later request of the conversation reports them again.
+ * Observe how many replayed assistant messages one request sent without
+ * their Anthropic thinking because the request prefix changed or was never
+ * recorded; called for every request, including with a zero count. Stale
+ * messages stay omitted, so later requests of the conversation count them again.
*/
onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void
}
@@ -465,7 +466,7 @@ export class PiAiAdapter extends LlmAdapter {
},
}, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests)
- if (omitted > 0) this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
+ this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index a671b7229a..9fa3f0c7ac 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -253,7 +253,7 @@ export function apply(ctx: Context, config: Config): void {
)
},
onThinkingOmitted: ({ provider, model, count }) => {
- ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
+ if (count > 0) ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
},
})
// Independent of the route set: signing in is what makes a route worth
From 018689a59b9ae52930622c8ea6aa96672074217c Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 02:38:47 +0800
Subject: [PATCH 026/196] fix(llm-pi-ai): narrow Anthropic thinking omission to
signed blocks
Keep stale thinking on the open tool round only when the request enables
manual thinking and the round continues with tool results; omit only
signed or redacted thinking; report omissions only when there are any;
own PrefixDigests in prefix-binding; document consumer gaps under Known
Limitations.
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/README.i18n.yaml | 8 +--
packages/llm/llm-pi-ai/README.md | 4 +-
packages/llm/llm-pi-ai/README.zh.md | 4 +-
packages/llm/llm-pi-ai/src/adapter.ts | 17 +++--
packages/llm/llm-pi-ai/src/context.ts | 2 +-
packages/llm/llm-pi-ai/src/index.ts | 2 +-
packages/llm/llm-pi-ai/src/prefix-binding.ts | 71 ++++++++++++-------
packages/llm/llm-pi-ai/src/replay.ts | 4 +-
.../llm-pi-ai/tests/prefix-binding.spec.ts | 58 +++++++++++++--
9 files changed, 119 insertions(+), 51 deletions(-)
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index a1dbdb1b1d..7c68e687ce 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -60,8 +60,8 @@
en: 477d277e9ac35c8f
zh: 96870ca333fb6510
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary:
- en: 507ceb2856849646
- zh: 4f54925b52a1c190
+ en: e68f92d78150d341
+ zh: 45850c739a7329d3
/deepseek-ai-dsh-llm-pi-ai/further-exploration:
en: a2a2ccdeae59d408
zh: c47ee091aa90ad91
@@ -93,8 +93,8 @@
en: b6bd570c0159ba08
zh: 7d2d91eea6a8d830
/deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work:
- en: 1c54b949c81df84f
- zh: b894d01aa3e6afc1
+ en: 1664f55c85478298
+ zh: 1fa4e07e49cc6cb6
/deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note:
en: 3678dd0f6642c2a5
zh: 75cbdd064cc64282
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 68cf9c77d3..b70d1b12b5 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -192,7 +192,7 @@ The `pre-commit` hook runs `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and uses it to retain same-model signatures; provider-reported response models remain diagnostic metadata. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks. Protocol conversion operates on request-local copies. Switching from A to B and back to A preserves the stored content, including empty text blocks, tool ids, original block order, and native replay metadata; requests to a different provider, API, or model omit unsupported signatures and reasoning blocks. Degraded replay without native metadata also omits reasoning. Valid native replay retains the stored reasoning when the original model resumes.
-Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's thinking and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text and tool calls remain, and omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues keeps its thinking. The digest covers the pi-ai context only; switching authentication or compat settings that change the wire request mid-conversation is not detected.
+Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's signed and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text, tool calls, and unsigned thinking remain; a response that held only signed thinking is no longer sent. Omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when a request enables thinking and the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues with tool results keeps its thinking.
@@ -268,6 +268,8 @@ These limits define where the adapter stops and future work begins. They are cur
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers.
- **Retry policy is provider-owned, not an SDK retry** — pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
- **Streamed JSON tool-call arguments avoid per-delta reparsing** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@1.3.0.patch`](../../../patches/@earendil-works__pi-ai@1.3.0.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Completion events supply parsed arguments; Responses may parse at both argument completion and output-item completion. This adapter reads only delta strings and finalized arguments. Re-apply or retire the patch on every pi-ai upgrade.
+- **Stale Anthropic thinking is omitted even where the endpoint would accept it** — prefix checks depend on the model, the account, and the request, which the adapter cannot observe, so every prefix change and every replay state without `prefixDigest` costs the model that reasoning, and Anthropic regenerates it as output tokens. The digest covers the pi-ai context objects, not the wire body: switching authentication or wire-changing compat settings mid-conversation is not detected, a pi-ai upgrade that changes Anthropic message serialization requires a new `DIGEST_SEED` in [`src/prefix-binding.ts`](src/prefix-binding.ts), and any change to how this package builds those objects drops recorded thinking once.
+- **A manual-thinking tool round that spans a prefix change can still be rejected** — its open assistant message must keep its stale thinking to satisfy manual thinking, so an endpoint that enforces the prefix check rejects each request until a user message ends the round. Models that enforce the check use adaptive thinking in the installed catalog.
- **Gemini tool-call thought signatures are patched into Chat Completions** — the same patch reads `tool_calls[].extra_content.google.thought_signature` from a streamed OpenAI-compatible response into the tool call's replay signature and sends it back as `extra_content` when the same model resumes (upstream [earendil-works/pi#10157](https://github.com/earendil-works/pi/issues/10157)); unpatched, Gemini rejects the next step of a tool-calling turn with HTTP 400. Encrypted OpenRouter reasoning details keep their `reasoning_details` replay.
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index 620fd428f6..69bcd52afa 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -192,7 +192,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并用它保留同模型签名;提供方报告的响应模型仅作为诊断元数据。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。 协议转换仅作用于请求副本。从 A 切换到 B 再切回 A 时,存储的内容保持完整,包括空文本块、工具 id、原始块顺序和原生回放元数据;发给不同提供方、API 或模型的请求会省略不受支持的签名和推理块。缺少原生元数据的降级回放也会省略推理。切回原模型时,有效的原生回放会保留已存储的推理。
-Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本和工具调用保留,已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当模型的 compat 未设置 `forceAdaptiveThinking` 时,请求所延续的工具轮次对应的 assistant 消息会保留其 thinking。摘要只覆盖 pi-ai 上下文;对话中途切换认证或 compat 设置而改变线上请求时,不会被检测到。
+Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的已签名 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本、工具调用和未签名 thinking 保留;只含已签名 thinking 的响应不再发送。已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当请求启用 thinking 且模型的 compat 未设置 `forceAdaptiveThinking` 时,请求以工具结果延续的工具轮次对应的 assistant 消息会保留其 thinking。
@@ -268,6 +268,8 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish
- **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。
- **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent(智能体)步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。
- **流式 JSON 工具调用参数避免逐 delta 重复解析**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@1.3.0.patch`](../../../patches/@earendil-works__pi-ai@1.3.0.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。完成事件提供解析后的参数;Responses 可能在参数完成和输出项完成时分别解析。本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。
+- **即使端点会接受,过期的 Anthropic thinking 也会被省略**——前缀检查取决于模型、账户和请求,而适配器无法观测这些因素,因此每次前缀变化以及每个不含 `prefixDigest` 的回放状态都会让模型失去这部分推理,Anthropic 会以输出 token 重新生成它。摘要覆盖 pi-ai 上下文对象,而不是线上请求体:对话中途切换认证或会改变线上请求的 compat 设置不会被检测到;改变 Anthropic 消息序列化的 pi-ai 升级需要在 [`src/prefix-binding.ts`](src/prefix-binding.ts) 中使用新的 `DIGEST_SEED`;本包构建这些对象的方式发生任何变化,都会使已记录的 thinking 被丢弃一次。
+- **跨越前缀变化的手动思考工具轮次仍可能被拒绝**——其未结束的 assistant 消息必须保留过期的 thinking 以满足手动思考要求,因此执行前缀检查的端点会拒绝每个请求,直到一条用户消息结束该轮次。在已安装的目录中,执行此检查的模型使用自适应思考。
- **Chat Completions 中的 Gemini 工具调用思考签名由补丁保留**——同一补丁把 OpenAI 兼容流式响应里的 `tool_calls[].extra_content.google.thought_signature` 读入工具调用的重放签名,并在同一模型继续时以 `extra_content` 发回(上游 [earendil-works/pi#10157](https://github.com/earendil-works/pi/issues/10157));未打补丁时,Gemini 会以 HTTP 400 拒绝工具调用轮次的下一步。OpenRouter 的加密推理详情仍通过 `reasoning_details` 重放。
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index dc27de4535..be41fc2242 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -63,7 +63,7 @@ import { requestImageTarget, toPiContext } from './context.ts'
import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts'
import { toStreamChunks } from './stream.ts'
import { bindThinkingToPrefix } from './prefix-binding.ts'
-import type { PrefixDigests } from './replay.ts'
+import type { PrefixDigests } from './prefix-binding.ts'
import type { PiAiRequestHeaderContext } from './types.ts'
/**
@@ -129,10 +129,10 @@ export interface PiAiAdapterOptions {
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
/**
- * Observe how many replayed assistant messages one request sent without
- * their Anthropic thinking because the request prefix changed or was never
- * recorded; called for every request, including with a zero count. Stale
- * messages stay omitted, so later requests of the conversation count them again.
+ * Observe one request that sent replayed assistant messages without their
+ * signed Anthropic thinking because the request prefix changed or was never
+ * recorded. Stale messages stay omitted, so later requests of the
+ * conversation report them again.
*/
onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void
}
@@ -465,8 +465,11 @@ export class PiAiAdapter extends LlmAdapter {
maxBytes: profile.requestImageMaxBytes,
},
}, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
- const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests)
- this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
+ const manualThinking = model.reasoning && reasoning !== undefined && reasoning !== 'off'
+ && (model as Model<'anthropic-messages'>).compat?.forceAdaptiveThinking !== true
+ const { context, digest } = bindThinkingToPrefix(converted, model.api, prefixDigests, manualThinking, (count) => {
+ this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count })
+ })
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts
index 3d5f9d91b7..91e9e1c35f 100644
--- a/packages/llm/llm-pi-ai/src/context.ts
+++ b/packages/llm/llm-pi-ai/src/context.ts
@@ -16,7 +16,7 @@ import type {
} from '@deepseek-ai/dsh-attachment'
import type { Context as PiContext, ImageContent, Message as PiMessage, SystemMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
import { toPiAssistant } from './replay.ts'
-import type { PrefixDigests } from './replay.ts'
+import type { PrefixDigests } from './prefix-binding.ts'
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts'
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index 9fa3f0c7ac..a671b7229a 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -253,7 +253,7 @@ export function apply(ctx: Context, config: Config): void {
)
},
onThinkingOmitted: ({ provider, model, count }) => {
- if (count > 0) ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
+ ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
},
})
// Independent of the route set: signing in is what makes a route worth
diff --git a/packages/llm/llm-pi-ai/src/prefix-binding.ts b/packages/llm/llm-pi-ai/src/prefix-binding.ts
index d5ed0451df..9ca530c339 100644
--- a/packages/llm/llm-pi-ai/src/prefix-binding.ts
+++ b/packages/llm/llm-pi-ai/src/prefix-binding.ts
@@ -6,44 +6,55 @@
* and tool-result pruning rewrite that prefix, so a retained signed block
* becomes invalid and endpoints that enforce the check reject the whole
* request. Each Anthropic Messages response records a digest of the context it
- * was generated from; a later request replays its thinking only while the
- * reconstructed prefix still has the same digest. Replay state without a
+ * was generated from; a later request replays its signed thinking only while
+ * the reconstructed prefix still has the same digest. Replay state without a
* digest predates this check and is treated as stale.
*
- * Manual (non-adaptive) thinking requires the final assistant turn of a tool
- * round to start with a thinking block, so that message keeps its thinking
- * when the model does not force adaptive thinking.
+ * The digest covers the pi-ai context objects this package builds, including
+ * fields Anthropic never receives. Any change to their construction
+ * invalidates every recorded digest once; a pi-ai upgrade that changes how
+ * Anthropic messages are serialized without changing those objects requires a
+ * new {@link DIGEST_SEED}.
*
* @module dsh-llm-pi-ai/prefix-binding
*/
import { createHash } from 'node:crypto'
-import type { Api, AssistantMessage, Context as PiContext, Model } from '@earendil-works/pi-ai'
-import type { PrefixDigests } from './replay.ts'
+import type { Api, AssistantMessage, Context as PiContext } from '@earendil-works/pi-ai'
+
+/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */
+export type PrefixDigests = Map
/** Wire protocol whose thinking signatures bind to the request prefix. */
const PREFIX_BOUND_API = 'anthropic-messages'
-/** Seed of every digest chain; changing the digest input requires a new seed. */
+/** Seed of every digest chain; changing the digest input or its serialization requires a new seed. */
const DIGEST_SEED = 'dsh-llm-pi-ai/prefix-binding/v1'
function chain(previous: string, value: unknown): string {
return createHash('sha256').update(previous).update('\0').update(JSON.stringify(value)).digest('hex')
}
-/** Index of the assistant message whose tool round the request continues, or -1. */
+/** Index of the assistant message whose tool round the request continues with tool results, or -1. */
function openToolRoundAssistant(messages: PiContext['messages']): number {
+ let results = 0
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as PiContext['messages'][number]
- if (message.role === 'assistant') return index
+ if (message.role === 'assistant') return results > 0 ? index : -1
if (message.role !== 'toolResult') return -1
+ results++
}
return -1
}
-/** Copy of an assistant message without its thinking blocks. */
-function withoutThinking(message: AssistantMessage): AssistantMessage {
- return { ...message, content: message.content.filter(block => block.type !== 'thinking') }
+/** Whether a thinking block carries a signature or redacted payload that binds it to its prefix. */
+function signed(block: AssistantMessage['content'][number]): boolean {
+ return block.type === 'thinking' && (block.redacted === true || (block.thinkingSignature ?? '').length > 0)
+}
+
+/** Copy of an assistant message without its signed thinking blocks. */
+function withoutSignedThinking(message: AssistantMessage): AssistantMessage {
+ return { ...message, content: message.content.filter(block => !signed(block)) }
}
/** Context whose stale thinking is removed, with the digest of the complete request. */
@@ -52,27 +63,31 @@ export interface PrefixBoundContext {
context: PiContext
/** Digest of the sent context, recorded with the response; absent for other protocols. */
digest?: string
- /** Number of replayed assistant messages whose thinking was removed. */
- omitted: number
}
/**
- * Remove thinking from replayed Anthropic Messages assistant messages whose
- * request prefix changed since they were generated, or whose replay state
- * records no prefix digest.
+ * Remove signed thinking from replayed Anthropic Messages assistant messages
+ * whose request prefix changed since they were generated, or whose replay
+ * state records no prefix digest. Manual thinking requires the final
+ * assistant turn of a tool round to start with a thinking block, so with
+ * `manualThinking` the assistant message whose tool round the request
+ * continues keeps its thinking.
* @param context - converted request context.
- * @param model - requested model.
+ * @param api - wire protocol of the requested model.
* @param prefixDigests - recorded digests the context conversion collected.
- * @returns the context to send, its digest for Anthropic Messages models, and the omission count.
+ * @param manualThinking - whether the request enables budget-based rather than adaptive thinking.
+ * @param onOmitted - called once with the number of messages whose thinking was removed, when there are any.
+ * @returns the context to send and, for Anthropic Messages, its digest.
*/
export function bindThinkingToPrefix(
context: PiContext,
- model: Model,
+ api: Api,
prefixDigests: PrefixDigests,
+ manualThinking: boolean,
+ onOmitted: (count: number) => void,
): PrefixBoundContext {
- if (model.api !== PREFIX_BOUND_API) return { context, omitted: 0 }
- const adaptive = (model as Model).compat?.forceAdaptiveThinking === true
- const kept = adaptive ? -1 : openToolRoundAssistant(context.messages)
+ if (api !== PREFIX_BOUND_API) return { context }
+ const kept = manualThinking ? openToolRoundAssistant(context.messages) : -1
let digest = chain(DIGEST_SEED, { systemPrompt: context.systemPrompt ?? null, tools: context.tools ?? null })
let messages: PiContext['messages'] | undefined
let omitted = 0
@@ -80,14 +95,16 @@ export function bindThinkingToPrefix(
let message = original
if (
message.role === 'assistant' && message.api === PREFIX_BOUND_API && index !== kept
- && prefixDigests.get(message) !== digest && message.content.some(block => block.type === 'thinking')
+ && prefixDigests.get(message) !== digest && message.content.some(signed)
) {
- message = withoutThinking(message)
+ message = withoutSignedThinking(message)
messages ??= context.messages.slice()
messages[index] = message
omitted++
}
digest = chain(digest, message)
}
- return { context: messages === undefined ? context : { ...context, messages }, digest, omitted }
+ if (messages === undefined) return { context, digest }
+ onOmitted(omitted)
+ return { context: { ...context, messages }, digest }
}
diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts
index 8e5bcd3ca3..ba1be42e14 100644
--- a/packages/llm/llm-pi-ai/src/replay.ts
+++ b/packages/llm/llm-pi-ai/src/replay.ts
@@ -11,6 +11,7 @@
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage as HarnessAssistantMessage, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
import type { Api, AssistantMessage, ToolCall, Usage as PiUsage } from '@earendil-works/pi-ai'
+import type { PrefixDigests } from './prefix-binding.ts'
/** Per-block half of the pi-ai replay envelope, one entry per content block. */
export type PiAiReplayBlock =
@@ -67,9 +68,6 @@ function emptyPiUsage(): PiUsage {
}
}
-/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */
-export type PrefixDigests = Map
-
/**
* Project a successful pi-ai response into the minimal durable replay state.
* The per-block half is index-aligned with the streamed blocks (pi-ai content
diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
index f3c108c027..ac003107a2 100644
--- a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
@@ -21,11 +21,14 @@ const tool = { name: 'lookup', description: 'Look up a value.', parameters: { ty
/** One Anthropic response: signed and redacted thinking, then a tool call or a reply. */
function frames(signature: string, toolUse: boolean): ReturnType[] {
- const blocks = [
+ return response(signature, [
{ type: 'thinking', thinking: `thinking ${signature}`, signature },
{ type: 'redacted_thinking', data: `redacted-${signature}` },
toolUse ? { type: 'tool_use', id: `toolu_${signature}`, name: 'lookup', input: { key: 1 } } : { type: 'text', text: 'done' },
- ]
+ ], toolUse)
+}
+
+function response(signature: string, blocks: Record[], toolUse = false): ReturnType[] {
return [
anthropicFrame({ type: 'message_start', message: {
id: `msg_${signature}`, type: 'message', role: 'assistant', model: 'model', content: [],
@@ -40,14 +43,15 @@ function frames(signature: string, toolUse: boolean): ReturnType[0], adaptive = true) {
+async function setup(api: 'anthropic-messages' | 'openai-completions', script: Parameters[0], adaptive = true, manual = false) {
vi.stubEnv('PREFIX_BINDING_KEY', 'test-key')
const server = await mockServer(script)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmPiAi, { providers: {
- route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model' }],
+ route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model', ...manual ? { reasoningEfforts: { high: 'high' } } : {} }],
+ ...manual ? { reasoning: 'high' as const } : {},
...api === 'anthropic-messages' ? { compat: { forceAdaptiveThinking: adaptive } } : {} },
} })
const run = async (messages: Message[]) => {
@@ -137,17 +141,59 @@ describe('Anthropic thinking prefix binding', () => {
expect(wire(server.requests[1])).toContain('toolu_first')
})
- it('keeps stale thinking only on the open tool round of a manual-thinking model', async () => {
+ it('keeps stale thinking only on the open tool round of a manual-thinking request', async () => {
const { server, run } = await setup('anthropic-messages', [
{ events: frames('first', true) },
{ events: frames('second', true) },
{ events: frames('third', false) },
- ], false)
+ { events: frames('fourth', false) },
+ ], false, true)
const first = await run([user('Look up the value.')])
const second = await run([user('Look up the value.'), first, result(callId(first), 'value')])
await run([user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')])
expect(wire(server.requests[2])).not.toContain('"signature":"first"')
expect(wire(server.requests[2])).toContain('"signature":"second"')
+ expect(server.requests[2]).toMatchObject({ thinking: { type: 'enabled' } })
+
+ // Once a user message closes the round, the formerly kept thinking is stale too.
+ const compacted = [user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')]
+ await run([...compacted, user('Continue.')])
+ expect(wire(server.requests[3])).not.toContain('"signature":"second"')
+ })
+
+ it('sends an empty manual-thinking history unchanged', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', false) }], false, true)
+ await run([])
+ expect(server.requests[0]).toMatchObject({ messages: [] })
+ })
+
+ it('omits stale manual thinking from a final assistant message without tool results', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', false) }, { events: frames('second', false) }], false, true)
+ const first = await run([user('Answer.')])
+ await run([user(''), first])
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ })
+
+ it('omits stale thinking of the open tool round when the request disables thinking', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }], false)
+ const first = await run([user('Look up the value.')])
+ await run([user(''), first, result(callId(first), 'value')])
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ })
+
+ it('drops a thinking-only message and keeps unsigned thinking', async () => {
+ const { server, run } = await setup('anthropic-messages', [
+ { events: response('only', [{ type: 'thinking', thinking: 'thinking only', signature: 'only' }]) },
+ { events: response('unsigned', [{ type: 'thinking', thinking: 'unsigned thinking', signature: '' }, { type: 'text', text: 'reply' }]) },
+ { events: frames('third', false) },
+ ])
+ const only = await run([user('Think.')])
+ const unsigned = await run([user('Think.'), only, user('Answer.')])
+ await run([user(''), only, user('Answer.'), unsigned, user('Again.')])
+ const messages = (server.requests[2] as { messages: { role: string }[] }).messages
+ expect(messages.map(message => message.role)).toEqual(['user', 'user', 'assistant', 'user'])
+ expect(wire(server.requests[2])).not.toContain('thinking only')
+ expect(wire(server.requests[2])).toContain('unsigned thinking')
})
it('omits stale thinking without an omission observer', async () => {
From 74a8d0486e29b1c66e3828e61baade85ae9c66b0 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 12:03:18 +0800
Subject: [PATCH 027/196] fix(subprocess): recheck a known live group member
instead of rescanning /proc
The Linux fallback owner polls its process group every 15 ms after the
direct child exits. Remember the live member found by the last scan and
read only its stat on later polls, so a long-lived background member no
longer costs a full synchronous /proc enumeration per poll.
Fixes #7600
---
.../subprocess-local/src/process-inspector.ts | 23 +++++++++-----
.../subprocess/subprocess-local/src/spawn.ts | 17 ++++++----
.../tests/process-inspector.spec.ts | 31 ++++++++++++++++---
.../subprocess-local/tests/spawn.spec.ts | 25 +++++++++++++--
4 files changed, 76 insertions(+), 20 deletions(-)
diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts
index 297843584a..f525d45bc0 100644
--- a/packages/subprocess/subprocess-local/src/process-inspector.ts
+++ b/packages/subprocess/subprocess-local/src/process-inspector.ts
@@ -196,18 +196,27 @@ function readLinuxTerminalDevice(
}
}
+function isLiveMember(stat: ProcStat | undefined, processGroupId: number): boolean {
+ return stat?.pgrp === processGroupId && !/^[ZXx]$/.test(stat.state)
+}
+
/**
- * Report whether a Linux process group has an executing member. `false`
- * means the group contains only zombie/dead entries; `undefined` means the
- * process table could not prove either outcome.
+ * Find an executing member of a Linux process group. `false` means the group
+ * contains only zombie/dead entries; `undefined` means the process table could
+ * not prove either outcome. A still-executing `knownMember` is returned from
+ * its own stat entry without enumerating `/proc`, so repeated polls of a
+ * long-lived group cost one file read instead of one per host process.
* @param processGroupId - POSIX process-group id to inspect.
+ * @param knownMember - member PID returned by an earlier call, if any.
* @param internals - injectable process-table operations.
- * @returns Live-member presence, or `undefined` when unavailable/absent.
+ * @returns An executing member's PID, `false`, or `undefined` when unavailable/absent.
*/
-export function linuxProcessGroupHasLiveMembers(
+export function linuxProcessGroupLiveMember(
processGroupId: number,
+ knownMember?: number,
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
-): boolean | undefined {
+): number | false | undefined {
+ if (knownMember !== undefined && isLiveMember(readLinuxStat(internals, knownMember), processGroupId)) return knownMember
let entries: string[]
try {
entries = internals.readDir('/proc')
@@ -220,7 +229,7 @@ export function linuxProcessGroupHasLiveMembers(
const stat = readLinuxStat(internals, Number(entry))
if (stat?.pgrp !== processGroupId) continue
matched = true
- if (!/^[ZXx]$/.test(stat.state)) return true
+ if (isLiveMember(stat, processGroupId)) return stat.pid
}
return matched ? false : undefined
}
diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts
index ed9baa2020..19015e1593 100644
--- a/packages/subprocess/subprocess-local/src/spawn.ts
+++ b/packages/subprocess/subprocess-local/src/spawn.ts
@@ -24,7 +24,7 @@ import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts
import { waitWithAbort } from './managed-owner.ts'
import { controlEnvironment, controlPipe } from './control-spawn.ts'
import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
-import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
+import { linuxProcessGroupLiveMember } from './process-inspector.ts'
import { OutputCollector, prepareManagedProcessBinding, type SpillFailureReporter } from './output.ts'
type SpawnProcess = (
@@ -65,8 +65,8 @@ export interface SpawnInternals {
taskkill?: (pid: number) => void
/** Host platform override for signalling decisions. */
platform?: NodeJS.Platform
- /** Linux process-group member probe (defaults to `/proc` inspection). */
- linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
+ /** Linux process-group live-member probe (defaults to `/proc` inspection); receives the member PID it last returned. */
+ linuxProcessGroupLiveMember?: (processGroupId: number, knownMember: number | undefined) => number | false | undefined
}
/**
@@ -204,12 +204,13 @@ function fallbackOwner(
pid: number | undefined,
child: ChildProcess,
taskkill: (pid: number) => void,
- linuxGroupHasLiveMembers: (processGroupId: number) => boolean | undefined,
+ linuxGroupLiveMember: (processGroupId: number, knownMember: number | undefined) => number | false | undefined,
direct: Promise,
): BoundProcessOwner {
let stopped = false
let directSettled = false
let observation: Promise | undefined
+ let liveMember: number | undefined
void direct.then(
() => { directSettled = true },
() => { directSettled = true },
@@ -220,7 +221,11 @@ function fallbackOwner(
if (platform === 'win32') return child.exitCode === null && child.signalCode === null
try {
process.kill(-pid, 0)
- if (directSettled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false
+ if (directSettled && platform === 'linux') {
+ const member = linuxGroupLiveMember(pid, liveMember)
+ if (member === false) return false
+ liveMember = member
+ }
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -478,7 +483,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
pid,
child,
internals.taskkill ?? taskkillProcessTree,
- internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers,
+ internals.linuxProcessGroupLiveMember ?? linuxProcessGroupLiveMember,
direct,
)
return bindManagedProcess(spec, {
diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts
index 039db91da4..95e8b9c55a 100644
--- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts
+++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
createProcessInspector,
- linuxProcessGroupHasLiveMembers,
+ linuxProcessGroupLiveMember,
parseProcStat,
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
@@ -115,17 +115,38 @@ describe('Linux process inspector', () => {
it('treats zombie-only process groups as quiescent and fails closed when unobservable', () => {
const fake = fakeInternals()
- expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBeUndefined()
+ expect(linuxProcessGroupLiveMember(77, undefined, fake.internals)).toBeUndefined()
fake.dirs.set('/proc', ['self', '10', '11', '12'])
fake.files.set('/proc/10/stat', stat(10, 77, 10, -1, '500', 1, 'Z'))
fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501', 1, 'X'))
fake.files.set('/proc/12/stat', stat(12, 88, 12, -1, '502'))
- expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(false)
- expect(linuxProcessGroupHasLiveMembers(99, fake.internals)).toBeUndefined()
+ expect(linuxProcessGroupLiveMember(77, undefined, fake.internals)).toBe(false)
+ expect(linuxProcessGroupLiveMember(99, undefined, fake.internals)).toBeUndefined()
fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501'))
- expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(true)
+ expect(linuxProcessGroupLiveMember(77, undefined, fake.internals)).toBe(11)
+ })
+
+ it('rechecks a known live group member without enumerating /proc', () => {
+ const fake = fakeInternals()
+ let scans = 0
+ fake.internals.readDir = (path) => {
+ scans += 1
+ return fake.dirs.get(path) ?? []
+ }
+ fake.dirs.set('/proc', ['10', '11', '12'])
+ fake.files.set('/proc/10/stat', stat(10, 77, 10, -1, '500', 1, 'Z'))
+ fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501'))
+ fake.files.set('/proc/12/stat', stat(12, 77, 10, -1, '502'))
+ expect(linuxProcessGroupLiveMember(77, 11, fake.internals)).toBe(11)
+ expect(scans).toBe(0)
+
+ fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501', 1, 'Z'))
+ expect(linuxProcessGroupLiveMember(77, 11, fake.internals)).toBe(12)
+ fake.files.set('/proc/12/stat', stat(12, 88, 12, -1, '502'))
+ expect(linuxProcessGroupLiveMember(77, 12, fake.internals)).toBe(false)
+ expect(scans).toBe(2)
})
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts
index dc77e0b429..2162239335 100644
--- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts
+++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts
@@ -390,7 +390,7 @@ describe('spawnSubprocess', () => {
const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`)
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), {
platform: 'linux',
- linuxProcessGroupHasLiveMembers: () => false,
+ linuxProcessGroupLiveMember: () => false,
})
const descendant = await waitForPidFile(pidFile)
try {
@@ -404,6 +404,27 @@ describe('spawnSubprocess', () => {
}
})
+ it.skipIf(process.platform === 'win32')('passes the last live Linux group member back to the next probe', async () => {
+ const pidFile = join(spillDir, `known-member-${Date.now()}.pid`)
+ const known: Array