Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 协议**。
Expand Down Expand Up @@ -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,程序会
Expand Down
4 changes: 3 additions & 1 deletion docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ interface SessionIR {
title?: string
projectPath?: string
parentSourceId?: string
derivedFromSourceSessionId?: string
createdAt?: number
updatedAt?: number
recovery: "complete" | "partial" | "metadata-only" | "unrecoverable"
Expand All @@ -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 }
```
Expand Down
1 change: 1 addition & 0 deletions docs/m0-3-source-location.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

物理数据库只保留为环境诊断信息:

Expand Down
2 changes: 2 additions & 0 deletions docs/m0-5-mapping-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 必须使用
Expand All @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions docs/m3-3-assistant-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 按
Expand Down
3 changes: 3 additions & 0 deletions docs/m3-5-tool-calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;不混合两次不同调用的结果。
Expand Down
5 changes: 5 additions & 0 deletions docs/m4-3-stable-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 项测试;完整质量门禁通过。
Expand Down
11 changes: 10 additions & 1 deletion docs/operation-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,15 @@ npm install -g @opencode/cli@2.0.16
或出现无法安全脱敏的字段。保留本地产物,阅读[故障排查](troubleshooting.md)中对应的
`T2O_*` 错误码;不要通过编辑 JSON 补造字段。

当前 mapping v10 会根据同一 assistant 消息中一致的 `agent_run_id`、
`parent_agent_run_ids`、agent、任务描述和完成状态,将可验证的直接 subagent 调用
重建为 OpenCode 原生子会话。父消息同时生成 `subagent` 工具卡并通过
`metadata.sessionID` 指向子会话;选择根会话时,这些派生子会话会自动加入迁移计划。

空工具占位符不会被误判为工具调用。TRAE 若保留了早于消息创建时间的实际执行窗口,
迁移仍会显式归一目标时间,并将原始时间和投影原因保存在 metadata 中。跨多个 assistant
消息重复出现、嵌套关系尚未验证、身份冲突或没有完成证据的 run 不会被强行拆分。

### 看到“已自动将疑似凭据替换为脱敏占位符”

这是正常提示。可安全识别的凭据会被替换为 `[REDACTED_SECRET]`,迁移继续进行且会话
Expand All @@ -343,7 +352,7 @@ bundle 内容来缩小文件。

这是旧 mapping v6 生成的无序消息 ID 或无界历史上下文造成的兼容问题。更新本仓库后
重新运行 `npm run migrate:local`,选择同一会话并按提示输入 `OVERWRITE`,让工具以
mapping v8 重新导入。v8 还会将 checkpoint 的角色化上下文放入不直接展示的 `recent`
当前 mapping v10 重新导入。v10 延续 v9 的 checkpoint 设计,并将角色化上下文放入不直接展示的 `recent`
字段,避免 `[User]`、`[Assistant]` 摘录在时间线中重复显示。

如果该目标会话已在 OpenCode 中继续过对话,受保护覆盖会拒绝删除它。先保留或导出新增
Expand Down
25 changes: 23 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 v8 使用稳定递增 ID,并为超大历史加入原生 completed-compaction 边界;
拒绝。当前 mapping v10 使用稳定递增 ID,并为超大历史加入原生 completed-compaction 边界;
角色化摘录写入只供后续模型读取的 `recent`,可见 `summary` 保持为空,因此不会把
`[User]`、`[Assistant]` 重复显示为普通正文。原始时间线仍完整保留。

更新代码后重新执行 `npm run migrate:local` 并按提示安全覆盖。目标会话若已在 OpenCode
中新增内容,覆盖保护会停止;先保留新增内容,再人工删除旧目标会话并重新迁移。

### 迁移到 OpenCode 1.x(mapping v8)
### 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 消息,因此:

Expand All @@ -103,6 +121,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` 阻止,而不是写入不完整数据。

Expand Down
2 changes: 1 addition & 1 deletion docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Changelog 一致。Release PR 由 Actions token 创建时不会自然触发其
| --- | ---: | --- |
| `schemaVersion` | 1 | TRAE 中间格式(IR)结构 |
| `manifestVersion` | 1 | 迁移记录、续跑与回滚结构 |
| `mappingVersion` | 8 | IR 到 OpenCode 的映射语义 |
| `mappingVersion` | 10 | IR 到 OpenCode 的映射语义 |
| `reportVersion` | 3 | OpenCode 独立验证报告结构 |

格式版本只在对应结构或语义变化时递增。一次产品发布可以不改变任何格式版本,也可以
Expand Down
2 changes: 1 addition & 1 deletion fixtures/ir/v1/golden/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
}
],
"goldenVersion": 1,
"schemaSha256": "sha256:9a88ece7780cb6a0ade93ce0660a05de5d732d77d016788210a982d817f040a4",
"schemaSha256": "sha256:4d7da690de8ee426578a4e1e6ef8c86125c0dd1840ca6e816a46f681139325c7",
"schemaVersion": 1
}
14 changes: 14 additions & 0 deletions schemas/migration-bundle.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,12 @@
"text": {
"type": "string"
},
"presentation": {
"enum": [
"progress",
"response"
]
},
"createdAt": true,
"completedAt": true,
"sourceRefs": true
Expand Down Expand Up @@ -452,6 +458,10 @@
},
"createdAt": true,
"completedAt": true,
"childSessionSourceId": {
"type": "string",
"minLength": 1
},
"sourceRefs": true
}
}
Expand Down Expand Up @@ -613,6 +623,10 @@
"type": "string",
"minLength": 1
},
"derivedFromSourceSessionId": {
"type": "string",
"minLength": 1
},
"createdAt": {
"type": "integer",
"minimum": 0
Expand Down
49 changes: 41 additions & 8 deletions src/cli/__tests__/interactive-migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -342,26 +353,48 @@ 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);
});

it("accepts only verified tool-owned manifests as replacement evidence", () => {
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);
});
Expand Down
Loading
Loading