Replies: 7 comments 2 replies
|
头疼,历史session都嘎了,然后又导致新开对话也报错,完全无法使用了。 |
|
Mode A(v0 文件被 v0→v1 迁移拒读)是磁盘合同里我们能碰的那一层: dsh-session-surgeon
不会做的:
先停写者,先 dry-run。已经是 v2 且宿主能打开、只是对话空白的,请继续跟官方前端修;不要对已裁过的文件连 apply。 装/更新: |
EN / EnglishAn outside research team — we use DSH daily, mostly on the npm line — adding the systemic connection this report sits in, plus a practical note for affected users. jhugard has the report and the root cause; xiaoshenming has the repair tooling. What's missing is where this accident sits in the format's history — and it sits squarely inside two failure families that were both flagged before: 1. Mode A (v0→v1 refuses the session) is the migrator family from #4910/#5694 — this is live instance #3Since Aug 28 we've argued format v0 never had a unified contract ( 2. Mode B (client blanks the conversation) is the assembler family from #5692 — same fatal-throw gap, second symptomWe reported 3. Root-cause isomorphism, stated once#5909 root-cause point 4 ("the invariant is enforced on the read side but never on the write side") and point 5 ("no client degradation") are the two halves of what both families share: formats and clients assert contracts that writers were never required to hold, and neither layer degrades when reality disagrees. Fixing the writer (dedupe) prevents new cases; fixing the readers (recoverable migration + degrade-on-bad-block) rescues the sessions already written. Both halves are needed; #5692's degrade proposal covers the second. 4. Practical triage for affected users (Nicolas-One et al.)
Not a duplicate of anything above — the report, the tool, and the client-side discussions all stand on their own. This is the connecting note: the same v0 write-side gap, refused by two released readers in two different ways, both predicted before they shipped. Authorship note: reproduced and documented by me in real-world usage; root-cause tracing and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out; I will re-verify against the toolchain and correct. Reported by the OfferKuai Team — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/ | Contact: contact@offerkuai.com 中文版 / ZH我们是外部研究团队——日常使用 DSH(以 npm 线为主)——补充这条报告所处的系统性脉络,以及给受影响用户的实际判断方法。 jhugard 已给出报告与根因;xiaoshenming 已给出修复工具。缺的是这次事故在格式历史中的位置——它正好落在两个都曾被提前指出的失败家族里: 1. 失效模式 A(v0→v1 拒收整个会话)是 #4910/#5694 的迁移器家族——这是第三个现场实例自 8/28 起我们就主张:v0 格式从来没有统一契约( 2. 失效模式 B(客户端对话整段空白)是 #5692 的 assembler 家族——同一个 fatal-throw 缺口,第二种症状我们报告过 3. 根因同构,说一次#5909 根因第 4 点("不变量只在读侧强制、写侧从未强制")与第 5 点("客户端无降级")正是两个家族共有的两半:格式与客户端在断言写端从未被要求遵守的契约,且当现实不符时没有任何一层选择降级。修写端(去重)防新增;修读端(可恢复迁移 + 坏块降级)救已落盘会话。两半都需要;#5692 的降级提案覆盖第二半。 4. 受影响用户的实用判断(Nicolas-One 等)
这不是对上面任何内容的重复——报告、工具、客户端侧讨论各自成立。这是连接注记:同一个 v0 写端缺口,被两个已发布读端以两种方式拒绝,而两者在发布前都已被预测。 协作说明:本文由我在真实使用中复现并记录;根因与成文由 AI 协助完成,我负责校验与发布。我非工程背景,技术表述如有错误,欢迎指出——我会回去用工具链重新验证后更正。 本报告由 OfferKuai(Offer快)团队提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/ | 联系:contact@offerkuai.com |
|
建议使用dshvm来管理dsh版本,基于node v24.14.0测试。 我现在使用dshvm已经更新到 0.1.3-alpha.2了, 运行稳定。 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dshvm 0.1.0 —— DeepSeek Harness CLI(dsh)的 nvm 式多版本管理器它做什么dshvm 是 DeepSeek Harness CLI(
安装npm install -g @dsh-so/dshvm
dshvm setup # 收编现有全局 dsh + 装好连接器上手五连: dshvm install 0.1.2-rc.1 # 从 npm 安装
dshvm install 0.1.3-alpha.1 # npm 没有 → 自动 GitHub 源码构建
dshvm use 0.1.3-alpha.1 # 切换(前缀匹配即可)
dshvm list # * = 当前激活
dsh --version # 跑的就是激活版本常见使用场景1 · 安全尝鲜新版:不动你正在用的环境(隔离拷贝升级) dshvm isolate 0.1.3-alpha.1 --copy # 拷贝现有 ~/.dsh 后转为独立
dshvm use 0.1.3-alpha.1 # 激活这份独立副本你的会话、插件、配置照常可用——新版本在自己那份数据里折腾,升级永远不会污染你正在用的环境。 2 · 日常多版本管理 dshvm install 0.1.2-rc.1 # 正常安装
dshvm use 0.1.2-rc.1 # 切换;和所有版本一样共享默认 ~/.dsh3 · 完全隔离的测试环境 dshvm isolate 0.1.3-alpha.1 # 全新、空白的 $DSH_HOME——不拷贝任何东西,不影响任何现有数据
dshvm use 0.1.3-alpha.1在"干净房间"里测试插件 / profiles / 会话,不会向共享数据泄漏任何东西。 4 · 随时回退、去掉隔离、清理收尾 dshvm use 0.1.2-rc.1 # 回到共享 ~/.dsh 和旧版本
dshvm unisolate 0.1.3-alpha.1 # 删掉该版本的隔离副本
dshvm remove 0.1.2-rc.1 # 用完了就删槽位(含其隔离数据)几个要知道的点 —— 其它亮点
P.S. dshvm 是独立社区项目,与 DeepSeek 官方无隶属关系。MIT 开源、已发布到 npm,迁移到 dsh-so 组织后刚迎来 0.1.0 首个正式版。Issue、功能建议和 PR 都非常欢迎——它解决的是真实存在的"升级焦虑",同样也需要真实反馈。 利益相关披露. 基于透明原则说明:我是 dsh.so 站点(DeepSeek Harness 社区插件的注册与发现中心)的维护者,也是 dsh-plugin-advisor(从该注册中心搜索、对比并安装 dsh 插件的工具,含验证等级 L1–L5 与安全扫描)的作者。 作者: zhoushimin(dshvm contributors)· 协议: MIT · 仓库: https://github.com/dsh-so/dshvm · npm: |
|
The underlying issue here is that IMHO, since it is unlikely that the mainline and every fork of -- 下面是你这段 修订后的英文 的精准、技术语气中文翻译,保持原意与上下文一致: 根本问题在于:llama.cpp 在同一个回复里对并行的工具调用使用了相同的 ID,而 DSH 试图对每一次工具调用都强制使用唯一的 ID。 在我看来,由于主线版本以及所有 llama.cpp 的分支几乎不可能同时被说服去更新,DSH 需要重写为在同一个 step 内容忍非唯一的 ID。 如果你希望更正式、更口语化或更简练的版本,我可以继续调整。 |
已在本地修复和验证,历史数据实现正常加载,记录如下:中文:问题概述(Summary) 升级 DeepSeek Harness 之后,大部分升级前创建的会话无法加载: released-format 迁移校验器只对当前的共享 环境(Environment)
复现步骤(Steps to reproduce)
实际结果(Actual result)会话拒绝加载并报出上述错误。 function replayEnvelopeValue(value: SessionFormatJsonValue | undefined, label: string): void {
const replay = exactRecord(value, label, ['response'], ['blocks'])
if (replay['blocks'] !== undefined && !Array.isArray(replay['blocks'])) {
throw new SessionFormatError(`${label} blocks must be an array`)
}
}磁盘上的扁平形式(取自真实故障会话,provider/model 已省略)在第一个成员 {
"kind": "pi-ai",
"version": 1,
"api": "openai-completions",
"provider": "…",
"model": "…",
"responseId": "…",
"stopReason": "toolUse",
"blocks": [
{ "type": "reasoning", "thinkingSignature": "reasoning_content" },
{ "type": "tool-call" }
]
}每个携带该形式的 期望结果(Expected result)已发布构建写下的产物应当能够迁移。 同根因的连带缺口(Secondary gap)仅在 chunk 层放行扁平形式并不足够。有两处重组点会把 message source 的
当 建议修法(Suggested fix)与该包自身「已发布的未知分支保持 owner-opaque」的政策一致(先例见
完成这两处修改后,受影响会话即可干净迁移;扁平 验证情况(Verification)我复现了该拒绝,普查了本地升级前的 v0 日志(扁平的
仓库当前不接受 PR,如有需要我可以提供 patch 或任何补充材料(原始日志样本、完整堆栈)。 补充说明(Additional context)
EN:SummaryAfter upgrading DeepSeek Harness, most sessions created before the upgrade fail to load: The released-format migration validator only models the current shared Environment
Steps to reproduce
Actual resultThe session refuses to load with the error above. function replayEnvelopeValue(value: SessionFormatJsonValue | undefined, label: string): void {
const replay = exactRecord(value, label, ['response'], ['blocks'])
if (replay['blocks'] !== undefined && !Array.isArray(replay['blocks'])) {
throw new SessionFormatError(`${label} blocks must be an array`)
}
}The flat form on disk (real sample from a failing session, provider/model elided) fails on its first member {
"kind": "pi-ai",
"version": 1,
"api": "openai-completions",
"provider": "…",
"model": "…",
"responseId": "…",
"stopReason": "toolUse",
"blocks": [
{ "type": "reasoning", "thinkingSignature": "reasoning_content" },
{ "type": "tool-call" }
]
}Every Expected resultArtifacts written by released builds should migrate. Secondary gap (same root cause)Admitting the flat form at chunk level is not sufficient. Two reassembly sites compare the message source's
Suggested fixConsistent with the package's own policy that released unknown arms stay owner-opaque (see
With these two changes the affected sessions migrate cleanly; the flat VerificationI reproduced the refusal, surveyed local pre-upgrade v0 logs (the flat
The repository currently does not accept PRs, so I am happy to provide the patch or any additional detail (raw log samples, stack traces) if useful. Additional context
|
|
我在 同一条 assistant 消息中重复工具调用 ID 的结果如下:
v0 的错误是 唯一 ID 对照与完成工具生命周期后的跨 step ID 复用也通过,最终共 15 项表征测试。v2 样本使用当前消息结算字段,避免把字段格式错误混成重复 ID 问题。 这份补充只验证加载链,没有跑 Web 组件或真实 llama.cpp 输出;v2 加载成功不代表对话能正常显示。建议保留已发布迁移校验,把写入侧 ID 处理、已有损坏数据的显式恢复与客户端显示分别验证,不要把简单放宽读取校验当作完整修复。 可复跑的表征测试补丁(仅测试,不修改生产源码)--- /dev/null
+++ b/packages/session/session-format/tests/contribution-proof.spec.ts
@@ -0,0 +1,236 @@
+import { describe, expect, it } from 'vitest'
+import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
+
+const v0Header = {
+ type: 'session',
+ version: 0,
+ id: 'dup-tool-calls',
+ createdAt: 1,
+ delegationDepth: 0,
+} as const
+
+function assistantMessage(
+ seq: number,
+ time: number,
+ toolCalls: Array<{ id: string; name: string; arguments: string }>,
+) {
+ return {
+ type: 'assistant/message' as const,
+ seq,
+ time,
+ sourceEventSeqs: [] as readonly number[],
+ surfaceOp: 'append' as const,
+ data: {
+ turn: 1,
+ step: 1,
+ message: {
+ id: 'msg-1',
+ role: 'assistant' as const,
+ content: toolCalls.map(tc => ({
+ type: 'tool-call' as const,
+ id: tc.id,
+ name: tc.name,
+ arguments: tc.arguments,
+ })),
+ source: { kind: 'model' as const, provider: 'mock', model: 'mock' },
+ },
+ },
+ }
+}
+
+function toolCallEvent(
+ seq: number,
+ time: number,
+ callId: string,
+ name: string,
+ step = 1,
+): unknown {
+ return {
+ type: 'tool/call',
+ seq,
+ time,
+ data: { turn: 1, step, callId, name, arguments: '{}' },
+ }
+}
+
+function toolResultEvent(
+ seq: number,
+ time: number,
+ callId: string,
+ step = 1,
+): unknown {
+ return {
+ type: 'tool/result',
+ seq,
+ time,
+ sourceEventSeqs: [seq - 1],
+ surfaceOp: 'append',
+ data: {
+ turn: 1,
+ step,
+ message: {
+ id: `result-${callId}`,
+ role: 'user',
+ content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text: 'ok' }], isError: false }],
+ source: { kind: 'tool', callId },
+ },
+ meta: { opaque: { seq: 999 } },
+ },
+ }
+}
+
+const turnStart = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
+const stepStart1 = { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }
+
+function duplicateRows(): readonly unknown[] {
+ return [
+ turnStart,
+ stepStart1,
+ assistantMessage(2, 3, [
+ { id: 'call_abc', name: 'read_file', arguments: '{}' },
+ { id: 'call_abc', name: 'read_file', arguments: '{}' },
+ ]),
+ ]
+}
+
+function uniqueRows(): readonly unknown[] {
+ return [
+ turnStart,
+ stepStart1,
+ assistantMessage(2, 3, [
+ { id: 'call_abc', name: 'read_file', arguments: '{}' },
+ { id: 'call_def', name: 'write_file', arguments: '{}' },
+ ]),
+ ]
+}
+
+function restore(
+ header: unknown,
+ rows: readonly unknown[],
+ validation: 'transformed' | 'current',
+) {
+ const current = sessionFormatCatalog.createRestore(header, {
+ recovery: 'strict',
+ validation,
+ })
+ for (const row of rows) current.decodeRow(row)
+ return current.finish()
+}
+
+describe('Session contribution proof — duplicate tool-call IDs', () => {
+ it('rejects a v0 Session with duplicate tool-call ids under current validation', () => {
+ expect(() => restore(v0Header, duplicateRows(), 'current')).toThrow(
+ /assistant\/message repeats advertised tool call call_abc/,
+ )
+ })
+
+ it('rejects a v0 Session with duplicate tool-call ids under transformed validation', () => {
+ expect(() => restore(v0Header, duplicateRows(), 'transformed')).toThrow(
+ /assistant\/message repeats advertised tool call call_abc/,
+ )
+ })
+
+ it('accepts a v0 Session with unique tool-call ids under current validation', () => {
+ const result = restore(v0Header, uniqueRows(), 'current')
+ expect(result.events.length).toBeGreaterThanOrEqual(3)
+ expect(result.header.version).toBe(2)
+ })
+
+ it('accepts a v0 Session with unique tool-call ids under transformed validation', () => {
+ const result = restore(v0Header, uniqueRows(), 'transformed')
+ expect(result.events.length).toBeGreaterThanOrEqual(3)
+ expect(result.header.version).toBe(2)
+ })
+
+ it('allows the same tool-call id across distinct steps that each resolve their lifecycle', () => {
+ const rows = [
+ turnStart,
+ stepStart1,
+ assistantMessage(2, 3, [{ id: 'call_abc', name: 'read_file', arguments: '{}' }]),
+ toolCallEvent(3, 4, 'call_abc', 'read_file', 1),
+ toolResultEvent(4, 5, 'call_abc', 1),
+ { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
+ { type: 'step/start', seq: 6, time: 7, data: { turn: 1, step: 2 } },
+ {
+ type: 'assistant/message', seq: 7, time: 8,
+ sourceEventSeqs: [], surfaceOp: 'append',
+ data: {
+ turn: 1, step: 2,
+ message: {
+ id: 'msg-2', role: 'assistant',
+ content: [{ type: 'tool-call', id: 'call_abc', name: 'read_file', arguments: '{}' }],
+ source: { kind: 'model', provider: 'mock', model: 'mock' },
+ },
+ },
+ },
+ toolCallEvent(8, 9, 'call_abc', 'read_file', 2),
+ toolResultEvent(9, 10, 'call_abc', 2),
+ { type: 'step/end', seq: 10, time: 11, data: { turn: 1, step: 2 } },
+ ]
+ expect(() => restore(v0Header, rows, 'current')).not.toThrow()
+ expect(() => restore(v0Header, rows, 'transformed')).not.toThrow()
+ })
+
+ it('distinguishes the transformed error wrapping from the current error', () => {
+ let currentError: Error | undefined
+ try {
+ restore(v0Header, duplicateRows(), 'current')
+ } catch (error: unknown) {
+ currentError = error as Error
+ }
+ expect(currentError).toBeDefined()
+ expect(currentError!.name).toBe('SessionFormatError')
+ expect(currentError!.message).toContain('assistant/message repeats advertised tool call call_abc')
+
+ let transformedError: Error | undefined
+ try {
+ restore(v0Header, duplicateRows(), 'transformed')
+ } catch (error: unknown) {
+ transformedError = error as Error
+ }
+ expect(transformedError).toBeDefined()
+ expect(transformedError!.name).toBe('SessionFormatUnsupportedMigrationError')
+ expect(transformedError!.message).toContain('refuses the transformed artifact')
+ expect(transformedError!.message).toContain('assistant/message repeats advertised tool call call_abc')
+ })
+
+ it('propagates the cause through the transformed wrapper', () => {
+ let error: Error | undefined
+ try {
+ restore(v0Header, duplicateRows(), 'transformed')
+ } catch (err: unknown) {
+ error = err as Error
+ }
+ expect(error).toBeDefined()
+ const cause = (error as { cause?: unknown }).cause
+ expect(cause).toBeInstanceOf(Error)
+ expect((cause as Error).message).toContain('assistant/message repeats advertised tool call call_abc')
+ })
+})
+for (const version of [1, 2] as const) {
+ function physicalRows(rows: readonly unknown[]) {
+ return rows.map((value) => {
+ const row = value as { type: string; data: object; sourceEventSeqs?: unknown }
+ if (version !== 2 || row.type !== 'assistant/message') return value
+ const { sourceEventSeqs: _provenance, ...rest } = row
+ return { ...rest, data: { ...row.data, stream: [] } }
+ })
+ }
+ for (const validation of ['current', 'transformed'] as const) {
+ const header = { ...v0Header, version, ...(version === 2 ? { isSeeded: false } : {}) }
+ it(`v${version} ${validation}: restores the unique-id control`, () => {
+ expect(restore(header, physicalRows(uniqueRows()), validation).header.version).toBe(2)
+ })
+ it(`v${version} ${validation}: characterizes duplicate-id restore`, () => {
+ if (version === 2 && validation === 'transformed') {
+ const result = restore(header, physicalRows(duplicateRows()), validation)
+ expect(result.header.version).toBe(2)
+ expect(result.events).toHaveLength(3)
+ } else {
+ expect(() => restore(header, physicalRows(duplicateRows()), validation)).toThrow(
+ /assistant\/message repeats advertised tool call call_abc/,
+ )
+ }
+ })
+ }
+} |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Duplicate Tool‑Call IDs Break Session Loading (blank session) and cause migration v0→v1→v2 failures
会话工具调用 ID 重复:导致会话加载失败(会话空白)并引发 v0→v1→v2 迁移失败
c389f96bf3(=origin/master)dsh --profile web, local OpenAI-compatible Responses server (llama.cpp-class)Summary
A local OpenAI-compatible Responses server (llama.cpp-class) re-emits the same
call_idandidpair for multiple output items within one assistant response. The pi-ai adapter composes the tool-call id as`${call_id}|${id}`(shaped likecall_<token>|fc_<token>) without de-duplication, and this repo's pi-ai adapter layer (packages/llm/llm-pi-ai/src/stream.ts:188,200) passes the id through verbatim. A single assistant response therefore yields duplicated tool-call ids, which are persisted into the v0 session log — the v0 format enforces no uniqueness on tool-call ids at write time, so the corrupt data is written.The read side, however, treats a repeated advertised tool call as a hard error. Once persisted, the corrupt data sits dormant in the log file — no fresh model output is needed: for a v2 session, simply reopening it trips the failure. The same corrupt data therefore trips two different failures on two different read paths:
ConversationNodeAssemblerthrowsreceived more than one start Match— the entire conversation is blank and "Load earlier" is wedged. This is the current case.中文摘要 (Chinese summary)
本地 OpenAI 兼容 Responses 服务器(llama.cpp 级别)会在同一个 assistant 响应内为多个输出项重新发出相同的
call_id与id组合。pi-ai 适配器把工具调用 ID 组合为`${call_id}|${id}`(形如call_<token>|fc_<token>),且不做去重;本仓库的 pi-ai 适配层(packages/llm/llm-pi-ai/src/stream.ts:188,200)原样透传该 ID。于是同一次 assistant 响应里就出现了重复的工具调用 ID,并随 v0 会话日志落盘——v0 格式在写入侧对工具调用 ID 没有任何唯一性约束,脏数据得以落盘。但读取侧把"重复的已通告工具调用"当作硬错误。一旦落盘,脏数据便潜伏在日志文件中——无需新的模型输出:对 v2 会话,仅重新打开即会触发失效。于是同一份脏数据在两条不同的读取路径上分别触发两种失效模式:
ConversationNodeAssembler抛出received more than one start Match——对话内容整段空白,"Load earlier" 卡死。这是当前遇到的情况。Failure modes
Mode A: v0 files — the v0→v1 migration refuses the whole session at load time
The v0→v1 migration reuses the released validator
assertReleasedArtifactRelationships. When it sees atool-callblock id in anassistant/messagethat was already advertised, it throws, and the entire migration — therefore the session load — fails:packages/session/session-format-v0-to-v1/src/relationships.ts:140tool/call ... does not match one advertised tool call(:156),tool/result ... has no advertised tool lifecycle(:171),assertNoUnresolvedTools(:375).Symptom: the session cannot open at all. Driving the exact product chain (v0→v1→v2 + restore) over a dirty v0 file fails with
assistant/message repeats advertised tool call <id>. This was the original encounter.中文:失效模式 A
v0→v1 迁移复用已发布的校验器
assertReleasedArtifactRelationships。当它在assistant/message中遇到一个已经通告过的tool-call块 ID 时直接抛出,整个迁移——因此整个会话加载——失败:packages/session/session-format-v0-to-v1/src/relationships.ts:140tool/call ... does not match one advertised tool call(:156)、tool/result ... has no advertised tool lifecycle(:171)、assertNoUnresolvedTools(:375)。症状:会话完全无法打开。对产品真实使用的完整链(v0→v1→v2 + restore)跑一个带脏数据的 v0 文件,得到
assistant/message repeats advertised tool call <id>。这是最初遇到的失效模式。Mode B: v2 files — host restores fine; the web client blanks the conversation
A session that is already v2 (e.g.
81570fca: 1236 events, containing all 45 duplicated-id groups) does not traverse the v0→v1 / v1→v2 edges. The production read path usesvalidation: 'transformed'(packages/session/session-persistence-jsonl/src/index.ts:276-280), which is tolerant of duplicate ids, so the host-side restore succeeds (~22 ms, measured with the productioncreateRestoreatvalidation:'transformed'). But the web client'sConversationNodeAssemblerthrows when one conversation context receives twostartMatches:packages/client/ui-conversation/src/client/conversation/assembler.ts:513and:569Symptom: the session "opens" but the conversation is entirely blank, with only a "Load earlier" button. Clicking it re-runs the assembler via
prepend, re-throws the same error, re-enables the button, and no content ever appears.中文:失效模式 B
已经是 v2 的会话(例如
81570fca:1236 事件,含全部 45 组重复 ID)不走 v0→v1 / v1→v2 迁移边。生产读取路径使用validation: 'transformed'(packages/session/session-persistence-jsonl/src/index.ts:276-280),它对重复 ID 宽容,因此主机侧恢复成功(用生产createRestore、validation:'transformed'实测 ~22 ms)。但 Web 客户端的ConversationNodeAssembler对同一个上下文收到两个startMatch 时抛出:packages/client/ui-conversation/src/client/conversation/assembler.ts:513与:569症状:会话能"打开",但对话内容整段空白,只剩 "Load earlier" 按钮;点击它走
prepend重新执行装配器,再次抛出同一错误,按钮复位,但始终没有内容出现。Data shape (shared by both modes)
call_<token>|fc_<token>(composed from the Responses stream'scall_idandid).assistant/messagecontent block,tool/call,tool/result(message.source.callIdandmessage.content[0].toolCallId), andassistant/chunk(the tool-call block's deltas and block-end).中文:数据形态(两种模式共用)
call_<token>|fc_<token>(由 Responses 流的call_id与id组合)。assistant/message的 content 块、tool/call、tool/result(message.source.callId与message.content[0].toolCallId)、assistant/chunk(tool-call 块的 delta 与 block-end)。Root cause
call_id+idfor multiple output items within one assistant response.`${call_id}|${id}`without a uniqueness check; this repo'spackages/llm/llm-pi-ai/src/stream.ts:188,200passesevent.toolCall.id/known?.idthrough verbatim.relationships.ts:140). That invariant is enforced on the read side but never on the write side — the asymmetry is the root cause that lets the corruption be produced and later refused.ConversationNodeAssemblerthrows on "one context, multiple starts" (assembler.ts:513/569) with no graceful path, blanking the whole conversation instead of skipping the corrupt block.中文:根因分析
call_id+id。`${call_id}|${id}`,不检查唯一性;本仓库packages/llm/llm-pi-ai/src/stream.ts:188,200直接透传event.toolCall.id/known?.id,无去重。relationships.ts:140)。该不变量在读取侧被强制,却在写入侧从未被强制——两侧不对称,正是脏数据能够产生并被后续拒绝的根本原因。ConversationNodeAssembler对"一个上下文多个 start"直接抛错(assembler.ts:513/569),没有降级路径,导致整段会话空白而非仅跳过脏块。Evidence
packages/session/session-format-v0-to-v1/src/relationships.ts:140; the full v0→v1→v2 + restore chain printsMIGRATE_REFUSEDcreateRestore,validation:'transformed'packages/session/session-persistence-jsonl/src/index.ts:276-280(recovery:'recoverable',validation:'transformed')packages/client/ui-conversation/src/client/conversation/assembler.ts:513,569packages/llm/llm-pi-ai/src/stream.ts:188,20081570fca: v2, 1236 events, 45 duplicated-id groups, each exactly twice(45 组重复 ID,每组恰好 2 次)Interim workaround (pure id-rename) — sidesteps the issue, does not fix it
A one-shot repair of an affected session is a pure id-rename, and it is easy to replicate by hand or script:
seqremains dense and everysourceEventSeqs/surfaceOpreference stays valid.assistant/messagecontent block,tool/call,tool/result, and theassistant/chunkdeltas) within one (turn, step, id) group, mirroring the v1→v2BlockAssemblerchunk mapping.This sidesteps the issue (the ids no longer collide) but does not fix the root cause (the writer can still emit duplicate ids).
中文:临时修复(纯重命名,绕过而非修复)
对受影响会话的一次性修复是一次纯 ID 重命名,手工或写脚本都很容易复现:
seq保持稠密,所有sourceEventSeqs/surfaceOp引用保持有效。assistant/message的 content 块、tool/call、tool/result、assistant/chunk的 delta)同步重命名,镜像 v1→v2BlockAssembler的 chunk 映射。该做法规避了问题(重复 ID 不再冲突),但不触及根因(写入侧仍会产生重复 ID)。
Suggested fixes
ConversationNodeAssemblershould log and skip the corrupt block rather than throw and blank the entire conversation.中文:建议的修复方向
ConversationNodeAssembler遇到"一个上下文多个 start"时记录并跳过脏块,而不是抛出导致整段会话空白。Related reports & upstream status
The same root cause — model/provider-issued tool-call ids are not unique — already surfaces in other discussions; this report is a different surface (the migration / read-side acceptance condition):
ConversationNodeAssemblerreceived more than one start Matchwedge (Mode B above).46196d6f95("perf(session-format): stream released v0-to-v2 migrations", in0.1.3-alpha.2) removed the'target'validation mode, soassertReleasedArtifactRelationshipsnow also runs on'current'restoration — meaning migrated input and strict-current verification (worker / fixture) refuse these logs, not only the v0→v1 edge. Production daily reads still usevalidation: 'transformed'and load fine.ReleasedRelationshipExtensions.legacyInterruptedTurnRestart(relationships.ts:38,82) explicitly admits a released-writer pattern the validator would otherwise reject.中文:相关报告与上游状态
同一根因——模型/服务方产出的工具调用 ID 不唯一——在以下讨论中已有其他表现面;本报告是不同的表现面(迁移 / 读取侧的接受条件):
ConversationNodeAssembler抛出received more than one start Match的卡死(即上文失效模式 B)。46196d6f95("perf(session-format): stream released v0-to-v2 migrations",含于0.1.3-alpha.2)移除了'target'校验模式,assertReleasedArtifactRelationships现在也会在'current'恢复时执行——因此迁移输入与"严格 current 校验"(worker / fixture 验证)都会拒绝此类日志,而非仅 v0→v1 一条边。生产日常读取仍走validation: 'transformed',会话本身仍可打开。ReleasedRelationshipExtensions.legacyInterruptedTurnRestart(relationships.ts:38,82)显式放行了一种否则会被校验器拒绝的已发布写入端模式。All reactions