Browse Source

Merge master into session format migration

Tianyi Cui 1 month ago
parent
commit
91e5f8838e

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md
+2026-09-01-streamed-tool-call-identity.md: c52f39b735199270d65ed30a388333a217003237
+2026-09-01-streamed-tool-call-identity.zh.md: 9a7ffb6343870a08e06507408e007ad94fbad178

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.md

@@ -0,0 +1,33 @@
+# Agent Note: Streamed tool-call identity survives empty continuation deltas
+
+Status: implemented
+
+English | [中文](2026-09-01-streamed-tool-call-identity.zh.md)
+
+## Problem
+
+The DeepSeek SSE translator assigned `id` and `name` on every tool-call delta that carried the field, so a continuation delta repeating either as an empty string erased the identity established by the call's first delta. The assembled block reached the loop with an empty name, which the tool registry refuses as `unknown tool ""`, leaving the affected models unable to run any tool. Gateways that fill those fields with `null` erased the identity the same way, and `WireToolCallDelta` declared both as `string | undefined`, keeping the observed `null` out of the compiler's reach.
+
+The empty identity outlived the turn. `appendToolCall` and `appendToolResult` write the block's id verbatim and no write path validates it, while `adoptSessionEvent` refuses a `tool/result` whose `callId` is empty, so the persistence coordinator wrapped that refusal in `SessionPersistenceCorruptionError`. A session that recorded one such call was writable and no longer loadable.
+
+## Decision
+
+`acceptIdentity` accepts only a non-empty string for a tool call's `id` and `name`; `undefined`, `null`, `''`, and any non-string leave the established value in place. The assignment set only narrows, so no input reaches a worse outcome than before. `WireToolCallDelta` widens `id`, `function.name`, and `function.arguments` to admit `null`, putting the values gateways actually send into the type system and making the runtime guard load-bearing rather than speculative.
+
+## Alternatives considered
+
+**Concatenate `id` and `name` across deltas.** Rejected: they are identity, not accumulation. Concatenation produces `Globnull` against a gateway that sends `null`, and a doubled name against one that repeats a non-empty value.
+
+**Refuse a conflicting non-empty identity mid-stream.** Deferred: a gateway that fragments a long tool name would be refused for it, and no observed provider re-sends a different non-empty identity within one call index.
+
+**Refuse a response whose tool call never receives an identity.** Deferred. It requires a new failure code, a change to the default retryable set, and a `[DONE]` gate that must not override the finish reason a provider already sent — cost and risk that the reported defect does not carry. The lenient wire it guards against is hypothetical: no report describes a stream that omits identity entirely.
+
+**Relax the session reader's empty-`callId` refusal.** Rejected: an empty `callId` cannot be paired back to the provider on the next request, so accepting it moves the failure into the model request. That refusal is the durable-boundary gate; the producer was the defect.
+
+## Consequences
+
+A continuation delta repeating identity empty or null is inert, so a call keeps the identity its first delta established, and the reported path to `unknown tool ""` and an unreadable session is closed. A stream that never carries identity at all still assembles an empty one, exactly as before; that path and the recovery of sessions already holding an empty `callId` are outside this change.
+
+## Testing
+
+`translate.spec.ts` covers empty and null continuation deltas, a repeated identical identity, and parallel calls holding separate identities under empty continuations. The existing cases for a wire that omits identity entirely keep their recorded empty-identity output.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-01-streamed-tool-call-identity.zh.md

@@ -0,0 +1,33 @@
+# Agent Note:流式工具调用身份不被空续传分片抹除
+
+Status: implemented
+
+[English](2026-09-01-streamed-tool-call-identity.md) | 中文
+
+## 问题
+
+DeepSeek SSE 翻译器对每个携带该字段的工具调用分片都直接赋值 `id` 与 `name`,因此续传分片把其中任一字段重复发送为空串时,会抹掉该调用首个分片已建立的身份。组装出的块带着空名字进入循环,工具注册表以 `unknown tool ""` 拒绝它,受影响的模型上任何工具都跑不起来。把这些字段填成 `null` 的网关会造成同样的抹除,而 `WireToolCallDelta` 把两者都声明为 `string | undefined`,让实际观察到的 `null` 落在编译器视野之外。
+
+空身份还会活过本轮。`appendToolCall` 与 `appendToolResult` 原样写入块的 id 且没有任何写入路径校验它,而 `adoptSessionEvent` 拒绝 `callId` 为空的 `tool/result`,持久化协调器于是把该拒绝包装成 `SessionPersistenceCorruptionError`。记录过一次这种调用的会话可写但不再可读。
+
+## 决定
+
+`acceptIdentity` 对工具调用的 `id` 与 `name` 只接受非空字符串;`undefined`、`null`、`''` 以及任何非字符串都保留已建立的值。会触发赋值的输入集合只减不增,因此没有任何输入会比改动前更差。`WireToolCallDelta` 把 `id`、`function.name` 与 `function.arguments` 放宽到允许 `null`,使网关实际发送的值进入类型系统,运行时守卫因此是承重的而非臆测的。
+
+## 考虑过的替代方案
+
+**跨分片拼接 `id` 与 `name`。** 否决:它们是身份而非累积。面对发送 `null` 的网关,拼接产生 `Globnull`;面对重复发送非空值的网关,产生重复的名字。
+
+**流中途拒绝冲突的非空身份。** 推迟:分片发送长工具名的网关会因此被拒,且没有观察到任何提供方在同一个调用 index 内改发不同的非空身份。
+
+**拒绝始终未获得身份的响应。** 推迟。它需要新增失败 code、改动默认可重试集,还需要一个不得覆盖提供方已给出终止原因的 `[DONE]` 闸门——这些代价与风险,已报告的缺陷并不需要承担。它所防的宽松线上格式是假想的:没有任何报告描述过完全不发送身份的流。
+
+**放宽会话读取端对空 `callId` 的拒绝。** 否决:空 `callId` 无法在下一次请求中与提供方配对,接受它只是把失败推进模型请求。该拒绝是持久化边界的闸门;缺陷在生产方。
+
+## 后果
+
+重复发送空或 null 身份的续传分片不产生作用,调用因此保有其首个分片建立的身份,通往 `unknown tool ""` 与不可读会话的已报告路径就此切断。完全不携带身份的流仍会组装出空身份,与改动前一致;该路径以及已经写入空 `callId` 的会话恢复都不在本次改动范围内。
+
+## 测试
+
+`translate.spec.ts` 覆盖空与 null 续传分片、重复的相同身份,以及空续传下并行调用各自保有身份。原有那些描述完全不发送身份的线上格式的用例,保留其记录的空身份输出。

+ 2 - 2
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md
-2026-08-31-pr-opened-issue-start-dates.md: f8976b8b0499aa5c68c9637e8571805b78ce6d48
-2026-08-31-pr-opened-issue-start-dates.zh.md: 3142bad5007cbbdd27e1f564bc3ccec9101d3d79
+2026-08-31-pr-opened-issue-start-dates.md: 49756d9960a7616993e4513c20c990e5cfba167e
+2026-08-31-pr-opened-issue-start-dates.zh.md: 752533251be559cb4fe82f619553872bbcd748d1

+ 8 - 6
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md

@@ -6,26 +6,28 @@ English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
 
 ## Problem
 
-The Issue Project records planned work in a `Start date` field, but adding or linking an Issue does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
+The organization-level `Start date` Issue field records when work begins, but adding an Issue to the Issue Project or linking it from a pull request does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
 
 Updating the field on every pull-request event would assign dates to existing work after edits, pushes, or reopenings. Replacing an existing date would also discard a manually planned date or a date recorded by an earlier pull request.
 
 ## Decision
 
-The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured Date field only when the current value is empty.
+The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured organization Issue Date field only when the current value is empty.
 
-The configuration names the Project field and time zone. Missing configuration fails when the policy module loads; a missing field, a non-Date field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
+The configuration names the field exposed in the Project and the time zone. The Project field must resolve to an organization Issue Date field; the workflow reads its Issue value and updates it through `updateIssueFieldValue`. Missing configuration fails when the policy module loads; a missing field, a non-Date or Project-local field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
 
 [Event-directed PR review status commands](2026-08-10-event-directed-pr-review-status.md) continue to own Status transitions. Date initialization includes resolving and informational Issue references, runs for Draft and automated pull requests, and does not depend on PR policy enforcement.
 
 ## Verification
 
-[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the GraphQL mutation variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
+[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, Issue-field discovery, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the `updateIssueFieldValue` variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
 
 ## Alternatives considered
 
 **Use a built-in Project workflow.** The built-in workflows own fixed Project item and Status transitions; the repository workflow already owns authenticated GraphQL mutations and can supply the PR creation date.
 
+**Use a Project-local Date field.** A Project field would allow different dates for the same Issue in different Projects and would not appear on the Issue itself. Work begins for the Issue rather than for one Project membership, so the organization Issue field owns the value.
+
 **Process every subscribed PR event or run a reconciler.** Later events would fill dates for existing pull requests and references added after creation, but they would make the field a repair projection instead of a record created with the pull request and would add repeated Project reads.
 
 **Update only resolving Issue references.** Informational references also identify Issues whose implementation work begins with the pull request, so the date initializer uses the existing all-reference set while Status transitions retain resolving-only semantics.
@@ -34,6 +36,6 @@ The configuration names the Project field and time zone. Missing configuration f
 
 ## Consequences
 
-Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests.
+Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests. The date follows the Issue across organization Projects that expose the field.
 
-The empty-value read makes retries idempotent in ordinary operation. ProjectV2 has no conditional field update, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.
+The empty-value read makes retries idempotent in ordinary operation. The Issue-field mutation has no compare-and-set precondition, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.

+ 8 - 6
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md

@@ -6,26 +6,28 @@ Status: implemented
 
 ## 问题
 
-Issue Project 使用 `Start date` 字段记录已规划工作的开始日期,但加入或关联 Issue 不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
+组织级 `Start date` Issue 字段记录工作开始时间,但把 Issue 加入 Issue Project 或从 PR 关联它都不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
 
 如果每个 PR 事件都更新该字段,编辑、推送或重新打开 PR 会为已有工作补上日期。覆盖已有日期还会丢弃人工规划的日期或较早 PR 记录的日期。
 
 ## 决策
 
-Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的 Date 字段。
+Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的组织级 Issue Date 字段。
 
-配置指定 Project 字段和时区。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
+配置指定 Project 中显示的字段和时区。该 Project 字段必须解析为组织级 Issue Date 字段;工作流读取它的 Issue 值并通过 `updateIssueFieldValue` 更新。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型或是 Project 局部字段、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
 
 [由事件直接指定的 PR 评审状态命令](2026-08-10-event-directed-pr-review-status.zh.md)继续负责 Status 转换。日期初始化同时包含解决型和信息型 Issue 引用,对 Draft PR 和自动化 PR 同样运行,也不依赖 PR 策略检查是否生效。
 
 ## 验证
 
-[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、空值写入、已有值保留、Project item 缺失、字段配置无效和 GraphQL mutation 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、Issue 字段发现、空值写入、已有值保留、Project item 缺失、字段配置无效和 `updateIssueFieldValue` 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
 
 ## 考虑过的替代方案
 
 **使用 Project 内置工作流。** 内置工作流负责固定的 Project item 和 Status 转换;仓库工作流已经负责经过身份验证的 GraphQL mutation,并且能够提供 PR 创建日期。
 
+**使用 Project 局部 Date 字段。** Project 字段允许同一个 Issue 在不同 Project 中使用不同日期,并且不会显示在 Issue 自身。工作是针对 Issue 开始,而不是针对某次 Project 归属开始,因此由组织级 Issue 字段持有该值。
+
 **处理每个已订阅 PR 事件或运行协调器。** 后续事件可以为已有 PR 和创建后新增的引用补上日期,但这会让该字段成为修复型投影,而不是随 PR 创建的记录,并且会增加重复 Project 读取。
 
 **仅更新解决型 Issue 引用。** 信息型引用同样标识随该 PR 开始实现工作的 Issue,因此日期初始化使用现有的全部引用集合,Status 转换仍只处理解决型引用。
@@ -34,6 +36,6 @@ Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date
 
 ## 后果
 
-只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。
+只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。日期会随 Issue 出现在组织内显示该字段的各个 Project 中。
 
-空值读取使重试在通常情况下保持幂等。ProjectV2 没有条件字段更新,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。
+空值读取使重试在通常情况下保持幂等。Issue 字段 mutation 没有比较并设置前提,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。

+ 23 - 15
.github/issue-management/policy.mjs

@@ -484,7 +484,13 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
           title
           fields(first: 50) {
             nodes {
-              ... on ProjectV2Field { id name dataType }
+              ... on ProjectV2Field {
+                id
+                name
+                dataType
+                isIssueField
+                issueField { ... on IssueFieldDate { id } }
+              }
               ... on ProjectV2SingleSelectField { id name dataType options { id name } }
             }
           }
@@ -512,7 +518,9 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
               }
               startDateValue: fieldValueByName(name: $startDateField)
                 @include(if: $includeStartDate) {
-                ... on ProjectV2ItemFieldDateValue { date }
+                ... on ProjectV2ItemIssueFieldValue {
+                  issueFieldValue { ... on IssueFieldDateValue { value } }
+                }
               }
             }
           }
@@ -544,6 +552,9 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
   if (startDateField && startDateField.dataType !== 'DATE') {
     throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
   }
+  if (startDateField && (!startDateField.isIssueField || !startDateField.issueField?.id)) {
+    throw new Error(`Project ${config.startDateField} 字段必须为 Issue Date 字段`)
+  }
   const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
   const latestStatusEvent = issue.timelineItems?.nodes
     ?.filter((event) => event?.project?.id === project.id)
@@ -582,27 +593,24 @@ async function ensureProjectItem(number, includeStartDate = false) {
 }
 
 /**
- * Initialize one Issue's Project Start date when it is empty.
+ * Initialize one Issue's organization Start date when it is empty.
  * @param {number} number Same-repository Issue number.
  * @param {string} date Date in YYYY-MM-DD form.
- * @returns {Promise<void>} Resolves after the conditional Project update.
+ * @returns {Promise<void>} Resolves after the conditional Issue-field update.
  */
 export async function initializeIssueStartDate(number, date) {
   const context = await ensureProjectItem(number, true)
-  if (context.item.startDateValue?.date) return
+  if (context.item.startDateValue?.issueFieldValue?.value) return
   await graphql(
-    `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
-      updateProjectV2ItemFieldValue(input: {
-        projectId: $projectId,
-        itemId: $itemId,
-        fieldId: $fieldId,
-        value: {date: $date}
-      }) { projectV2Item { id } }
+    `mutation($issueId: ID!, $fieldId: ID!, $date: String!) {
+      updateIssueFieldValue(input: {
+        issueId: $issueId,
+        issueField: {fieldId: $fieldId, dateValue: $date}
+      }) { issue { id } }
     }`,
     {
-      projectId: context.project.id,
-      itemId: context.item.id,
-      fieldId: context.startDateField.id,
+      issueId: context.issue.id,
+      fieldId: context.startDateField.issueField.id,
       date,
     },
   )

+ 40 - 13
.github/issue-management/policy.test.mjs

@@ -21,6 +21,8 @@ const projectGraphqlData = ({
   startDate = null,
   startDateField = true,
   startDateType = 'DATE',
+  startDateIsIssueField = true,
+  startDateIssueField = true,
 } = {}) => ({
   organization: {
     projectV2: {
@@ -30,7 +32,15 @@ const projectGraphqlData = ({
         nodes: [
           { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] },
           ...(startDateField
-            ? [{ id: 'start-date-field-id', name: 'Start date', dataType: startDateType }]
+            ? [
+                {
+                  id: 'start-date-project-field-id',
+                  name: 'Start date',
+                  dataType: startDateType,
+                  isIssueField: startDateIsIssueField,
+                  issueField: startDateIssueField ? { id: 'start-date-issue-field-id' } : null,
+                },
+              ]
             : []),
         ],
       },
@@ -46,7 +56,8 @@ const projectGraphqlData = ({
                 id: 'item-id',
                 project: { id: 'project-id' },
                 fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
-                startDateValue: startDate === null ? null : { date: startDate },
+                startDateValue:
+                  startDate === null ? null : { issueFieldValue: { value: startDate } },
               },
             ]
           : [],
@@ -258,25 +269,27 @@ test('initializes every referenced Issue only for a PR opened event', async () =
   assert.equal(writes.length, 3)
 })
 
-test('writes an empty Project Start date with the configured field', async (t) => {
+test('writes an empty Issue Start date with the configured field', async (t) => {
   const requests = mockGraphql(t, (request) => {
     if (request.query.includes('query(')) return projectGraphqlData()
-    return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } }
+    return { updateIssueFieldValue: { issue: { id: 'issue-id' } } }
   })
 
   await initializeIssueStartDate(42, '2026-08-28')
 
   assert.equal(requests.length, 2)
-  assert.match(requests[1].query, /value: \{date: \$date\}/)
+  assert.match(requests[0].query, /isIssueField/)
+  assert.match(requests[0].query, /ProjectV2ItemIssueFieldValue/)
+  assert.match(requests[1].query, /updateIssueFieldValue/)
+  assert.match(requests[1].query, /issueField: \{fieldId: \$fieldId, dateValue: \$date\}/)
   assert.deepEqual(requests[1].variables, {
-    projectId: 'project-id',
-    itemId: 'item-id',
-    fieldId: 'start-date-field-id',
+    issueId: 'issue-id',
+    fieldId: 'start-date-issue-field-id',
     date: '2026-08-28',
   })
 })
 
-test('preserves an existing Project Start date', async (t) => {
+test('preserves an existing Issue Start date', async (t) => {
   const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' }))
 
   await initializeIssueStartDate(42, '2026-08-28')
@@ -290,24 +303,38 @@ test('adds a referenced Issue to the Project before setting Start date', async (
     if (request.query.includes('addProjectV2ItemById')) {
       return { addProjectV2ItemById: { item: { id: 'new-item-id' } } }
     }
-    return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } }
+    return { updateIssueFieldValue: { issue: { id: 'issue-id' } } }
   })
 
   await initializeIssueStartDate(42, '2026-08-28')
 
   assert.equal(requests.length, 3)
   assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' })
-  assert.equal(requests[2].variables.itemId, 'new-item-id')
+  assert.deepEqual(requests[2].variables, {
+    issueId: 'issue-id',
+    fieldId: 'start-date-issue-field-id',
+    date: '2026-08-28',
+  })
 })
 
-test('rejects a missing or non-Date Start date field', async (t) => {
+test('rejects a missing, non-Date, or Project-local Start date field', async (t) => {
   let response = projectGraphqlData({ startDateField: false })
   const requests = mockGraphql(t, () => response)
 
   await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start date 字段/)
   response = projectGraphqlData({ startDateType: 'TEXT' })
   await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start date 字段必须为 Date/)
-  assert.equal(requests.length, 2)
+  response = projectGraphqlData({ startDateIsIssueField: false })
+  await assert.rejects(
+    initializeIssueStartDate(42, '2026-08-28'),
+    /Start date 字段必须为 Issue Date 字段/,
+  )
+  response = projectGraphqlData({ startDateIssueField: false })
+  await assert.rejects(
+    initializeIssueStartDate(42, '2026-08-28'),
+    /Start date 字段必须为 Issue Date 字段/,
+  )
+  assert.equal(requests.length, 4)
 })
 
 test('does not treat pull request references as Issue associations', () => {

+ 2 - 2
packages/llm/llm-deepseek/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
-README.md: 58639be34cb116f3103f579b277504818cb82278
-README.zh.md: d5a5be0847bc8f3ad069e7bb1fff3a42e1662e5d
+README.md: 6c69083909c71631dde04b453b399cc6bf687110
+README.zh.md: 9b6d35864dee20320e3f16bed82d8eecb4f39ec1

+ 1 - 1
packages/llm/llm-deepseek/README.md

@@ -117,7 +117,7 @@ The plugin is built on one explicit resolve step and one registration fact. `res
 | [`src/file-store.ts`](src/file-store.ts) + [`src/files-api.ts`](src/files-api.ts) | Scoped upload caching, expiry, stale-id recovery, quota cleanup, and remote file operations |
 | [`src/serialize.ts`](src/serialize.ts) | Wire serialization: thinking defaults, Files or inline image blocks, history rules |
 | [`src/sse.ts`](src/sse.ts) | `eventsource-parser` SSE framing for the direct `fetch` stream |
-| [`src/translate.ts`](src/translate.ts) | SSE payload translation into harness `StreamChunk` values |
+| [`src/translate.ts`](src/translate.ts) | SSE payload translation into harness `StreamChunk` values; tool-call `id` and `name` are identity, so a continuation delta repeating them empty or null leaves the established value alone |
 | [`src/types.ts`](src/types.ts) | Wire-level types shared by the modules above |
 
 ### Wire flow

+ 1 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -117,7 +117,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
 | [`src/file-store.ts`](src/file-store.ts) + [`src/files-api.ts`](src/files-api.ts) | 限定作用域的上传缓存、到期、陈旧 id 恢复、配额清理与远程文件操作 |
 | [`src/serialize.ts`](src/serialize.ts) | 协议序列化:thinking 默认值、Files 或内联图片块、历史规则 |
 | [`src/sse.ts`](src/sse.ts) | 直接 `fetch` 流的 `eventsource-parser` SSE 分帧 |
-| [`src/translate.ts`](src/translate.ts) | 把 SSE 载荷翻译为 harness `StreamChunk` 值 |
+| [`src/translate.ts`](src/translate.ts) | 把 SSE 载荷翻译为 harness `StreamChunk` 值;工具调用的 `id` 与 `name` 是身份,后续分片重复发送空串或 null 时保留已建立的值 |
 | [`src/types.ts`](src/types.ts) | 上述模块共享的协议级类型 |
 
 ### 协议流程

+ 20 - 5
packages/llm/llm-deepseek/src/translate.ts

@@ -19,9 +19,9 @@ interface OpenBlock {
   index: number
   kind: 'text' | 'reasoning' | 'tool-call'
   text: string
-  /** tool-call only */
-  callId?: string
-  name?: string
+  /** tool-call only, absent until a delta carries a non-empty value. */
+  callId?: string | undefined
+  name?: string | undefined
 }
 
 /**
@@ -71,6 +71,21 @@ export function mapUsage(usage: WireUsage): TokenUsage {
   }
 }
 
+/**
+ * Accept one streamed identity field for a tool call. `id` and `name` are
+ * identity, not accumulation: the wire sends each once, on the call's first
+ * delta. A continuation delta that re-sends the field empty — or `null`, which
+ * some OpenAI-compatible gateways fill in — means "no update", never "clear".
+ * @param current - the identity established by an earlier delta of this call.
+ * @param incoming - the field as parsed from this delta. The wire type is a
+ *   claim about a remote encoder, so anything but a non-empty string leaves the
+ *   established value alone rather than overwriting it.
+ * @returns the identity in force after this delta.
+ */
+function acceptIdentity(current: string | undefined, incoming: unknown): string | undefined {
+  return typeof incoming === 'string' && incoming.length > 0 ? incoming : current
+}
+
 /** Assemble the final ContentBlock for one open block. */
 function closeBlock(block: OpenBlock): ContentBlock {
   switch (block.kind) {
@@ -166,8 +181,8 @@ export async function* translate(payloads: AsyncIterable<string>): AsyncGenerato
           toolBlocks.set(call.index, block)
           yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
         }
-        if (call.id !== undefined) block.callId = call.id
-        if (call.function?.name !== undefined) block.name = call.function.name
+        block.callId = acceptIdentity(block.callId, call.id)
+        block.name = acceptIdentity(block.name, call.function?.name)
         const fragment = call.function?.arguments ?? ''
         block.text += fragment
         yield {

+ 8 - 5
packages/llm/llm-deepseek/src/types.ts

@@ -145,14 +145,17 @@ export interface WireDelta {
 export interface WireToolCallDelta {
   /** Disambiguates parallel tool calls; stable across a call's deltas. */
   index: number
-  /** Present on the first delta of each call only. */
-  id?: string
+  /**
+   * Carried by the first delta of each call. Gateways observed in the wild
+   * repeat it on continuation deltas as `''` or `null`; both mean "unchanged".
+   */
+  id?: string | null
   type?: 'function'
   function?: {
-    /** Present on the first delta of each call only. */
-    name?: string
+    /** Carried by the first delta of each call, with the same `''`/`null` repetition as {@link WireToolCallDelta.id}. */
+    name?: string | null
     /** Argument JSON fragment (concatenate across deltas). */
-    arguments?: string
+    arguments?: string | null
   }
 }
 

+ 80 - 0
packages/llm/llm-deepseek/tests/translate.spec.ts

@@ -364,3 +364,83 @@ describe('translate: defensive tool-call branches', () => {
     expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' })
   })
 })
+
+describe('translate: tool-call identity across deltas', () => {
+  it('keeps the established identity when continuation deltas re-send it empty', async () => {
+    const chunks = await collect(translate(feed(
+      firstChunk,
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] },
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: '{"city"' } }] } }] },
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: ': "Paris"}' } }] } }] },
+      { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
+      DONE,
+    )))
+    expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{
+      type: 'block-end',
+      index: 0,
+      block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' },
+    }])
+  })
+
+  it('keeps the established identity when continuation deltas re-send it null', async () => {
+    const chunks = await collect(translate(feed(
+      firstChunk,
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] },
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: null, function: { name: null, arguments: '{}' } }] } }] },
+      { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
+      DONE,
+    )))
+    expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{
+      type: 'block-end',
+      index: 0,
+      block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' },
+    }])
+  })
+
+  it('re-sending the same non-empty identity does not duplicate it', async () => {
+    const chunks = await collect(translate(feed(
+      firstChunk,
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] },
+      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '{}' } }] } }] },
+      { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
+      DONE,
+    )))
+    expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{
+      type: 'block-end',
+      index: 0,
+      block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' },
+    }])
+  })
+
+  it('maintains each parallel call identity separately under empty continuation deltas', async () => {
+    const chunks = await collect(translate(feed(
+      firstChunk,
+      {
+        choices: [{
+          delta: {
+            tool_calls: [
+              { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '' } },
+              { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } },
+            ],
+          },
+        }],
+      },
+      {
+        choices: [{
+          delta: {
+            tool_calls: [
+              { index: 1, id: '', function: { name: '', arguments: '{"b":1}' } },
+              { index: 0, id: '', function: { name: '', arguments: '{"a":1}' } },
+            ],
+          },
+        }],
+      },
+      { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
+      DONE,
+    )))
+    expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([
+      { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{"a":1}' } },
+      { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{"b":1}' } },
+    ])
+  })
+})