From aec27c2cccc0da5cba0b3cc12a199ef1b29170ec Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Tue, 29 Sep 2026 14:47:08 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(mapping):=20=E6=94=AF=E6=8C=81=E5=8C=85?= =?UTF-8?q?=E5=90=AB=20subagent=20=E8=B0=83=E7=94=A8=E7=9A=84=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/migration/ownership.ts | 8 ++- src/source/__tests__/assemble-bundle.test.ts | 44 ++++++++++++ src/source/__tests__/tool-calls.test.ts | 8 +-- src/source/trae/tool-calls.ts | 2 +- src/target/__tests__/opencode-mapping.test.ts | 37 +++++++++- .../__tests__/opencode-v1-mapping.test.ts | 28 +++++++- src/target/opencode/mapping.ts | 67 +++++++++++++++++-- src/target/opencode/v1/mapping.ts | 10 ++- 8 files changed, 184 insertions(+), 20 deletions(-) diff --git a/src/migration/ownership.ts b/src/migration/ownership.ts index 85a436e..bc6c2f5 100644 --- a/src/migration/ownership.ts +++ b/src/migration/ownership.ts @@ -1,7 +1,9 @@ import { hashCanonicalJson } from "../ir/canonical.js"; import type { JsonValue } from "../ir/types.js"; import { isRecord } from "../target/opencode/contract.js"; -import type { OpenCodeSession } from "../target/opencode/mapping.js"; +import { + OPENCODE_MAPPING_VERSION, type OpenCodeSession, +} from "../target/opencode/mapping.js"; import type { ManifestSession, MigrationManifest } from "./manifest.js"; export const jsonHash = (value: unknown) => @@ -13,10 +15,10 @@ export function isOwnedByRun( ): boolean { const metadata = transfer.info.metadata; const marker = isRecord(metadata) ? metadata.trae2opencode : undefined; - // v1 and the current v2 format both use v8; older verified v2 manifests remain replaceable. + // v1 and v2 share the current mapping format; older verified manifests remain replaceable. const supportedMappingVersion = isRecord(marker) && typeof marker.mappingVersion === "number" && - marker.mappingVersion >= 1 && marker.mappingVersion <= 8 && + marker.mappingVersion >= 1 && marker.mappingVersion <= OPENCODE_MAPPING_VERSION && Number.isInteger(marker.mappingVersion); return transfer.info.id === item.targetId && isRecord(marker) && marker.migrationRunId === manifest.runId && marker.sourceSessionId === item.sourceId && diff --git a/src/source/__tests__/assemble-bundle.test.ts b/src/source/__tests__/assemble-bundle.test.ts index d5d075e..b8da335 100644 --- a/src/source/__tests__/assemble-bundle.test.ts +++ b/src/source/__tests__/assemble-bundle.test.ts @@ -112,6 +112,50 @@ describe("assembleTraeMigrationBundle", () => { ); }); + it("keeps subagent turns recoverable with null empty tool placeholders and source time evidence", () => { + const options = input(); + const assistant = messages(options)[0]; + assistant.agent_type = "solo_agent"; + assistant.created_at = 1_700_000_010; + assistant.chat_start_time = 1_700_000_001_000; + assistant.chat_end_time = 1_700_000_004_000; + assistant.content = { + messages: [ + { + type: "plan_item", + plan_item: { + id: "plan-subagent", + agent_run_id: "run-subagent", + sub_agent_call_description: "Synthetic subagent task.", + thought: "Synthetic persisted progress.", + tool_call_info: { + id: "placeholder-subagent", + name: "", + params: null, + result: {}, + already_emitted_generating_event: false, + already_emitted_run_event: false, + }, + }, + }, + ], + }; + + const bundle = assembleTraeMigrationBundle(options); + const session = bundle.sessions[0]; + assert.equal(session.recovery, "partial"); + const event = session.events[1]; + assert.equal(event.type, "assistant"); + if (event.type !== "assistant") throw new Error("fixture shape changed"); + assert.equal(event.content.some((block) => block.type === "tool"), false); + assert.ok(bundle.diagnostics.some( + (diagnostic) => diagnostic.code === "T2O_IR_EVENT_TIME_INVALID", + )); + assert.equal(bundle.diagnostics.some( + (diagnostic) => diagnostic.code === "T2O_TRAE_TOOL_CALL_INVALID", + ), false); + }); + it("deduplicates identical runtime observations without changing the IR", () => { const options = input(); const expected = assembleTraeMigrationBundle(options); diff --git a/src/source/__tests__/tool-calls.test.ts b/src/source/__tests__/tool-calls.test.ts index 7449596..55d3a5a 100644 --- a/src/source/__tests__/tool-calls.test.ts +++ b/src/source/__tests__/tool-calls.test.ts @@ -93,19 +93,19 @@ describe("parseTraeToolCalls", () => { }); it("ignores verified empty tool placeholders without hiding malformed calls", () => { - const emptyPlaceholder = { + const emptyPlaceholder = (params: undefined | null) => ({ type: "plan_item", plan_item: { id: "plan-placeholder", tool_call_info: { - id: "placeholder-a", name: "", params: undefined, result: {}, + id: "placeholder-a", name: "", params, result: {}, already_emitted_generating_event: false, already_emitted_run_event: false, }, timing: { generated_at_ms: 100 }, }, - }; + }); const malformed = observation("call-b", {}, { name: "", params: { path: "a" } }); - const report = parse([emptyPlaceholder, malformed]); + const report = parse([emptyPlaceholder(undefined), emptyPlaceholder(null), malformed]); assert.deepEqual(report.toolCalls, []); assert.deepEqual(report.issues.map((issue) => issue.code), ["T2O_TRAE_TOOL_CALL_INVALID"]); }); diff --git a/src/source/trae/tool-calls.ts b/src/source/trae/tool-calls.ts index 979a7c3..5739c6f 100644 --- a/src/source/trae/tool-calls.ts +++ b/src/source/trae/tool-calls.ts @@ -126,7 +126,7 @@ export function parseTraeToolCalls( Object.keys(raw.result).length === 0; const isEmptyPlaceholder = isRuntimeObject(raw) && raw.name === "" && - raw.params === undefined && + (raw.params === undefined || raw.params === null) && (raw.result === undefined || raw.result === null || isEmptyResult); if (isEmptyPlaceholder) continue; const hasValidCall = diff --git a/src/target/__tests__/opencode-mapping.test.ts b/src/target/__tests__/opencode-mapping.test.ts index 16e6ec5..90dc253 100644 --- a/src/target/__tests__/opencode-mapping.test.ts +++ b/src/target/__tests__/opencode-mapping.test.ts @@ -10,6 +10,7 @@ import { MISSING_ASSISTANT_TEXT, MISSING_TOOL_ERROR_TEXT, MISSING_TOOL_OUTPUT_TEXT, + OPENCODE_MAPPING_VERSION, } from "../opencode/mapping.js"; const fixture = JSON.parse(readFileSync(new URL( @@ -59,7 +60,7 @@ describe("OpenCode IR mapping", () => { assert.equal(meta.status, "completed"); assert.equal( ((transfer.info.metadata as JsonObject).trae2opencode as JsonObject).mappingVersion, - 8, + OPENCODE_MAPPING_VERSION, ); assert.deepEqual((meta.content as JsonObject[]).slice(0, 2), [{ completedAt: 1700000002000 }, { createdAt: 1700000001001 }]); @@ -114,7 +115,10 @@ describe("OpenCode IR mapping", () => { assert.match(String(boundary.recent), /^\[Assistant\]: x+$/); assert.equal(Buffer.byteLength(String(boundary.recent), "utf8") <= 16 * 1024, true); assert.doesNotMatch(String(boundary.summary), /\[User\]|\[Assistant\]/); - assert.equal(((boundary.metadata as JsonObject).trae2opencode as JsonObject).mappingVersion, 8); + assert.equal( + ((boundary.metadata as JsonObject).trae2opencode as JsonObject).mappingVersion, + OPENCODE_MAPPING_VERSION, + ); assert.ok(diagnostics.some((item) => item.code === "T2O_OPENCODE_CONTINUATION_BOUNDARY")); }); @@ -391,6 +395,35 @@ describe("OpenCode IR mapping", () => { assert.throws(() => map(bundle), expectedRejection); }); + it("projects contradictory source completion time for partial sessions and preserves evidence", () => { + const bundle = structuredClone(fixture); + const event = assistant(bundle); + assert.ok(event.createdAt !== undefined); + const createdAt = event.createdAt; + event.completedAt = createdAt - 1_000; + assert.throws(() => map(bundle), expectedRejection); + + bundle.sessions[0].recovery = "partial"; + const projected = map(bundle); + const message = projected.transfer.messages[1]; + assert.deepEqual(message.time, { + created: createdAt, + completed: createdAt, + }); + const metadata = (message.metadata as JsonObject).trae2opencode as JsonObject; + assert.deepEqual(metadata.timeProjection, { + reason: "source-completion-precedes-creation", + source: { created: createdAt, completed: createdAt - 1_000 }, + target: { created: createdAt, completed: createdAt }, + }); + assert.ok(projected.diagnostics.some( + (item) => item.code === "T2O_OPENCODE_EVENT_TIME_PROJECTED", + )); + const marker = + (projected.transfer.info.metadata as JsonObject).trae2opencode as JsonObject; + assert.deepEqual(marker.projectedSourceCodes, ["T2O_IR_EVENT_TIME_INVALID"]); + }); + it("rejects duplicate message ids and projects broken replies only for partial sessions", () => { assert.throws(() => map(undefined, { messageIds: new Map() }), expectedRejection); assert.throws(() => map(undefined, { messageIds: new Map([ diff --git a/src/target/__tests__/opencode-v1-mapping.test.ts b/src/target/__tests__/opencode-v1-mapping.test.ts index 2a4cbad..45315f6 100644 --- a/src/target/__tests__/opencode-v1-mapping.test.ts +++ b/src/target/__tests__/opencode-v1-mapping.test.ts @@ -78,6 +78,32 @@ describe("OpenCode v1 IR mapping", () => { assert.deepEqual((parts[3].state as JsonObject).time, { start: 1700000002000, end: 1700000003000 }); }); + it("keeps source time evidence when normalizing a contradictory partial turn", () => { + const bundle = structuredClone(fixture); + bundle.sessions[0].recovery = "partial"; + const event = bundle.sessions[0].events[1]; + if (event?.type !== "assistant") throw new Error("fixture shape changed"); + assert.ok(event.createdAt !== undefined); + const createdAt = event.createdAt; + event.completedAt = createdAt - 1_000; + + const { transfer, diagnostics } = map(bundle); + const assistant = infoOf(transfer.messages[1]); + assert.deepEqual(assistant.time, { + created: createdAt, + completed: createdAt, + }); + const sourceEvent = (markerOf(transfer.info).events as JsonObject[])[1]; + assert.deepEqual(sourceEvent.timeProjection, { + reason: "source-completion-precedes-creation", + source: { created: createdAt, completed: createdAt - 1_000 }, + target: { created: createdAt, completed: createdAt }, + }); + assert.ok(diagnostics.some( + (item) => item.code === "T2O_OPENCODE_EVENT_TIME_PROJECTED", + )); + }); + it("carries per-event provenance on the session because v1 messages have no metadata", () => { const { transfer } = map(); const marker = markerOf(transfer.info); @@ -140,4 +166,4 @@ describe("OpenCode v1 IR mapping", () => { assert.deepEqual(parts[3].state, { status: "pending", input: {}, raw: "{\"path\":" }); assert.ok(diagnostics.some((issue) => issue.code === "T2O_OPENCODE_V1_TOOL_STATUS_PROJECTED")); }); -}); \ No newline at end of file +}); diff --git a/src/target/opencode/mapping.ts b/src/target/opencode/mapping.ts index a505977..f86c4e3 100644 --- a/src/target/opencode/mapping.ts +++ b/src/target/opencode/mapping.ts @@ -47,8 +47,10 @@ export const MISSING_ASSISTANT_TEXT = "[TRAE assistant response ended before final text was persisted]"; export const MAX_CONTINUATION_CONTEXT_BYTES = 192 * 1024; export const MAX_CONTINUATION_RECENT_BYTES = 16 * 1024; +export const OPENCODE_MAPPING_VERSION = 9; const PARTIAL_PROJECTION_CODES = new Set([ + "T2O_IR_EVENT_TIME_INVALID", "T2O_IR_REPLY_REFERENCE_INVALID", "T2O_TRAE_TOOL_CALL_INVALID", "T2O_TRAE_TOOL_ERROR_UNVERIFIED", @@ -95,8 +97,44 @@ function concernsSession(issue: Diagnostic, session: SessionIR): boolean { } function concernsEvent(issue: Diagnostic, event: EventIR): boolean { + const eventRefs = new Set(event.sourceRefs.map((ref) => + hashCanonicalJson(ref as unknown as JsonValue))); return (issue.subject?.type === "event" && issue.subject.sourceId === event.sourceId) || - issue.context?.sourceMessageId === event.sourceId; + issue.context?.sourceMessageId === event.sourceId || + issue.sourceRefs.some((ref) => eventRefs.has(hashCanonicalJson(ref as unknown as JsonValue))); +} + +interface EventTimeProjection { + created: number; + completed: number; + sourceCompleted: number; +} + +function projectedAssistantTime( + session: SessionIR, + event: Extract, + issues: Diagnostic[], + field: string, + diagnostics: Diagnostic[], +): EventTimeProjection | undefined { + const createdAt = event.createdAt; + if (createdAt === undefined) reject(session, `${field}.createdAt`); + if (event.completedAt === undefined || event.completedAt >= createdAt) return undefined; + const hasInvalidSourceTime = session.recovery === "partial" && + issues.some((issue) => + issue.code === "T2O_IR_EVENT_TIME_INVALID" && concernsEvent(issue, event)); + if (!hasInvalidSourceTime) reject(session, `${field}.time`); + diagnostics.push(diagnostic( + session, + "T2O_OPENCODE_EVENT_TIME_PROJECTED", + "A source completion time preceded its creation time; the target completion was clamped while the source values remain in metadata.", + `${field}.time`, + )); + return { + created: createdAt, + completed: createdAt, + sourceCompleted: event.completedAt, + }; } function hasErrorPayload(value: JsonValue | undefined): boolean { @@ -336,7 +374,11 @@ function hasValidReply(session: SessionIR, event: EventIR): boolean { return reply?.type === "user" && reply.order < event.order; } -function eventMetadata(event: EventIR, validReply: boolean): JsonObject { +function eventMetadata( + event: EventIR, + validReply: boolean, + timeProjection?: EventTimeProjection, +): JsonObject { const deferredContent = event.type === "assistant" ? event.content.flatMap((block, sourceIndex) => block.type === "tool" && block.createdAt === undefined @@ -354,6 +396,13 @@ function eventMetadata(event: EventIR, validReply: boolean): JsonObject { ...(event.type === "assistant" ? { status: event.status, unknownSourceFields: ["agent", "model"], + ...(timeProjection ? { + timeProjection: { + reason: "source-completion-precedes-creation", + source: { created: timeProjection.created, completed: timeProjection.sourceCompleted }, + target: { created: timeProjection.created, completed: timeProjection.completed }, + }, + } : {}), // Text has no native time field; the parallel metadata preserves every block's time. content: event.content.map((block) => ({ ...(block.createdAt === undefined ? {} : { createdAt: block.createdAt }), @@ -479,7 +528,7 @@ function addContinuationBoundaries( summary: "", recent: retainedContext, metadata: { trae2opencode: { - mappingVersion: 8, + mappingVersion: OPENCODE_MAPPING_VERSION, kind: "continuation-boundary", activeContextBytes: activeBytes, } }, @@ -562,9 +611,12 @@ export function mapOpenCodeSession( `${field}.replyToSourceId`, )); } + const timeProjection = event.type === "assistant" + ? projectedAssistantTime(session, event, issues, field, diagnostics) + : undefined; const common = { id: targetIds[index]!, - metadata: { trae2opencode: eventMetadata(event, validReply) }, + metadata: { trae2opencode: eventMetadata(event, validReply, timeProjection) }, }; if (event.type === "user") { return { ...common, type: "user", time: { created: event.createdAt }, text: event.text }; @@ -602,7 +654,10 @@ export function mapOpenCodeSession( } return { ...common, type: "assistant", - time: { created: event.createdAt, completed: event.completedAt }, + time: { + created: timeProjection?.created ?? event.createdAt, + completed: timeProjection?.completed ?? event.completedAt, + }, agent: "trae-import-unknown", model: { id: "unknown", providerID: "trae-import-unknown" }, ...(event.status === "error" || projectsUnknownState @@ -621,7 +676,7 @@ export function mapOpenCodeSession( time: { created: session.createdAt, updated: session.updatedAt }, location: { directory: options.directory }, metadata: { trae2opencode: { - mappingVersion: 8, sourceSessionId: session.sourceId, + mappingVersion: OPENCODE_MAPPING_VERSION, sourceSessionId: session.sourceId, sourceSessionSha256: hashCanonicalJson(session as unknown as JsonValue), unknownSourceFields: ["cost", "tokens", "agent", "model"], resourceCount: session.resources.length, diff --git a/src/target/opencode/v1/mapping.ts b/src/target/opencode/v1/mapping.ts index c57ae2b..25c55b5 100644 --- a/src/target/opencode/v1/mapping.ts +++ b/src/target/opencode/v1/mapping.ts @@ -3,12 +3,13 @@ import type { Diagnostic, JsonObject, JsonValue, MigrationBundle } from "../../. import { Trae2OpenCodeError } from "../../../shared/errors.js"; import { isRecord } from "../contract.js"; import { - mapOpenCodeSession, type OpenCodeMapping, type OpenCodeMappingOptions, type OpenCodeSession, + mapOpenCodeSession, OPENCODE_MAPPING_VERSION, type OpenCodeMapping, + type OpenCodeMappingOptions, type OpenCodeSession, } from "../mapping.js"; import { assertOpenCodeV1Session } from "./contract.js"; /** v1 uses role-based messages with native `parts`, so message metadata has no home there. */ -export const V1_MAPPING_VERSION = 8; +export const V1_MAPPING_VERSION = OPENCODE_MAPPING_VERSION; export interface OpenCodeV1Session extends OpenCodeSession { info: JsonObject & { @@ -262,6 +263,9 @@ export function mapOpenCodeV1Session( replyToSourceId: text(sourceMarker.replyToSourceId) ?? null, replyReferenceStatus: text(sourceMarker.replyReferenceStatus) ?? null, status: text(sourceMarker.status) ?? null, + ...(isRecord(sourceMarker.timeProjection) + ? { timeProjection: sourceMarker.timeProjection as JsonObject } + : {}), ...(Array.isArray(sourceMarker.content) ? { content: sourceMarker.content } : {}), }); if (text(message.type) === "user") { @@ -377,4 +381,4 @@ export function mapOpenCodeV1Session( }; assertOpenCodeV1Session(v1Transfer); return { transfer: structuredClone(v1Transfer), diagnostics }; -} \ No newline at end of file +} From 4c7246b0118297c43d94ccd9a93c7c69f7e26195 Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Tue, 29 Sep 2026 14:47:39 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(mapping):=20=E8=AF=B4=E6=98=8E=20subag?= =?UTF-8?q?ent=20=E6=97=B6=E9=97=B4=E6=8A=95=E5=BD=B1=E4=B8=8E=20mapping?= =?UTF-8?q?=20v9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/operation-manual.md | 6 +++++- docs/troubleshooting.md | 7 +++++-- docs/versioning.md | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/operation-manual.md b/docs/operation-manual.md index 746abd2..c06054f 100644 --- a/docs/operation-manual.md +++ b/docs/operation-manual.md @@ -327,6 +327,10 @@ npm install -g @opencode/cli@2.0.16 或出现无法安全脱敏的字段。保留本地产物,阅读[故障排查](troubleshooting.md)中对应的 `T2O_*` 错误码;不要通过编辑 JSON 补造字段。 +当前 mapping v9 能识别 subagent 历史中的空工具占位符;TRAE 若为这类历史保留了 +早于消息创建时间的实际执行窗口,迁移会显式归一目标时间,并将原始时间和投影原因 +保存在 metadata 中。其他缺失时间、无效身份或无法验证的工具数据仍会安全停止。 + ### 看到“已自动将疑似凭据替换为脱敏占位符” 这是正常提示。可安全识别的凭据会被替换为 `[REDACTED_SECRET]`,迁移继续进行且会话 @@ -343,7 +347,7 @@ bundle 内容来缩小文件。 这是旧 mapping v6 生成的无序消息 ID 或无界历史上下文造成的兼容问题。更新本仓库后 重新运行 `npm run migrate:local`,选择同一会话并按提示输入 `OVERWRITE`,让工具以 -mapping v8 重新导入。v8 还会将 checkpoint 的角色化上下文放入不直接展示的 `recent` +当前 mapping v9 重新导入。v9 延续 v8 的 checkpoint 设计,并将角色化上下文放入不直接展示的 `recent` 字段,避免 `[User]`、`[Assistant]` 摘录在时间线中重复显示。 如果该目标会话已在 OpenCode 中继续过对话,受保护覆盖会拒绝删除它。先保留或导出新增 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0018710..e128f86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -81,14 +81,14 @@ macOS 的 `open -a "Trae CN" --args ...` 经 3.3.104 实机确认可能丢弃调 mapping v6 的哈希消息 ID 不满足 OpenCode Desktop 2.0.16 对时间顺序的字符串比较, 实时 Revert 后可能暂时清空当前窗口;超大历史也可能在首次自动 compaction 时被 provider -拒绝。mapping v8 使用稳定递增 ID,并为超大历史加入原生 completed-compaction 边界; +拒绝。当前 mapping v9 使用稳定递增 ID,并为超大历史加入原生 completed-compaction 边界; 角色化摘录写入只供后续模型读取的 `recent`,可见 `summary` 保持为空,因此不会把 `[User]`、`[Assistant]` 重复显示为普通正文。原始时间线仍完整保留。 更新代码后重新执行 `npm run migrate:local` 并按提示安全覆盖。目标会话若已在 OpenCode 中新增内容,覆盖保护会停止;先保留新增内容,再人工删除旧目标会话并重新迁移。 -### 迁移到 OpenCode 1.x(mapping v8) +### 迁移到 OpenCode 1.x(mapping v9) v1 没有 `SessionTransfer` 路由,也没有 carry-summary 的 compaction 消息,因此: @@ -103,6 +103,9 @@ v1 没有 `SessionTransfer` 路由,也没有 carry-summary 的 compaction 消 (sourceId、order、replyToSourceId)改存于会话级 `metadata.trae2opencode.events`。 - `T2O_OPENCODE_V1_TOOL_STATUS_PROJECTED`:被中断的流式工具在 v1 中只能表示为 `pending` + 原始片段,不臆造结果。 +- `T2O_OPENCODE_EVENT_TIME_PROJECTED`:TRAE 合并 subagent 历史后可能保留早于 + `created_at` 的实际执行窗口。目标时间会归一为不倒退,原始创建/完成时间与投影原因 + 保存在迁移 metadata 中,不伪装成精确的目标时间。 - 源会话若缺少 assistant 完成时间或可解析的回复对象,v1 无法无损表示;这些会话会被 `T2O_OPENCODE_V1_UNSUPPORTED_STATE` 阻止,而不是写入不完整数据。 diff --git a/docs/versioning.md b/docs/versioning.md index ebc47a3..f582124 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -40,7 +40,7 @@ Changelog 一致。Release PR 由 Actions token 创建时不会自然触发其 | --- | ---: | --- | | `schemaVersion` | 1 | TRAE 中间格式(IR)结构 | | `manifestVersion` | 1 | 迁移记录、续跑与回滚结构 | -| `mappingVersion` | 8 | IR 到 OpenCode 的映射语义 | +| `mappingVersion` | 9 | IR 到 OpenCode 的映射语义 | | `reportVersion` | 3 | OpenCode 独立验证报告结构 | 格式版本只在对应结构或语义变化时递增。一次产品发布可以不改变任何格式版本,也可以 From 43dfe131e3d38f0732c0b49ecaf82f362969f219 Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Tue, 29 Sep 2026 15:38:36 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(migration):=20=E9=87=8D=E5=BB=BA=20Ope?= =?UTF-8?q?nCode=20=E5=8E=9F=E7=94=9F=20subagent=20=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fixtures/ir/v1/golden/manifest.json | 2 +- schemas/migration-bundle.v1.schema.json | 14 + src/ir/__tests__/integrity.test.ts | 19 ++ src/ir/integrity.ts | 19 +- src/ir/schema.ts | 5 + src/ir/types.ts | 3 + src/migration/__tests__/plan.test.ts | 37 +++ src/migration/plan.ts | 5 + src/source/__tests__/assemble-bundle.test.ts | 71 ++++- .../__tests__/profile-definitions.test.ts | 3 +- src/source/__tests__/subagent-runs.test.ts | 94 +++++++ src/source/trae/assemble-bundle.ts | 216 ++++++++++++++- src/source/trae/assistant-messages.ts | 50 +++- src/source/trae/collect.ts | 20 +- src/source/trae/index.ts | 7 + src/source/trae/profile-definitions.ts | 4 +- src/source/trae/subagent-runs.ts | 256 ++++++++++++++++++ src/target/__tests__/opencode-mapping.test.ts | 48 ++++ src/target/opencode/mapping.ts | 47 +++- 19 files changed, 877 insertions(+), 43 deletions(-) create mode 100644 src/source/__tests__/subagent-runs.test.ts create mode 100644 src/source/trae/subagent-runs.ts diff --git a/fixtures/ir/v1/golden/manifest.json b/fixtures/ir/v1/golden/manifest.json index 975a543..6385e61 100644 --- a/fixtures/ir/v1/golden/manifest.json +++ b/fixtures/ir/v1/golden/manifest.json @@ -14,6 +14,6 @@ } ], "goldenVersion": 1, - "schemaSha256": "sha256:9a88ece7780cb6a0ade93ce0660a05de5d732d77d016788210a982d817f040a4", + "schemaSha256": "sha256:4d7da690de8ee426578a4e1e6ef8c86125c0dd1840ca6e816a46f681139325c7", "schemaVersion": 1 } diff --git a/schemas/migration-bundle.v1.schema.json b/schemas/migration-bundle.v1.schema.json index f698057..a99e1ef 100644 --- a/schemas/migration-bundle.v1.schema.json +++ b/schemas/migration-bundle.v1.schema.json @@ -370,6 +370,12 @@ "text": { "type": "string" }, + "presentation": { + "enum": [ + "progress", + "response" + ] + }, "createdAt": true, "completedAt": true, "sourceRefs": true @@ -452,6 +458,10 @@ }, "createdAt": true, "completedAt": true, + "childSessionSourceId": { + "type": "string", + "minLength": 1 + }, "sourceRefs": true } } @@ -613,6 +623,10 @@ "type": "string", "minLength": 1 }, + "derivedFromSourceSessionId": { + "type": "string", + "minLength": 1 + }, "createdAt": { "type": "integer", "minimum": 0 diff --git a/src/ir/__tests__/integrity.test.ts b/src/ir/__tests__/integrity.test.ts index 6fd6d54..81d535e 100644 --- a/src/ir/__tests__/integrity.test.ts +++ b/src/ir/__tests__/integrity.test.ts @@ -47,6 +47,25 @@ describe("validateMigrationBundleIntegrity", () => { ); }); + it("accepts ancestor-backed provenance for reconstructed child sessions", () => { + const bundle = input(); + const parent = bundle.sessions[0]; + const child = structuredClone(parent); + child.sourceId = "subagent-child"; + child.parentSourceId = parent.sourceId; + child.derivedFromSourceSessionId = parent.sourceId; + bundle.sessions.push(child); + assert.deepEqual(validateMigrationBundleIntegrity(bundle), []); + + child.derivedFromSourceSessionId = "absent-source"; + assert.deepEqual(new Set(codes(bundle)), new Set([ + "T2O_IR_DERIVED_SOURCE_MISSING", + "T2O_IR_DERIVED_SOURCE_INVALID", + "T2O_IR_EVENT_SOURCE_MISMATCH", + "T2O_IR_CONTENT_SOURCE_MISMATCH", + ])); + }); + it("walks a deep parent chain iteratively", () => { const bundle = input(); const template = bundle.sessions[0]; diff --git a/src/ir/integrity.ts b/src/ir/integrity.ts index b9b7919..fba2c15 100644 --- a/src/ir/integrity.ts +++ b/src/ir/integrity.ts @@ -35,7 +35,21 @@ export function validateMigrationBundleIntegrity(value: unknown): Diagnostic[] { if (session.parentSourceId && !sessions.has(session.parentSourceId)) { report("T2O_IR_PARENT_REFERENCE_MISSING", "An IR session refers to an absent parent."); } + if (session.derivedFromSourceSessionId && !sessions.has(session.derivedFromSourceSessionId)) { + report("T2O_IR_DERIVED_SOURCE_MISSING", "An IR session refers to an absent evidence source session."); + } if (parentCycles.has(session.sourceId)) report("T2O_IR_PARENT_CYCLE", "IR parent relationships contain a cycle."); + const ancestors = new Set(); + let ancestor = session.parentSourceId; + while (ancestor !== undefined && !ancestors.has(ancestor)) { + ancestors.add(ancestor); + ancestor = sessions.get(ancestor)?.parentSourceId; + } + if (session.derivedFromSourceSessionId && + !ancestors.has(session.derivedFromSourceSessionId)) { + report("T2O_IR_DERIVED_SOURCE_INVALID", "An IR evidence source session is not an ancestor."); + } + const evidenceSourceId = session.derivedFromSourceSessionId ?? session.sourceId; const invalidSessionTime = session.createdAt !== undefined && session.updatedAt !== undefined && session.updatedAt < session.createdAt; if (invalidSessionTime) report("T2O_IR_SESSION_TIME_INVALID", "IR session timestamps are inconsistent."); @@ -52,7 +66,7 @@ export function validateMigrationBundleIntegrity(value: unknown): Diagnostic[] { } previousOrder = event.order; const hasForeignSource = event.sourceRefs.some((ref) => - ref.sourceSessionId !== null && ref.sourceSessionId !== session.sourceId); + ref.sourceSessionId !== null && ref.sourceSessionId !== evidenceSourceId); if (hasForeignSource) eventIssue("T2O_IR_EVENT_SOURCE_MISMATCH", "An IR event source belongs to another session."); if (event.type !== "assistant") continue; const reply = event.replyToSourceId ? events.get(event.replyToSourceId) : undefined; @@ -67,7 +81,8 @@ export function validateMigrationBundleIntegrity(value: unknown): Diagnostic[] { const invalidTime = block.createdAt !== undefined && block.completedAt !== undefined && block.completedAt < block.createdAt; if (invalidTime) eventIssue("T2O_IR_CONTENT_TIME_INVALID", "IR content timestamps are inconsistent."); - if (block.sourceRefs.some((ref) => ref.sourceSessionId !== null && ref.sourceSessionId !== session.sourceId)) { + if (block.sourceRefs.some((ref) => + ref.sourceSessionId !== null && ref.sourceSessionId !== evidenceSourceId)) { eventIssue("T2O_IR_CONTENT_SOURCE_MISMATCH", "An IR content source belongs to another session."); } } diff --git a/src/ir/schema.ts b/src/ir/schema.ts index 19eef01..4bfbc2d 100644 --- a/src/ir/schema.ts +++ b/src/ir/schema.ts @@ -280,6 +280,9 @@ export const migrationBundleSchema = { text: { type: "string", }, + presentation: { + enum: ["progress", "response"], + }, createdAt: true, completedAt: true, sourceRefs: true, @@ -352,6 +355,7 @@ export const migrationBundleSchema = { }, createdAt: true, completedAt: true, + childSessionSourceId: nonEmptyString, sourceRefs: true, }, }, @@ -460,6 +464,7 @@ export const migrationBundleSchema = { projectSourceId: nonEmptyString, projectPath: nonEmptyString, parentSourceId: nonEmptyString, + derivedFromSourceSessionId: nonEmptyString, createdAt: timestamp, updatedAt: timestamp, recovery: { diff --git a/src/ir/types.ts b/src/ir/types.ts index 6958c16..bad7c26 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -80,6 +80,7 @@ export interface ContentBaseIR { export interface TextContentIR extends ContentBaseIR { type: "text"; text: string; + presentation?: "progress" | "response"; } export interface ReasoningContentIR extends ContentBaseIR { @@ -98,6 +99,7 @@ export interface ToolContentIR extends ContentBaseIR { type: "tool"; callId: string; name: string; + childSessionSourceId?: string; input: JsonValue; output?: JsonValue; error?: JsonValue; @@ -135,6 +137,7 @@ export interface SessionIR { projectSourceId?: string; projectPath?: string; parentSourceId?: string; + derivedFromSourceSessionId?: string; createdAt?: number; updatedAt?: number; recovery: RecoveryGrade; diff --git a/src/migration/__tests__/plan.test.ts b/src/migration/__tests__/plan.test.ts index 236cd39..8a0f070 100644 --- a/src/migration/__tests__/plan.test.ts +++ b/src/migration/__tests__/plan.test.ts @@ -65,6 +65,43 @@ describe("migration plan", () => { assert.deepEqual(plan.sessions.map((item) => item.status), ["blocked", "blocked"]); }); + it("resolves subagent tool links through the complete session identity map", async () => { + const bundle = await fixture(); + const parent = bundle.sessions[0]; + const child = structuredClone(parent); + child.sourceId = "subagent-child"; + child.parentSourceId = parent.sourceId; + child.derivedFromSourceSessionId = parent.sourceId; + bundle.sessions.push(child); + const event = parent.events[1]; + assert.equal(event.type, "assistant"); + if (event.type !== "assistant") throw new Error("fixture shape changed"); + event.content.push({ + type: "tool", + callId: "subagent-call", + name: "subagent", + input: { agent: "Reviewer", description: "Review", prompt: "Review" }, + output: "Done", + status: "completed", + createdAt: 1_700_000_002_000, + completedAt: 1_700_000_003_000, + childSessionSourceId: child.sourceId, + sourceRefs: structuredClone(event.sourceRefs), + }); + + const plan = await buildMigrationPlan(bundle, options); + assert.deepEqual(plan.sessions.map((item) => item.status), ["ready", "ready"]); + const parentItem = plan.sessions.find((item) => item.sourceId === parent.sourceId); + const childItem = plan.sessions.find((item) => item.sourceId === child.sourceId); + const tools = parentItem?.transfer?.messages.flatMap((message) => + Array.isArray(message.content) ? message.content : []); + const subagent = tools?.find((block) => + isRecord(block) && block.type === "tool" && block.name === "subagent"); + assert.ok(isRecord(subagent) && isRecord(subagent.state)); + assert.equal((subagent.state.metadata as Record).sessionID, childItem?.targetId); + assert.equal(childItem?.parentId, parentItem?.targetId); + }); + it("isolates unimportable messages from another valid session", async () => { const bundle = await fixture(); bundle.sessions.push(renamed(bundle, "valid")); diff --git a/src/migration/plan.ts b/src/migration/plan.ts index f6c74f9..c6e7d6d 100644 --- a/src/migration/plan.ts +++ b/src/migration/plan.ts @@ -94,6 +94,10 @@ export async function buildMigrationPlan( const targetVersion = options.targetVersion ?? OPENCODE_VERSION; const reconciliation = targetReconciliation(dialect); const identities = createOpenCodeIdentityMap(bundle, namespace); + const sessionIds = new Map(identities.map((identity) => [ + identity.sourceSessionId, + identity.sessionId, + ])); const sessions = new Map(bundle.sessions.map((session) => [session.sourceId, session])); const prepared = identities.map((identity) => { const session = sessions.get(identity.sourceSessionId)!; @@ -109,6 +113,7 @@ export async function buildMigrationPlan( // Mapping snapshots the caller-owned transcript before the first async directory check. mapping = mapTargetSession(bundle, session.sourceId, { ...identity, + sessionIds, directory: process.cwd(), dialect, protocolRule: options.protocolRule, targetVersion, diff --git a/src/source/__tests__/assemble-bundle.test.ts b/src/source/__tests__/assemble-bundle.test.ts index b8da335..f75d8db 100644 --- a/src/source/__tests__/assemble-bundle.test.ts +++ b/src/source/__tests__/assemble-bundle.test.ts @@ -112,7 +112,7 @@ describe("assembleTraeMigrationBundle", () => { ); }); - it("keeps subagent turns recoverable with null empty tool placeholders and source time evidence", () => { + it("reconstructs inline subagent history as a native child session", () => { const options = input(); const assistant = messages(options)[0]; assistant.agent_type = "solo_agent"; @@ -121,13 +121,51 @@ describe("assembleTraeMigrationBundle", () => { assistant.chat_end_time = 1_700_000_004_000; assistant.content = { messages: [ + { + type: "plan_item", + plan_item: { + id: "plan-parent", + agent_run_id: "run-parent", + agent_id: "solo_agent", + agent_display_name: "SOLO", + thought: "Delegating the review.", + }, + }, { type: "plan_item", plan_item: { id: "plan-subagent", agent_run_id: "run-subagent", + parent_agent_run_ids: ["run-parent"], + agent_id: "reviewer", + agent_display_name: "Reviewer", + agent_status: { status: "running", run_mode: "foreground" }, sub_agent_call_description: "Synthetic subagent task.", - thought: "Synthetic persisted progress.", + thought: "Inspecting the change.", + timing: { + generated_at_ms: 1_700_000_002_000, + tool_call_started_at_ms: 1_700_000_002_100, + tool_call_finished_at_ms: 1_700_000_002_500, + }, + tool_call_info: { + id: "call-subagent-read", + name: "read_file", + params: { path: "example.txt" }, + result: { status: "success", data: "source" }, + }, + }, + }, + { + type: "plan_item", + plan_item: { + id: "plan-subagent-complete", + agent_run_id: "run-subagent", + parent_agent_run_ids: ["run-parent"], + agent_id: "reviewer", + agent_display_name: "Reviewer", + agent_status: { status: "completed", run_mode: "foreground" }, + thought: "The review is complete.", + timing: { generated_at_ms: 1_700_000_003_000 }, tool_call_info: { id: "placeholder-subagent", name: "", @@ -142,18 +180,43 @@ describe("assembleTraeMigrationBundle", () => { }; const bundle = assembleTraeMigrationBundle(options); - const session = bundle.sessions[0]; + assert.equal(bundle.sessions.length, 2); + const [session, child] = bundle.sessions; assert.equal(session.recovery, "partial"); const event = session.events[1]; assert.equal(event.type, "assistant"); if (event.type !== "assistant") throw new Error("fixture shape changed"); - assert.equal(event.content.some((block) => block.type === "tool"), false); + const subagent = event.content.find((block) => + block.type === "tool" && block.name === "subagent"); + assert.ok(subagent?.type === "tool"); + assert.equal(subagent.childSessionSourceId, child.sourceId); + assert.deepEqual(subagent.input, { + agent: "Reviewer", + description: "Synthetic subagent task.", + prompt: "Synthetic subagent task.", + }); + assert.equal(child.parentSourceId, session.sourceId); + assert.equal(child.derivedFromSourceSessionId, session.sourceId); + assert.equal(child.title, "Synthetic subagent task."); + assert.deepEqual(child.events.map((item) => item.type), ["user", "assistant"]); + const childAssistant = child.events[1]; + assert.equal(childAssistant.type, "assistant"); + if (childAssistant.type !== "assistant") throw new Error("fixture shape changed"); + assert.equal(childAssistant.content.some((block) => + block.type === "tool" && block.name === "read_file"), true); + assert.equal(childAssistant.content.some((block) => + block.type === "text" && block.presentation === "response"), true); assert.ok(bundle.diagnostics.some( (diagnostic) => diagnostic.code === "T2O_IR_EVENT_TIME_INVALID", )); assert.equal(bundle.diagnostics.some( (diagnostic) => diagnostic.code === "T2O_TRAE_TOOL_CALL_INVALID", ), false); + const selected = selectBundle(bundle, { session: session.sourceId }); + assert.deepEqual(selected.sessions.map((item) => item.sourceId), [ + session.sourceId, + child.sourceId, + ]); }); it("deduplicates identical runtime observations without changing the IR", () => { diff --git a/src/source/__tests__/profile-definitions.test.ts b/src/source/__tests__/profile-definitions.test.ts index f4e56ac..9bbb504 100644 --- a/src/source/__tests__/profile-definitions.test.ts +++ b/src/source/__tests__/profile-definitions.test.ts @@ -25,7 +25,8 @@ describe("TRAE parser profile definitions", () => { assert.equal(workspace.sourceKind, "workspace"); assert.equal(runtime.verification, "verified"); assert.deepStrictEqual(runtime.capabilities, [ - "runtime-metadata", "user-messages", "assistant-messages", "reasoning-plan", "tool-calls", + "runtime-metadata", "user-messages", "assistant-messages", "reasoning-plan", + "subagent-runs", "tool-calls", ]); assert.deepStrictEqual(workspace.capabilities, ["session-metadata", "query-cache", "resources"]); }); diff --git a/src/source/__tests__/subagent-runs.test.ts b/src/source/__tests__/subagent-runs.test.ts new file mode 100644 index 0000000..fb8d049 --- /dev/null +++ b/src/source/__tests__/subagent-runs.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { parseTraeSubagentRuns } from "../trae/subagent-runs.js"; + +function content(overrides: Record = {}) { + const shared = { + agent_run_id: "run-child", + parent_agent_run_ids: ["run-parent"], + agent_id: "reviewer", + agent_display_name: "Reviewer", + sub_agent_call_description: "Review the change", + }; + return { + messages: [ + { + type: "plan_item", + plan_item: { + ...shared, + id: "plan-child-start", + agent_status: { status: "running", run_mode: "foreground" }, + timing: { + generated_at_ms: 1_700_000_001_000, + tool_call_started_at_ms: 1_700_000_001_100, + tool_call_finished_at_ms: 1_700_000_001_500, + }, + ...overrides, + }, + }, + { + type: "plan_item", + plan_item: { + ...shared, + id: "plan-child-complete", + agent_status: { status: "completed", run_mode: "foreground" }, + thought: "Review complete", + timing: { generated_at_ms: 1_700_000_002_000 }, + tool_call_info: { name: "", params: null, result: {} }, + }, + }, + ], + }; +} + +describe("parseTraeSubagentRuns", () => { + it("requires consistent parent, agent, description and completion evidence", () => { + const report = parseTraeSubagentRuns(content(), "task", "3.3.104"); + assert.deepEqual(report.issues, []); + assert.equal(report.runs.length, 1); + assert.deepEqual(report.runs[0], { + sourceRunId: "run-child", + parentRunIds: ["run-parent"], + agentId: "reviewer", + agentDisplayName: "Reviewer", + description: "Review the change", + runMode: "foreground", + entryIndexes: [0, 1], + responseEntryIndex: 1, + createdAt: 1_700_000_001_000, + completedAt: 1_700_000_002_000, + sources: report.runs[0].sources, + }); + assert.equal(report.runs[0].sources.length, 2); + }); + + it("keeps conflicting or incomplete runs out of reconstruction", () => { + const conflict = content({ parent_agent_run_ids: ["another-parent"] }); + const conflictReport = parseTraeSubagentRuns(conflict, "task", "3.3.104"); + assert.deepEqual(conflictReport.runs, []); + assert.ok(conflictReport.issues.some((issue) => + issue.code === "T2O_TRAE_SUBAGENT_RUN_CONFLICT")); + + const incomplete = content(); + const terminal = incomplete.messages[1].plan_item as Record; + terminal.agent_status = { status: "running", run_mode: "foreground" }; + const incompleteReport = parseTraeSubagentRuns(incomplete, "task", "3.3.104"); + assert.deepEqual(incompleteReport.runs, []); + assert.ok(incompleteReport.issues.some((issue) => + issue.code === "T2O_TRAE_SUBAGENT_RUN_INCOMPLETE")); + }); + + it("ignores ordinary plan items and rejects unsupported product versions", () => { + assert.deepEqual(parseTraeSubagentRuns({ + messages: [{ type: "plan_item", plan_item: { id: "ordinary-plan" } }], + }, "task", "3.3.104"), { runs: [], issues: [] }); + assert.deepEqual(parseTraeSubagentRuns(content(), "general", "3.3.104"), { + runs: [], + issues: [], + }); + assert.throws( + () => parseTraeSubagentRuns(content(), "task", "3.4.0"), + { code: "T2O_TRAE_ASSISTANT_MESSAGE_VERSION_UNSUPPORTED" }, + ); + }); +}); diff --git a/src/source/trae/assemble-bundle.ts b/src/source/trae/assemble-bundle.ts index 4ec78ab..3bd4a98 100644 --- a/src/source/trae/assemble-bundle.ts +++ b/src/source/trae/assemble-bundle.ts @@ -12,6 +12,7 @@ import { runtimeHash } from "./reasoning-plan.js"; import { gradeSessionRecovery } from "./recovery-grading.js"; import type { TraeResourceReport, TraeResourceSource } from "./resources.js"; import type { TraeSessionMetadata, TraeSessionMetadataReport } from "./session-metadata.js"; +import type { TraeSubagentRun } from "./subagent-runs.js"; import type { TraeUserMessage } from "./user-messages.js"; import type { WorkspaceResolutionReport } from "./workspace-resolution.js"; @@ -36,7 +37,7 @@ export interface AssembleTraeBundleOptions { interface SourceIssue { code: string; - severity: "warning" | "error"; + severity: "info" | "warning" | "error"; message: string; sourceSessionId?: string; sourceMessageId?: string; @@ -89,10 +90,19 @@ function resourceRef(source: TraeResourceSource): SourceRef { }; } -function assistantContent(message: TraeAssistantMessage, workspaces: string[]): AssistantContentIR[] { +interface PositionedAssistantContent { + index: number; + rank: number; + block: AssistantContentIR; +} + +function positionedAssistantContent( + message: TraeAssistantMessage, + workspaces: string[], +): PositionedAssistantContent[] { const refs = (source: { locator: string; sha256: string }) => runtimeRefs(message.sourceSessionId, workspaces, messageLocator(message.sourceMessageId, source.locator), source.sha256); - const blocks: { index: number; rank: number; block: AssistantContentIR }[] = []; + const blocks: PositionedAssistantContent[] = []; for (const text of message.textBlocks) { const match = /^content\.messages\[(\d+)\]/.exec(text.source.locator); blocks.push({ @@ -120,10 +130,112 @@ function assistantContent(message: TraeAssistantMessage, workspaces: string[]): }, }); } - return blocks.sort((a, b) => a.index - b.index || a.rank - b.rank).map(({ block }) => block); + return blocks.sort((a, b) => a.index - b.index || a.rank - b.rank); +} + +function assistantContent(message: TraeAssistantMessage, workspaces: string[]): AssistantContentIR[] { + return positionedAssistantContent(message, workspaces).map(({ block }) => block); +} + +function subagentSessionSourceId(sourceSessionId: string, sourceRunId: string): string { + return `subagent-${runtimeHash({ sourceSessionId, sourceRunId }).slice(7)}`; +} + +function subagentCallId(sourceSessionId: string, sourceMessageId: string, sourceRunId: string): string { + return `subagent-${runtimeHash({ sourceSessionId, sourceMessageId, sourceRunId }).slice(7)}`; } -function eventIR(message: TraeUserMessage | TraeAssistantMessage, workspaces: string[]): EventIR { +function runRefs( + message: TraeAssistantMessage, + run: TraeSubagentRun, + workspaces: string[], +): SourceRef[] { + return uniqueRefs(run.sources.flatMap((source) => + runtimeRefs( + message.sourceSessionId, + workspaces, + messageLocator(message.sourceMessageId, source.locator), + source.sha256, + ))); +} + +function subagentOutput(content: readonly AssistantContentIR[]): string | undefined { + const output = [...content].reverse().find((block) => + block.type === "text" && + (block.presentation === "response" || + block.sourceRefs.some((ref) => + ref.locator.value.endsWith(".plan_item.tool_call_info.params.summary")))); + return output?.type === "text" ? output.text : undefined; +} + +interface SubagentProjection { + run: TraeSubagentRun; + sourceId: string; + content: AssistantContentIR[]; + sourceRefs: SourceRef[]; +} + +function projectAssistantSubagents( + message: TraeAssistantMessage, + workspaces: string[], + runs: readonly TraeSubagentRun[] = message.subagentRuns, +): { content: AssistantContentIR[]; subagents: SubagentProjection[] } { + const positioned = positionedAssistantContent(message, workspaces); + const childIndexes = new Set(runs.flatMap((run) => run.entryIndexes)); + const subagents = runs.map((run): SubagentProjection => { + const indexes = new Set(run.entryIndexes); + const content = positioned + .filter((item) => indexes.has(item.index)) + .map(({ index, block }) => + block.type === "text" && index === run.responseEntryIndex + ? { ...block, presentation: "response" as const } + : block); + return { + run, + sourceId: subagentSessionSourceId(message.sourceSessionId, run.sourceRunId), + content, + sourceRefs: runRefs(message, run, workspaces), + }; + }); + const parent = positioned.filter((item) => !childIndexes.has(item.index)); + for (const subagent of subagents) { + const run = subagent.run; + const output = subagentOutput(subagent.content); + parent.push({ + index: run.entryIndexes[0], + rank: 2, + block: { + type: "tool", + callId: subagentCallId(message.sourceSessionId, message.sourceMessageId, run.sourceRunId), + name: "subagent", + input: { + agent: run.agentDisplayName ?? run.agentId, + description: run.description, + prompt: run.description, + ...(run.runMode === "background" ? { background: true } : {}), + }, + ...(output === undefined ? {} : { output }), + status: "completed", + createdAt: run.createdAt, + completedAt: run.completedAt, + childSessionSourceId: subagent.sourceId, + sourceRefs: subagent.sourceRefs, + }, + }); + } + return { + content: parent + .sort((left, right) => left.index - right.index || left.rank - right.rank) + .map(({ block }) => block), + subagents, + }; +} + +function eventIR( + message: TraeUserMessage | TraeAssistantMessage, + workspaces: string[], + projectedContent?: AssistantContentIR[], +): EventIR { const sourceRefs = message.sources.flatMap((source) => runtimeRefs(message.sourceSessionId, workspaces, messageLocator(message.sourceMessageId), source.sha256)); const base = { @@ -142,7 +254,7 @@ function eventIR(message: TraeUserMessage | TraeAssistantMessage, workspaces: st ...base, type: "assistant", turnSourceId: message.turnId, replyToSourceId: message.replyToMessageId, status: message.status === "in-progress" ? "running" : message.status, ...(message.completedAt === undefined ? {} : { completedAt: message.completedAt }), - content: assistantContent(message, workspaces), + content: projectedContent ?? assistantContent(message, workspaces), }; } @@ -262,7 +374,17 @@ export function assembleTraeMigrationBundle(options: AssembleTraeBundleOptions): for (const message of parsedMessages) { countsById.set(message.sourceMessageId, (countsById.get(message.sourceMessageId) ?? 0) + 1); } + const subagentRunCounts = new Map(); + for (const message of parsed?.assistants.messages ?? []) { + for (const run of message.subagentRuns) { + subagentRunCounts.set(run.sourceRunId, (subagentRunCounts.get(run.sourceRunId) ?? 0) + 1); + } + } + const allSubagentRunIds = new Set(subagentRunCounts.keys()); + const isDirectSubagent = (run: TraeSubagentRun) => + !run.parentRunIds.some((parentRunId) => allSubagentRunIds.has(parentRunId)); const events: EventIR[] = []; + const subagentProjections: SubagentProjection[] = []; let invalidMessages = 0; for (const message of parsedMessages) { const foreignSession = message.sourceSessionId !== sourceId; @@ -276,12 +398,37 @@ export function assembleTraeMigrationBundle(options: AssembleTraeBundleOptions): severity: "error", message: "A runtime message identity conflicts with its session or another role.", sourceSessionId: sourceId, sourceMessageId: message.sourceMessageId, }, refs); - } else events.push(eventIR(message, workspaceIds)); + } else if ("text" in message) { + events.push(eventIR(message, workspaceIds)); + } else { + const runs = message.subagentRuns.filter((run) => + subagentRunCounts.get(run.sourceRunId) === 1 && isDirectSubagent(run)); + const projection = projectAssistantSubagents(message, workspaceIds, runs); + events.push(eventIR(message, workspaceIds, projection.content)); + subagentProjections.push(...projection.subagents); + } } events.sort((a, b) => a.order - b.order || a.sourceId.localeCompare(b.sourceId)); sourceRefs = uniqueRefs([...sourceRefs, ...events.flatMap((event) => event.sourceRefs)]); const rawRefs = read.status === "available" ? runtimeRefs(sourceId, workspaceIds, "runtime:getMessages#response", runtimeHash(read.value)) : []; + if ([...subagentRunCounts.values()].some((count) => count > 1)) { + warn( + "T2O_TRAE_SUBAGENT_RUN_SPANS_MESSAGES", + "A TRAE subagent run spans multiple assistant records and remains inline.", + sourceId, + rawRefs, + ); + } + if ((parsed?.assistants.messages ?? []).some((message) => + message.subagentRuns.some((run) => !isDirectSubagent(run)))) { + warn( + "T2O_TRAE_NESTED_SUBAGENT_INLINE", + "A nested TRAE subagent run remains inline because only direct child topology is verified.", + sourceId, + rawRefs, + ); + } const parseIssues = [...(parsed?.users.issues ?? []), ...(parsed?.assistants.issues ?? [])]; const eventsById = new Map(events.map((event) => [event.sourceId, event])); for (const issue of parseIssues) { @@ -333,14 +480,65 @@ export function assembleTraeMigrationBundle(options: AssembleTraeBundleOptions): sourceCorrupt: false, }); for (const reason of assessment.missingReasons) warn(reason.code, reason.message, sourceId, sourceRefs); - sessions.push({ + const rootSession: SessionIR = { sourceId, ...(meta?.title === undefined ? {} : { title: meta.title }), ...(meta?.createdAt === undefined ? {} : { createdAt: meta.createdAt }), ...(meta?.updatedAt === undefined ? {} : { updatedAt: meta.updatedAt }), ...(meta?.parentSourceId === undefined ? {} : { parentSourceId: meta.parentSourceId }), ...(project ? { projectSourceId: project.sourceId, projectPath: project.path } : {}), recovery: assessment.recovery, events, resources, sourceRefs, - }); + }; + sessions.push(rootSession); + + for (const projection of subagentProjections) { + const { run } = projection; + const promptId = `${projection.sourceId}:prompt`; + const responseId = `${projection.sourceId}:response`; + const assistantRefs = uniqueRefs([ + ...projection.sourceRefs, + ...projection.content.flatMap((block) => block.sourceRefs), + ]); + sessions.push({ + sourceId: projection.sourceId, + title: run.description, + ...(project ? { projectSourceId: project.sourceId, projectPath: project.path } : {}), + parentSourceId: sourceId, + derivedFromSourceSessionId: sourceId, + createdAt: run.createdAt, + updatedAt: run.completedAt, + recovery: "partial", + events: [ + { + sourceId: promptId, + type: "user", + order: 0, + createdAt: run.createdAt, + text: run.description, + sourceRefs: projection.sourceRefs, + }, + { + sourceId: responseId, + type: "assistant", + order: 1, + createdAt: run.createdAt, + completedAt: run.completedAt, + turnSourceId: run.sourceRunId, + replyToSourceId: promptId, + status: "completed", + content: projection.content, + sourceRefs: assistantRefs, + }, + ], + resources: [], + sourceRefs: assistantRefs, + }); + add({ + code: "T2O_TRAE_SUBAGENT_RECONSTRUCTED", + severity: "info", + message: "An inline TRAE subagent run was reconstructed as a native child session.", + sourceSessionId: projection.sourceId, + }, projection.sourceRefs, projection.sourceId); + } } const bundle: MigrationBundle = { schemaVersion: 1, createdAt: options.collectedAt, diff --git a/src/source/trae/assistant-messages.ts b/src/source/trae/assistant-messages.ts index 62604b8..b15af45 100644 --- a/src/source/trae/assistant-messages.ts +++ b/src/source/trae/assistant-messages.ts @@ -12,6 +12,11 @@ import { type TraeReasoningBlock, type TraeReasoningPlanIssueCode, } from "./reasoning-plan.js"; +import { + parseTraeSubagentRuns, + type TraeSubagentRun, + type TraeSubagentRunIssueCode, +} from "./subagent-runs.js"; import { parseTraeToolCalls, type TraeToolCall, type TraeToolIssueCode } from "./tool-calls.js"; import type { TraeQueryCacheEntry } from "./user-messages.js"; @@ -30,6 +35,7 @@ export type TraeAssistantMessageSourceKind = export type TraeAssistantMessageIssueCode = | TraeReasoningPlanIssueCode + | TraeSubagentRunIssueCode | TraeToolIssueCode | "T2O_TRAE_ASSISTANT_MESSAGE_CONTAINER_INVALID" | "T2O_TRAE_ASSISTANT_MESSAGE_RECORD_INVALID" @@ -81,6 +87,7 @@ export interface TraeAssistantMessage { textBlocks: TraeAssistantText[]; reasoningBlocks: TraeReasoningBlock[]; planItems: TraePlanItem[]; + subagentRuns: TraeSubagentRun[]; toolCalls: TraeToolCall[]; sources: TraeAssistantMessageSource[]; } @@ -176,7 +183,10 @@ const QUERY_LONG_TEXT_PATH_FIELDS = new Set([ ]); const ISSUE_MESSAGES: Record< - Exclude, + Exclude< + TraeAssistantMessageIssueCode, + TraeReasoningPlanIssueCode | TraeSubagentRunIssueCode | TraeToolIssueCode + >, string > = { T2O_TRAE_ASSISTANT_MESSAGE_CONTAINER_INVALID: @@ -357,7 +367,7 @@ function parseGeneralContent(value: unknown): AssistantContentResult { }; } -function parseToolSummary(value: unknown): string | undefined { +function parseToolSummary(value: unknown): { key: string; text: string } | undefined { if (!isRecord(value) || !isRecord(value.tool_call_info)) { return undefined; } @@ -369,11 +379,14 @@ function parseToolSummary(value: unknown): string | undefined { if (!isResponseTool) return undefined; const params = parseContentRecord(toolCall.params); - return params && - typeof params.summary === "string" && - params.summary.trim().length > 0 - ? params.summary - : undefined; + if (!params || typeof params.summary !== "string" || + params.summary.trim().length === 0) return undefined; + return { + key: typeof toolCall.id === "string" && toolCall.id.length > 0 + ? toolCall.id + : sha256(toolCall), + text: params.summary, + }; } function parseChatTaskContent( @@ -381,7 +394,7 @@ function parseChatTaskContent( ): AssistantContentResult { let valid = true; const thoughts: Array<{ entryIndex: number; text: TraeAssistantText }> = []; - let summary: { entryIndex: number; text: TraeAssistantText } | null = null; + const summaries = new Map(); for (const [entryIndex, value] of messages.entries()) { if (!isRecord(value)) { @@ -414,17 +427,18 @@ function parseChatTaskContent( const responseSummary = parseToolSummary(value.plan_item); if (responseSummary) { const text = createText( - responseSummary, + responseSummary.text, `content.messages[${entryIndex}].plan_item.tool_call_info.params.summary`, ); - if (text) summary = { entryIndex, text }; + if (text) summaries.set(responseSummary.key, { entryIndex, text }); } } + const summaryTexts = new Set([...summaries.values()].map(({ text }) => text.text)); const textBlocks = thoughts - .filter(({ text }) => text.text !== summary?.text.text) + .filter(({ text }) => !summaryTexts.has(text.text)) .map(({ entryIndex, text }) => ({ entryIndex, rank: 0, text })); - if (summary) { + for (const summary of summaries.values()) { textBlocks.push({ entryIndex: summary.entryIndex, rank: 1, text: summary.text }); } return { @@ -767,6 +781,17 @@ function parseRuntimeRecord( }))); const tools = parseTraeToolCalls(value.content, messageType, VERIFIED_TRAE_ASSISTANT_MESSAGE_VERSION); issues.push(...tools.issues.map((issue) => ({ ...issue, sourceSessionId, sourceMessageId, entryIndex }))); + const subagents = parseTraeSubagentRuns( + value.content, + messageType, + VERIFIED_TRAE_ASSISTANT_MESSAGE_VERSION, + ); + issues.push(...subagents.issues.map((issue) => ({ + ...issue, + sourceSessionId, + sourceMessageId, + entryIndex, + }))); return { sourceMessageId, @@ -782,6 +807,7 @@ function parseRuntimeRecord( textBlocks: content.textBlocks, reasoningBlocks: reasoningPlan.reasoningBlocks, planItems: reasoningPlan.planItems, + subagentRuns: subagents.runs, toolCalls: tools.toolCalls, sources: [ { diff --git a/src/source/trae/collect.ts b/src/source/trae/collect.ts index 34e380b..db49a4a 100644 --- a/src/source/trae/collect.ts +++ b/src/source/trae/collect.ts @@ -53,9 +53,25 @@ export function selectBundle(bundle: MigrationBundle, selection: { session?: str const normalized = pathApi.normalize(value); return bundle.source.platform === "win32" ? normalized.toLowerCase() : normalized; }; - const sessions = bundle.sessions.filter((session) => + const selectedIds = new Set(bundle.sessions.filter((session) => (!selection.session || session.sourceId === selection.session) && - (!selection.project || (session.projectPath !== undefined && key(session.projectPath) === key(selection.project)))); + (!selection.project || (session.projectPath !== undefined && + key(session.projectPath) === key(selection.project)))) + .map((session) => session.sourceId)); + if (selection.session && selectedIds.size > 0) { + let changed = true; + while (changed) { + changed = false; + for (const session of bundle.sessions) { + if (session.parentSourceId && selectedIds.has(session.parentSourceId) && + !selectedIds.has(session.sourceId)) { + selectedIds.add(session.sourceId); + changed = true; + } + } + } + } + const sessions = bundle.sessions.filter((session) => selectedIds.has(session.sourceId)); if (sessions.length === 0) throw new Trae2OpenCodeError("T2O_MIGRATION_SELECTION_EMPTY"); const ids = new Set(sessions.map((session) => session.sourceId)); const projects = bundle.projects.filter((project) => sessions.some((session) => session.projectSourceId === project.sourceId)); diff --git a/src/source/trae/index.ts b/src/source/trae/index.ts index 0f6bef9..3e7510a 100644 --- a/src/source/trae/index.ts +++ b/src/source/trae/index.ts @@ -127,6 +127,13 @@ export { type TraeReasoningPlanIssueCode, type TraeReasoningPlanReport, } from "./reasoning-plan.js"; +export { + parseTraeSubagentRuns, + type TraeSubagentRun, + type TraeSubagentRunIssue, + type TraeSubagentRunIssueCode, + type TraeSubagentRunReport, +} from "./subagent-runs.js"; export { parseTraeToolCalls, type TraeToolCall, diff --git a/src/source/trae/profile-definitions.ts b/src/source/trae/profile-definitions.ts index 970ebc2..2d05bc5 100644 --- a/src/source/trae/profile-definitions.ts +++ b/src/source/trae/profile-definitions.ts @@ -14,6 +14,7 @@ export type TraeParserCapability = | "user-messages" | "assistant-messages" | "reasoning-plan" + | "subagent-runs" | "tool-calls"; export interface TraeParserProfile extends Readonly { @@ -44,7 +45,8 @@ const PROFILES: readonly TraeParserProfile[] = Object.freeze([ sourceKind: "runtime" as const, verification: "verified" as const, capabilities: Object.freeze([ - "runtime-metadata", "user-messages", "assistant-messages", "reasoning-plan", "tool-calls", + "runtime-metadata", "user-messages", "assistant-messages", "reasoning-plan", + "subagent-runs", "tool-calls", ] as const), }), ...["trae-cn-memento-v1", "trae-cn-hybrid"].map((id): TraeParserProfile => Object.freeze({ diff --git a/src/source/trae/subagent-runs.ts b/src/source/trae/subagent-runs.ts new file mode 100644 index 0000000..a695e3e --- /dev/null +++ b/src/source/trae/subagent-runs.ts @@ -0,0 +1,256 @@ +import { assertTraeParserCapability, RUNTIME_PROFILE } from "./profile-definitions.js"; +import { + isRuntimeObject, + parseRuntimeObject, + runtimeHash, + type TraeContentSource, +} from "./reasoning-plan.js"; + +const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/; + +export type TraeSubagentRunIssueCode = + | "T2O_TRAE_SUBAGENT_RUN_INVALID" + | "T2O_TRAE_SUBAGENT_RUN_CONFLICT" + | "T2O_TRAE_SUBAGENT_RUN_INCOMPLETE"; + +export interface TraeSubagentRunIssue { + code: TraeSubagentRunIssueCode; + severity: "warning"; + message: string; + contentLocator: string; +} + +export interface TraeSubagentRun { + sourceRunId: string; + parentRunIds: string[]; + agentId: string; + agentDisplayName?: string; + description: string; + runMode?: "foreground" | "background"; + entryIndexes: number[]; + responseEntryIndex?: number; + createdAt: number; + completedAt: number; + sources: TraeContentSource[]; +} + +export interface TraeSubagentRunReport { + runs: TraeSubagentRun[]; + issues: TraeSubagentRunIssue[]; +} + +interface Candidate { + sourceRunId: string; + parentRunIds?: string[]; + agentId?: string; + agentDisplayName?: string; + description?: string; + runMode?: "foreground" | "background"; + entryIndexes: number[]; + responseEntryIndexes: number[]; + times: number[]; + completedTimes: number[]; + completed: boolean; + sources: TraeContentSource[]; + conflict: boolean; +} + +const MESSAGES: Record = { + T2O_TRAE_SUBAGENT_RUN_INVALID: + "A TRAE subagent record has an invalid run, parent, agent, or timing field.", + T2O_TRAE_SUBAGENT_RUN_CONFLICT: + "Conflicting TRAE subagent records share a run identifier.", + T2O_TRAE_SUBAGENT_RUN_INCOMPLETE: + "A TRAE subagent run lacks the verified description or completed state required for reconstruction.", +}; + +function parseIdentifier(value: unknown): string | undefined { + return typeof value === "string" && IDENTIFIER_PATTERN.test(value) + ? value + : undefined; +} + +function parseTime(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +/** + * TRAE persists subagent work inline with the parent assistant message. A run is + * reconstructable only when its parent path, identity, description and terminal + * state agree across all observed plan items. + */ +export function parseTraeSubagentRuns( + value: unknown, + messageType: "general" | "task", + productVersion: string, +): TraeSubagentRunReport { + assertTraeParserCapability( + productVersion, + RUNTIME_PROFILE, + "subagent-runs", + "T2O_TRAE_ASSISTANT_MESSAGE_VERSION_UNSUPPORTED", + ); + const report: TraeSubagentRunReport = { runs: [], issues: [] }; + if (messageType === "general") return report; + const envelope = parseRuntimeObject(value); + if (!envelope || !Array.isArray(envelope.messages)) return report; + const candidates = new Map(); + const issue = ( + code: TraeSubagentRunIssueCode, + contentLocator: string, + ) => report.issues.push({ + code, + contentLocator, + severity: "warning", + message: MESSAGES[code], + }); + + for (const [entryIndex, entry] of envelope.messages.entries()) { + if (!isRuntimeObject(entry) || entry.type !== "plan_item" || + !isRuntimeObject(entry.plan_item)) continue; + const item = entry.plan_item; + const hasSubagentEvidence = + Array.isArray(item.parent_agent_run_ids) && item.parent_agent_run_ids.length > 0 || + typeof item.sub_agent_call_description === "string" && + item.sub_agent_call_description.trim().length > 0; + if (!hasSubagentEvidence) continue; + const locator = `content.messages[${entryIndex}].plan_item`; + const sourceRunId = parseIdentifier(item.agent_run_id); + const parentRunIds = Array.isArray(item.parent_agent_run_ids) + ? item.parent_agent_run_ids.map(parseIdentifier) + : []; + const validParents = sourceRunId !== undefined && + parentRunIds.length > 0 && + parentRunIds.every((parent): parent is string => parent !== undefined) && + new Set(parentRunIds).size === parentRunIds.length && + !parentRunIds.includes(sourceRunId); + if (!sourceRunId || !validParents) { + issue("T2O_TRAE_SUBAGENT_RUN_INVALID", locator); + continue; + } + const normalizedParents = parentRunIds as string[]; + const agentId = parseIdentifier(item.agent_id); + const agentDisplayName = typeof item.agent_display_name === "string" && + item.agent_display_name.trim().length > 0 + ? item.agent_display_name + : undefined; + const description = typeof item.sub_agent_call_description === "string" && + item.sub_agent_call_description.trim().length > 0 + ? item.sub_agent_call_description + : undefined; + const status = isRuntimeObject(item.agent_status) ? item.agent_status.status : undefined; + const runModeValue = isRuntimeObject(item.agent_status) + ? item.agent_status.run_mode + : undefined; + const runMode = runModeValue === "foreground" || runModeValue === "background" + ? runModeValue + : undefined; + const timing = isRuntimeObject(item.timing) ? item.timing : undefined; + const tool = isRuntimeObject(item.tool_call_info) ? item.tool_call_info : undefined; + const isTerminalText = + status === "completed" && + tool?.name === "" && + typeof item.thought === "string" && + item.thought.trim().length > 0; + const times = timing + ? [ + parseTime(timing.generated_at_ms), + parseTime(timing.tool_call_started_at_ms), + parseTime(timing.tool_call_finished_at_ms), + ].filter((time): time is number => time !== undefined) + : []; + if (!agentId || (status !== undefined && status !== "running" && status !== "completed") || + (runModeValue !== undefined && runMode === undefined) || + (timing !== undefined && times.length === 0)) { + issue("T2O_TRAE_SUBAGENT_RUN_INVALID", locator); + continue; + } + const source = { locator, sha256: runtimeHash(item) }; + const current = candidates.get(sourceRunId); + if (!current) { + candidates.set(sourceRunId, { + sourceRunId, + parentRunIds: normalizedParents, + agentId, + ...(agentDisplayName ? { agentDisplayName } : {}), + ...(description ? { description } : {}), + ...(runMode ? { runMode } : {}), + entryIndexes: [entryIndex], + responseEntryIndexes: isTerminalText ? [entryIndex] : [], + times, + completedTimes: status === "completed" ? times : [], + completed: status === "completed", + sources: [source], + conflict: false, + }); + continue; + } + const conflict = + current.agentId !== agentId || + !sameStrings(current.parentRunIds ?? [], normalizedParents) || + (description !== undefined && current.description !== undefined && + current.description !== description) || + (agentDisplayName !== undefined && current.agentDisplayName !== undefined && + current.agentDisplayName !== agentDisplayName) || + (runMode !== undefined && current.runMode !== undefined && current.runMode !== runMode); + if (conflict) { + current.conflict = true; + issue("T2O_TRAE_SUBAGENT_RUN_CONFLICT", locator); + continue; + } + current.description ??= description; + current.agentDisplayName ??= agentDisplayName; + current.runMode ??= runMode; + current.entryIndexes.push(entryIndex); + if (isTerminalText) current.responseEntryIndexes.push(entryIndex); + current.times.push(...times); + if (status === "completed") { + current.completed = true; + current.completedTimes.push(...times); + } + current.sources.push(source); + } + + for (const candidate of candidates.values()) { + const createdAt = candidate.times.length > 0 + ? Math.min(...candidate.times) + : undefined; + const completedAt = candidate.completedTimes.length > 0 + ? Math.max(...candidate.completedTimes) + : undefined; + if (candidate.conflict) continue; + if (!candidate.description || !candidate.completed || + createdAt === undefined || completedAt === undefined || completedAt < createdAt) { + issue( + "T2O_TRAE_SUBAGENT_RUN_INCOMPLETE", + candidate.sources[0]?.locator ?? "content.messages", + ); + continue; + } + report.runs.push({ + sourceRunId: candidate.sourceRunId, + parentRunIds: candidate.parentRunIds!, + agentId: candidate.agentId!, + ...(candidate.agentDisplayName ? { agentDisplayName: candidate.agentDisplayName } : {}), + description: candidate.description, + ...(candidate.runMode ? { runMode: candidate.runMode } : {}), + entryIndexes: [...candidate.entryIndexes].sort((left, right) => left - right), + ...(candidate.responseEntryIndexes.length > 0 + ? { responseEntryIndex: Math.max(...candidate.responseEntryIndexes) } + : {}), + createdAt, + completedAt, + sources: candidate.sources, + }); + } + report.runs.sort((left, right) => + left.entryIndexes[0] - right.entryIndexes[0] || + left.sourceRunId.localeCompare(right.sourceRunId)); + return report; +} diff --git a/src/target/__tests__/opencode-mapping.test.ts b/src/target/__tests__/opencode-mapping.test.ts index 90dc253..68fc95f 100644 --- a/src/target/__tests__/opencode-mapping.test.ts +++ b/src/target/__tests__/opencode-mapping.test.ts @@ -476,6 +476,54 @@ describe("OpenCode IR mapping", () => { assert.equal(map(bundle, { parentId: "ses_parent" }).transfer.info.parentID, "ses_parent"); }); + it("links reconstructed subagent tools to native child sessions", () => { + const bundle = structuredClone(fixture); + const parent = bundle.sessions[0]; + const child = structuredClone(parent); + child.sourceId = "session-subagent"; + child.title = "Review the change"; + child.parentSourceId = parent.sourceId; + child.derivedFromSourceSessionId = parent.sourceId; + bundle.sessions.push(child); + const event = assistant(bundle); + event.content.push({ + type: "tool", + callId: "subagent-run", + name: "subagent", + input: { + agent: "Reviewer", + description: "Review the change", + prompt: "Review the change", + }, + output: "Review complete", + status: "completed", + createdAt: 1_700_000_002_000, + completedAt: 1_700_000_003_000, + childSessionSourceId: child.sourceId, + sourceRefs: structuredClone(event.sourceRefs), + }); + const sessionIds = new Map([ + [parent.sourceId, "ses_mapping"], + [child.sourceId, "ses_mapping_child"], + ]); + + const content = map(bundle, { sessionIds }).transfer.messages[1].content as JsonObject[]; + const subagent = content.find((block) => + block.type === "tool" && block.name === "subagent"); + assert.ok(subagent && typeof subagent === "object"); + const state = subagent.state as JsonObject; + assert.deepEqual(state.metadata, { + sessionID: "ses_mapping_child", + status: "completed", + trae2opencode: { + outputEncoding: "text", + outputSha256: hashCanonicalJson("Review complete"), + childSessionSourceId: child.sourceId, + }, + }); + assert.throws(() => map(bundle), expectedRejection); + }); + it("reports only safe fields in errors even for private source text", () => { const bundle = structuredClone(fixture); bundle.sessions[0].title = "private title"; diff --git a/src/target/opencode/mapping.ts b/src/target/opencode/mapping.ts index f86c4e3..cb263b5 100644 --- a/src/target/opencode/mapping.ts +++ b/src/target/opencode/mapping.ts @@ -30,6 +30,7 @@ export interface OpenCodeTransfer extends OpenCodeSession { export interface OpenCodeMappingOptions { sessionId: string; messageIds: ReadonlyMap; + sessionIds?: ReadonlyMap; parentId?: string; directory: string; } @@ -47,7 +48,7 @@ export const MISSING_ASSISTANT_TEXT = "[TRAE assistant response ended before final text was persisted]"; export const MAX_CONTINUATION_CONTEXT_BYTES = 192 * 1024; export const MAX_CONTINUATION_RECENT_BYTES = 16 * 1024; -export const OPENCODE_MAPPING_VERSION = 9; +export const OPENCODE_MAPPING_VERSION = 10; const PARTIAL_PROJECTION_CODES = new Set([ "T2O_IR_EVENT_TIME_INVALID", @@ -219,18 +220,23 @@ function projectToolPresentation(block: Extract + if (block.type !== "text") return false; + if (block.presentation) return block.presentation === "progress"; + return block.sourceRefs.some((ref) => ref.locator.type === "runtime-field" && ref.locator.value.endsWith(".plan_item.thought")); } function mapContent( block: AssistantContentIR, + bundle: MigrationBundle, session: SessionIR, + options: OpenCodeMappingOptions, field: string, diagnostics: Diagnostic[], ): JsonObject | undefined { - if (!hasVerifiedRuntimeRefs(block.sourceRefs, session.sourceId)) reject(session, `${field}.sourceRefs`); + const evidenceSourceId = session.derivedFromSourceSessionId ?? session.sourceId; + if (!hasVerifiedRuntimeRefs(block.sourceRefs, evidenceSourceId)) reject(session, `${field}.sourceRefs`); const time = block.createdAt === undefined ? undefined : { created: block.createdAt, ...(block.completedAt === undefined ? {} : { completed: block.completedAt }), @@ -262,6 +268,15 @@ function mapContent( } if (!isRecord(block.input)) reject(session, `${field}.input`); const presentation = projectToolPresentation(block); + let linkedSessionId: string | undefined; + if (block.childSessionSourceId) { + const child = bundle.sessions.find((candidate) => + candidate.sourceId === block.childSessionSourceId); + linkedSessionId = options.sessionIds?.get(block.childSessionSourceId); + if (block.name !== "subagent" || block.status !== "completed" || + child?.parentSourceId !== session.sourceId || + !linkedSessionId) reject(session, `${field}.childSessionSourceId`); + } if (block.status === "running" || block.status === "unknown") { const hasPreservedPayload = block.output !== undefined || hasErrorPayload(block.error); const outputProjectedToShell = presentation.name === "shell" && @@ -358,12 +373,18 @@ function mapContent( state: { status: "completed", input: presentation.input, content: [{ type: "text", text: output.text }], - metadata: { trae2opencode: { - ...presentation.metadata, - outputEncoding: output.encoding, - outputSha256: output.sha256, - ...(sourceOutputMissing ? { sourceOutputMissing: true } : {}), - } }, + metadata: { + ...(linkedSessionId ? { sessionID: linkedSessionId, status: "completed" } : {}), + trae2opencode: { + ...presentation.metadata, + outputEncoding: output.encoding, + outputSha256: output.sha256, + ...(sourceOutputMissing ? { sourceOutputMissing: true } : {}), + ...(block.childSessionSourceId + ? { childSessionSourceId: block.childSessionSourceId } + : {}), + }, + }, }, }; } @@ -600,7 +621,8 @@ export function mapOpenCodeSession( } const sourceMessages = session.events.map((event, index): JsonObject => { const field = `events[${index}]`; - if (!hasVerifiedRuntimeRefs(event.sourceRefs, session.sourceId)) reject(session, `${field}.sourceRefs`); + const evidenceSourceId = session.derivedFromSourceSessionId ?? session.sourceId; + if (!hasVerifiedRuntimeRefs(event.sourceRefs, evidenceSourceId)) reject(session, `${field}.sourceRefs`); if (event.createdAt === undefined) reject(session, `${field}.createdAt`); const validReply = hasValidReply(session, event); if (event.type === "assistant" && !validReply) { @@ -635,7 +657,7 @@ export function mapOpenCodeSession( } const content: JsonObject[] = []; event.content.forEach((block, i) => { - const mapped = mapContent(block, session, `${field}.content[${i}]`, diagnostics); + const mapped = mapContent(block, bundle, session, options, `${field}.content[${i}]`, diagnostics); if (!mapped) return; content.push(mapped); }); @@ -681,6 +703,9 @@ export function mapOpenCodeSession( unknownSourceFields: ["cost", "tokens", "agent", "model"], resourceCount: session.resources.length, sourceRecovery: session.recovery, + ...(session.derivedFromSourceSessionId + ? { derivedFromSourceSessionId: session.derivedFromSourceSessionId } + : {}), sourceDiagnosticCodes: [...new Set(issues.map((issue) => issue.code))].sort(), ...(projectedSourceCodes.length > 0 ? { projectedSourceCodes } : {}), } }, From ff5d647c29e9a5deaac7dd96d93fb482b3e5388a Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Tue, 29 Sep 2026 15:45:45 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(mapping):=20=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=20subagent=20=E9=87=8D=E5=BB=BA=E4=B8=8E=20v?= =?UTF-8?q?10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++++--- docs/implementation-plan.md | 4 +++- docs/m0-3-source-location.md | 1 + docs/m0-5-mapping-matrix.md | 2 ++ docs/m3-3-assistant-messages.md | 6 ++++-- docs/m3-5-tool-calls.md | 3 +++ docs/m4-3-stable-identity.md | 5 +++++ docs/operation-manual.md | 13 +++++++++---- docs/troubleshooting.md | 22 ++++++++++++++++++++-- docs/versioning.md | 2 +- 10 files changed, 53 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3e451da..1d8ddf3 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ 2. 在本仓库根目录运行 `npm run migrate:local`。 3. 按编号选择窗口和一个或多个会话,等待批量迁移完成。 -工具会自动导出、脱敏、检查兼容性、导入、回读核验,并在中断后安全续跑。不会要求输入 -workbench ID 或 session ID。 +工具会自动导出、脱敏、检查兼容性、导入、回读核验,并在中断后安全续跑。可验证的 +TRAE subagent 调用会重建为 OpenCode 原生父子会话,并在父时间线中显示 Agent 工具卡。 +不会要求输入 workbench ID 或 session ID。 支持在 **macOS 与 Windows** 上直接读取本机 TRAE 会话,并按目标版本自动选择 OpenCode **v1 或 v2 协议**。 @@ -153,7 +154,8 @@ npm run migrate:local -n # 自动跳过需要 OVERWRITE 的会话 1. 为每个所选会话创建独立 bundle 和 manifest。 2. 自动剥离正文、标题和工具 payload 中已识别的凭据。 3. 在真正写入前检查迁移完整性和 OpenCode 兼容性。 -4. 逐个导入到 OpenCode,并回读消息、reasoning、工具记录和 hash;单个失败不阻止后续会话。 +4. 逐个导入到 OpenCode,并回读消息、reasoning、工具记录、subagent 父子关系和 hash; + 单个失败不阻止后续会话。 5. 成功后保留最新迁移记录,清理同一会话已被替代的旧终态记录。 如果目标中已存在同一来源会话,并且本地保留着本工具上次成功迁移的 manifest,程序会 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index a8ec2c9..1e9de02 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -207,6 +207,7 @@ interface SessionIR { title?: string projectPath?: string parentSourceId?: string + derivedFromSourceSessionId?: string createdAt?: number updatedAt?: number recovery: "complete" | "partial" | "metadata-only" | "unrecoverable" @@ -216,9 +217,10 @@ interface SessionIR { } type AssistantContentIR = - | { type: "text"; text: string } + | { type: "text"; text: string; presentation?: "progress" | "response" } | { type: "reasoning"; text: string; createdAt?: number; completedAt?: number } | { type: "tool"; callId: string; name: string; input: unknown; output?: unknown; + childSessionSourceId?: string; status: "running" | "completed" | "error"; createdAt?: number; completedAt?: number } ``` diff --git a/docs/m0-3-source-location.md b/docs/m0-3-source-location.md index 29c8026..f31fd25 100644 --- a/docs/m0-3-source-location.md +++ b/docs/m0-3-source-location.md @@ -62,6 +62,7 @@ canonical 证据位于 | assistant | `getMessages#message` | `message_id`、`chat_session_id`、`turn_id`、`reply_to_message_id`、`role`、`content`、`status`、消息类型、顺序与起止时间 | | reasoning | `assistant.messages[].plan_item` | `id`、`thought`、`reasoning_content`、`timing` | | tool | `plan_item.tool_call_info` | `id`、`name`、`params`、`result.status`、`result.data`、`result.error_message`、generated/start/finish timing | +| subagent | `assistant.messages[].plan_item` | `agent_run_id`、`parent_agent_run_ids`、`agent_id`、`agent_display_name`、`sub_agent_call_description`、`agent_status`、`timing` | 物理数据库只保留为环境诊断信息: diff --git a/docs/m0-5-mapping-matrix.md b/docs/m0-5-mapping-matrix.md index de03648..1f9fab6 100644 --- a/docs/m0-5-mapping-matrix.md +++ b/docs/m0-5-mapping-matrix.md @@ -35,6 +35,7 @@ | tool error | `result.error_message/error_variant` | error tool result | diagnostic;待非空错误 payload fixture | | assistant completion time | runtime `chat_end_time` | assistant `completedAt` | map;缺失时仍 reject-import | | message timestamps | runtime `created_at`、`chat_start_time`、`chat_end_time` | event/content timestamps | map | +| direct subagent run | `agent_run_id`、`parent_agent_run_ids`、agent、任务描述、状态与 timing | 派生 `SessionIR`、`parentSourceId`、父消息 subagent tool | map;仅唯一、一致、已完成且不跨消息的直接子调用 | | attachments/long text | `paste-files/`、`long-text/` 候选路径 | `resources` / content refs | deferred;归属和内容关联未验证 | `map` 表示证据允许实现字段转换,不表示转换器已经交付。生产 reader 必须使用 @@ -52,6 +53,7 @@ schema 字段名和 renderer 日志都不能替代该读取路径。 | completed tool | `state.status = "completed"`、`input`、`content[]` | call ID、名称、输入、输出、终态完整 | 仅有退出码时 diagnostic | | failed tool | `state.status = "error"`、`input`、`error` | 错误 payload 与调用关联完整 | 不根据非零退出码编造错误正文 | | active tool | `running` / `streaming` | 源状态真实,且所属 assistant 有真实完成时间 | 不修改状态;assistant 未完成则拒绝导入 | +| subagent tool | `name = "subagent"`、`state.metadata.sessionID` | 子会话目标 ID 已在完整身份映射中确定 | 缺失或不匹配时拒绝父会话映射 | | parent session | `info.parentID` | 父会话已成功导入并完成 ID 映射 | 父会话不存在时拒绝子会话 | | project metadata | target `projectID`、`subpath` | 由 import 目录投影 | manifest 记录源值和目标差异 | | updated time/model variant | target 重算字段 | 仅用于 readback 对账 | 不覆盖源 metadata | diff --git a/docs/m3-3-assistant-messages.md b/docs/m3-3-assistant-messages.md index 165c183..535f7e1 100644 --- a/docs/m3-3-assistant-messages.md +++ b/docs/m3-3-assistant-messages.md @@ -52,11 +52,13 @@ provider 支持与 M3-2 相同的数组、`messages`、`items`、`data.messages` | `message_type = general` | `content.content` | | task proposal | `content.messages[].proposal.content.thought`,保持块顺序 | | task chat / solo agent 进度 | 所有非空 `plan_item.thought`,保持块顺序 | -| task chat / solo agent 最终回复 | 最后一个 `Finish` / `response_to_user` 的 `params.summary` | +| task chat / solo agent 最终回复 | 按工具调用身份去重后的 `Finish` / `response_to_user` `params.summary` | `content` 可为 JSON 字符串或等价对象。每个正文块保留字段 locator 和内容 SHA-256。进度 thought 与最终 summary 均保留;两者文本完全相同时只保留 -summary,避免重复显示同一段最终输出。 +summary,避免重复显示同一段最终输出。mapping v10 会在后续 assembly 中按 +`agent_run_id` 将已验证 subagent 的正文块移入派生子会话,主会话只保留原生 +subagent 工具引用。 `reasoning_content` 不进入 M3-3 正文。`agent_type` 既不是 `chat` 也不是 `solo_agent` 的 task,其普通 `plan_item.thought` 仍不映射;它们由 M3-4 按 diff --git a/docs/m3-5-tool-calls.md b/docs/m3-5-tool-calls.md index aeae6f1..bf3172f 100644 --- a/docs/m3-5-tool-calls.md +++ b/docs/m3-5-tool-calls.md @@ -11,6 +11,9 @@ pending/canceled/interrupted 等无已验证目标映射的状态保留 unknown 完成时间来自真实 `timing.tool_call_finished_at_ms`;缺失时不补造。 没有 result 时标记 orphan warning;success 的 undefined data 可合法保留, 对应 M0 中已观察到的无输出工具。非零 exit code 不决定工具状态。 +`name = ""`、`params = null` 且 result 为空的 subagent 边界记录是 TRAE 的结构 +占位符,不生成普通工具,也不产生非法工具诊断;其 run 拓扑由独立 subagent parser +根据 agent、父链、任务描述、状态和 timing 处理。 同 call ID 的有效终态优先于活动记录,与输入顺序无关。名称、输入、终态 payload 或时间冲突时,整个 call ID fail closed;不混合两次不同调用的结果。 diff --git a/docs/m4-3-stable-identity.md b/docs/m4-3-stable-identity.md index a1a6ad8..87df8f0 100644 --- a/docs/m4-3-stable-identity.md +++ b/docs/m4-3-stable-identity.md @@ -18,6 +18,11 @@ order 会生成不同 ID,防止把改变顺序的消息误认成原目标。 `T2O_OPENCODE_IDENTITY_INVALID` 拒绝。父会话是否已成功写入由 import adapter 再次检查。 +mapping v10 使用来源 session ID 与 `agent_run_id` 的哈希作为派生 subagent +`SessionIR.sourceId`,因此相同 run 重跑会得到稳定的 OpenCode 子会话 ID。父消息中的 +原生 `subagent` 工具块引用完整身份映射中的目标子会话 ID;映射缺失或父子关系不一致时 +拒绝父会话写入。 + 2026-09-24 验证:新增 6 项单测首轮通过,覆盖更新稳定性、namespace 与 tuple 隔离、父排序、缺失/环/重复、12,000 层深链及空集合。深链无递归栈溢出。 累计 226 项测试;完整质量门禁通过。 diff --git a/docs/operation-manual.md b/docs/operation-manual.md index c06054f..2135ea7 100644 --- a/docs/operation-manual.md +++ b/docs/operation-manual.md @@ -327,9 +327,14 @@ npm install -g @opencode/cli@2.0.16 或出现无法安全脱敏的字段。保留本地产物,阅读[故障排查](troubleshooting.md)中对应的 `T2O_*` 错误码;不要通过编辑 JSON 补造字段。 -当前 mapping v9 能识别 subagent 历史中的空工具占位符;TRAE 若为这类历史保留了 -早于消息创建时间的实际执行窗口,迁移会显式归一目标时间,并将原始时间和投影原因 -保存在 metadata 中。其他缺失时间、无效身份或无法验证的工具数据仍会安全停止。 +当前 mapping v10 会根据同一 assistant 消息中一致的 `agent_run_id`、 +`parent_agent_run_ids`、agent、任务描述和完成状态,将可验证的直接 subagent 调用 +重建为 OpenCode 原生子会话。父消息同时生成 `subagent` 工具卡并通过 +`metadata.sessionID` 指向子会话;选择根会话时,这些派生子会话会自动加入迁移计划。 + +空工具占位符不会被误判为工具调用。TRAE 若保留了早于消息创建时间的实际执行窗口, +迁移仍会显式归一目标时间,并将原始时间和投影原因保存在 metadata 中。跨多个 assistant +消息重复出现、嵌套关系尚未验证、身份冲突或没有完成证据的 run 不会被强行拆分。 ### 看到“已自动将疑似凭据替换为脱敏占位符” @@ -347,7 +352,7 @@ bundle 内容来缩小文件。 这是旧 mapping v6 生成的无序消息 ID 或无界历史上下文造成的兼容问题。更新本仓库后 重新运行 `npm run migrate:local`,选择同一会话并按提示输入 `OVERWRITE`,让工具以 -当前 mapping v9 重新导入。v9 延续 v8 的 checkpoint 设计,并将角色化上下文放入不直接展示的 `recent` +当前 mapping v10 重新导入。v10 延续 v9 的 checkpoint 设计,并将角色化上下文放入不直接展示的 `recent` 字段,避免 `[User]`、`[Assistant]` 摘录在时间线中重复显示。 如果该目标会话已在 OpenCode 中继续过对话,受保护覆盖会拒绝删除它。先保留或导出新增 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e128f86..8c9f7b2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -81,14 +81,32 @@ macOS 的 `open -a "Trae CN" --args ...` 经 3.3.104 实机确认可能丢弃调 mapping v6 的哈希消息 ID 不满足 OpenCode Desktop 2.0.16 对时间顺序的字符串比较, 实时 Revert 后可能暂时清空当前窗口;超大历史也可能在首次自动 compaction 时被 provider -拒绝。当前 mapping v9 使用稳定递增 ID,并为超大历史加入原生 completed-compaction 边界; +拒绝。当前 mapping v10 使用稳定递增 ID,并为超大历史加入原生 completed-compaction 边界; 角色化摘录写入只供后续模型读取的 `recent`,可见 `summary` 保持为空,因此不会把 `[User]`、`[Assistant]` 重复显示为普通正文。原始时间线仍完整保留。 更新代码后重新执行 `npm run migrate:local` 并按提示安全覆盖。目标会话若已在 OpenCode 中新增内容,覆盖保护会停止;先保留新增内容,再人工删除旧目标会话并重新迁移。 -### 迁移到 OpenCode 1.x(mapping v9) +### subagent 没有显示为 OpenCode 子会话 + +mapping v10 要同时写入两类原生关系:子会话的 `info.parentID`,以及父 assistant 消息中 +`name = "subagent"` 且 `state.metadata.sessionID` 指向该子会话的工具块。只存在其中一个 +时,OpenCode 不会完整显示 Agent 工具卡和子会话导航。 + +TRAE 来源还必须为每个直接子调用提供唯一且一致的 `agent_run_id`、 +`parent_agent_run_ids`、agent、任务描述、时间和完成状态。满足条件时,选择根会话会自动 +包含派生子会话。以下记录会保守保持内联,并生成固定诊断: + +- 同一 run 跨多个 assistant 消息,无法证明唯一归属。 +- run 身份、父链、agent 或任务描述冲突。 +- 缺少完成状态或可验证时间。 +- 嵌套 subagent;当前只重建已经过真实回读验证的直接子调用。 + +如果目标是此前由 mapping v9 创建的扁平会话,更新代码后重新执行 +`npm run migrate:local`,选择相同会话并按提示执行安全 `OVERWRITE`。 + +### 迁移到 OpenCode 1.x(mapping v10) v1 没有 `SessionTransfer` 路由,也没有 carry-summary 的 compaction 消息,因此: diff --git a/docs/versioning.md b/docs/versioning.md index f582124..9871c0e 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -40,7 +40,7 @@ Changelog 一致。Release PR 由 Actions token 创建时不会自然触发其 | --- | ---: | --- | | `schemaVersion` | 1 | TRAE 中间格式(IR)结构 | | `manifestVersion` | 1 | 迁移记录、续跑与回滚结构 | -| `mappingVersion` | 9 | IR 到 OpenCode 的映射语义 | +| `mappingVersion` | 10 | IR 到 OpenCode 的映射语义 | | `reportVersion` | 3 | OpenCode 独立验证报告结构 | 格式版本只在对应结构或语义变化时递增。一次产品发布可以不改变任何格式版本,也可以 From 944ec41c28ff7e798a4ccd939d69c59eba6782e5 Mon Sep 17 00:00:00 2001 From: yororoIce <3364817735@qq.com> Date: Tue, 29 Sep 2026 16:03:13 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(cli):=20=E6=94=AF=E6=8C=81=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=B4=BE=E7=94=9F=20subagent=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/__tests__/interactive-migrate.test.ts | 49 ++++++++++-- src/cli/interactive-migrate.ts | 79 +++++++++++++++---- 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/cli/__tests__/interactive-migrate.test.ts b/src/cli/__tests__/interactive-migrate.test.ts index 00942aa..c6e40b6 100644 --- a/src/cli/__tests__/interactive-migrate.test.ts +++ b/src/cli/__tests__/interactive-migrate.test.ts @@ -292,11 +292,22 @@ describe("interactive migration helpers", () => { ); }); - it("accepts only a single bundle for the selected session", async () => { + it("accepts the selected root and only its derived session graph", async () => { const bundle = await readBundleFile("fixtures/ir/v1/valid-trae-assembled.json"); const sourceSessionId = bundle.sessions[0].sourceId; assert.equal(bundleMatchesSession(bundle, sourceSessionId), true); + const child = structuredClone(bundle.sessions[0]); + child.sourceId = "derived-child"; + child.parentSourceId = sourceSessionId; + child.derivedFromSourceSessionId = sourceSessionId; + bundle.sessions.push(child); + assert.equal(bundleMatchesSession(bundle, sourceSessionId), true); + child.derivedFromSourceSessionId = "different-session"; + assert.equal(bundleMatchesSession(bundle, sourceSessionId), false); + child.derivedFromSourceSessionId = sourceSessionId; + child.parentSourceId = "absent-parent"; + assert.equal(bundleMatchesSession(bundle, sourceSessionId), false); assert.equal(bundleMatchesSession(bundle, "different-session"), false); }); @@ -342,12 +353,24 @@ describe("interactive migration helpers", () => { it("recognizes only terminal manifests for the selected source session", () => { const manifest = { - sessions: [{ sourceId: "session-a", state: "verified" }], + sessions: [{ + sourceId: "session-a", + targetId: "ses_root", + state: "verified", + }, { + sourceId: "derived-child", + targetId: "ses_child", + parentId: "ses_root", + state: "verified", + }], } as unknown as MigrationManifest; assert.equal(isTerminalManifestForSession(manifest, "session-a"), true); - manifest.sessions[0].state = "failed"; + manifest.sessions[1].state = "failed"; + assert.equal(isTerminalManifestForSession(manifest, "session-a"), false); + manifest.sessions[1].state = "verified"; + manifest.sessions[1].parentId = "ses_absent"; assert.equal(isTerminalManifestForSession(manifest, "session-a"), false); - manifest.sessions[0].state = "verified"; + manifest.sessions[1].parentId = "ses_root"; assert.equal(isTerminalManifestForSession(manifest, "session-b"), false); }); @@ -355,13 +378,23 @@ describe("interactive migration helpers", () => { const manifest = replacementManifest("session-a"); assert.equal(isReplacementManifestForSession(manifest, "session-a"), true); assert.equal(isReplacementManifestForSession(manifest, "session-b"), false); + manifest.sessions.push({ + ...structuredClone(manifest.sessions[0]), + sourceId: "derived-child", + targetId: "ses_child", + parentId: "ses_target", + }); + assert.equal(isReplacementManifestForSession(manifest, "session-a"), true); - manifest.sessions[0].created = false; + manifest.sessions[1].created = false; + assert.equal(isReplacementManifestForSession(manifest, "session-a"), false); + manifest.sessions[1].created = true; + manifest.sessions[1].state = "skipped"; assert.equal(isReplacementManifestForSession(manifest, "session-a"), false); - manifest.sessions[0].created = true; - manifest.sessions[0].state = "skipped"; + manifest.sessions[1].state = "verified"; + manifest.sessions[1].parentId = "ses_absent"; assert.equal(isReplacementManifestForSession(manifest, "session-a"), false); - manifest.sessions[0].state = "verified"; + manifest.sessions[1].parentId = "ses_target"; manifest.rollbackState = "in-progress"; assert.equal(isReplacementManifestForSession(manifest, "session-a"), false); }); diff --git a/src/cli/interactive-migrate.ts b/src/cli/interactive-migrate.ts index 3114efc..fb8feaf 100644 --- a/src/cli/interactive-migrate.ts +++ b/src/cli/interactive-migrate.ts @@ -162,7 +162,20 @@ export function bundleMatchesSession( bundle: MigrationBundle, sourceSessionId: string, ): boolean { - return bundle.sessions.length === 1 && bundle.sessions[0].sourceId === sourceSessionId; + const byId = new Map(bundle.sessions.map((session) => [session.sourceId, session])); + if (byId.size !== bundle.sessions.length || !byId.has(sourceSessionId)) return false; + return bundle.sessions.every((session) => { + if (session.sourceId === sourceSessionId) return true; + if (session.derivedFromSourceSessionId !== sourceSessionId) return false; + const visited = new Set(); + let parentId = session.parentSourceId; + while (parentId !== undefined && !visited.has(parentId)) { + if (parentId === sourceSessionId) return true; + visited.add(parentId); + parentId = byId.get(parentId)?.parentSourceId; + } + return false; + }); } /** Release only an empty leaf so the exporter can claim it exclusively. */ @@ -216,22 +229,42 @@ export function isTerminalManifestForSession( sourceSessionId: string, ): boolean { const terminalStates = new Set(["verified", "skipped", "excluded", "blocked", "rolled-back"]); - return manifest.sessions.length > 0 && - manifest.sessions.every((item) => - item.sourceId === sourceSessionId && terminalStates.has(item.state)); + return manifestMatchesSessionGraph(manifest, sourceSessionId) && + manifest.sessions.every((item) => terminalStates.has(item.state)); } export function isReplacementManifestForSession( manifest: MigrationManifest, sourceSessionId: string, ): boolean { - const session = manifest.sessions[0]; return manifest.rollbackState === undefined && - manifest.sessions.length === 1 && - session.sourceId === sourceSessionId && - session.state === "verified" && - session.created && - session.deletionHash !== undefined; + manifestMatchesSessionGraph(manifest, sourceSessionId) && + manifest.sessions.every((session) => + session.state === "verified" && + session.created && + session.deletionHash !== undefined); +} + +function manifestMatchesSessionGraph( + manifest: MigrationManifest, + sourceSessionId: string, +): boolean { + const byTarget = new Map(manifest.sessions.map((session) => [session.targetId, session])); + if (byTarget.size !== manifest.sessions.length) return false; + const roots = manifest.sessions.filter((session) => session.sourceId === sourceSessionId); + if (roots.length !== 1) return false; + const rootTargetId = roots[0].targetId; + return manifest.sessions.every((session) => { + if (session.targetId === rootTargetId) return true; + const visited = new Set(); + let parentId = session.parentId; + while (parentId !== undefined && !visited.has(parentId)) { + if (parentId === rootTargetId) return true; + visited.add(parentId); + parentId = byTarget.get(parentId)?.parentId; + } + return false; + }); } export function replacementResumeNeedsExclusiveAccess( @@ -362,7 +395,7 @@ export async function replacementTargetExists( readSession: (targetId: string) => Promise, ): Promise { const manifest = await readManifest(manifestFilename); - const targetId = manifest.sessions[0]?.targetId; + const targetId = manifest.sessions.find((session) => session.parentId === undefined)?.targetId; return typeof targetId === "string" && await readSession(targetId) !== null; } @@ -1027,6 +1060,7 @@ async function migrateSelectedSession(options: { const { session, directories } = job; const { exportDirectory, runDirectory } = directories; const inputFile = path.join(exportDirectory, "migration-bundle.json"); + let expectedSessionCount: number; console.log(`\n[${position}/${total}] 正在处理:${session.title}`); const existingBundle = await fs.stat(inputFile).catch(() => undefined); @@ -1041,6 +1075,7 @@ async function migrateSelectedSession(options: { console.error("已有迁移 bundle 与本次选择的会话不一致,已跳过该会话。"); return false; } + expectedSessionCount = bundle.sessions.length; } catch { console.error("已有迁移 bundle 无法校验,已跳过该会话。"); return false; @@ -1059,6 +1094,17 @@ async function migrateSelectedSession(options: { "--redact-credentials", "--json", ], cliPath, server); if (!exported.ok) return false; + try { + const bundle = await readBundleFile(inputFile); + if (!bundleMatchesSession(bundle, session.id)) { + console.error("迁移工具导出的 bundle 与所选会话不一致,已跳过该会话。"); + return false; + } + expectedSessionCount = bundle.sessions.length; + } catch { + console.error("迁移工具导出的 bundle 无法校验,已跳过该会话。"); + return false; + } if (exported.outputText.includes("T2O_SENSITIVE_CONTENT_REDACTED")) { console.log("已自动将疑似凭据替换为脱敏占位符,该会话按部分恢复迁移。"); } @@ -1077,8 +1123,8 @@ async function migrateSelectedSession(options: { ], cliPath, server, password); if (!preview.ok) return false; const previewResult = cliJsonResult(preview.outputText); - if (previewResult?.ready !== 1 || previewResult.blocked !== 0 || - previewResult.excluded !== 0) { + if (previewResult?.ready !== expectedSessionCount || previewResult?.blocked !== 0 || + previewResult?.excluded !== 0) { console.error("所选会话包含当前无法无损映射的内容,未写入 OpenCode。"); return false; } @@ -1110,12 +1156,13 @@ async function migrateSelectedSession(options: { ], cliPath, server, password); if (!migrated.ok) return false; const migrationResult = cliJsonResult(migrated.outputText); - if (migrationResult?.skipped === 1 && migrationResult.created === 0 && - migrationResult.replaced === 0) { + if (migrationResult?.skipped === expectedSessionCount && migrationResult?.created === 0 && + migrationResult?.replaced === 0) { console.error("目标会话已存在,但缺少可验证的旧 manifest,未执行覆盖。"); return false; } - if (migrationResult?.hasFailures !== false || migrationResult.verified !== 1) { + if (migrationResult?.hasFailures !== false || + migrationResult.verified !== expectedSessionCount) { console.error("迁移结果未通过完整回读校验。"); return false; }