feat(daemon): persist session artifacts across restarts (#6557)

* feat(daemon): persist session artifact metadata

* fix(daemon): address artifact restore review findings

* fix(daemon): harden artifact persistence restore

* fix(daemon): align artifact persistence review decisions

* fix(daemon): address artifact persistence review gaps

* fix(daemon): harden artifact persistence recovery

* fix(daemon): align artifact ownership capability

* fix(daemon): preserve marker identity during fork

* fix(daemon): roll back durable replacement removals

* fix(daemon): surface artifact rollback warnings

* fix(daemon): surface restore warning details

* fix(daemon): preserve artifact marker metadata safely

* fix(daemon): sanitize fork marker metadata

* fix(daemon): harden artifact restore boundaries

* fix(daemon): omit orphaned sticky snapshot markers

* fix(daemon): preserve artifact tombstone and rewind warnings

* fix(daemon): address artifact fork review blockers

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
ChiGao 2026-07-09 21:04:16 +08:00 committed by GitHub
parent bb96ac4fe5
commit 53243de0c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 10441 additions and 186 deletions

View file

@ -0,0 +1,893 @@
# Qwen Code Daemon Session Artifacts V2 持久化设计
本文延续 PR #5895 的 V1 session artifact API设计 V2 持久化能力。V1 设计见同目录下的 [session-artifacts-daemon-api-implementation-design.md](./session-artifacts-daemon-api-implementation-design.md)。
V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复。当前 PR 不复制、不冻结、不托管 artifact 内容workspace 文件只保存路径、size、mtimeMs 和 sha256 作为恢复后的完整性校验。
## 1. 设计结论
V2 是一个 metadata persistence phase。PR #6259 的实现范围收敛为 metadata restore、artifact JSONL journal/snapshot/rebuild/fork remap、daemon restart/load/replay 后恢复 artifact metadata以及 REST/ACP/SDK 的 metadata persistence 暴露。content retentionworkspace content pin、session-scoped managed copy、manifest、quota、TTL、session-scoped GC/fsck不在当前 scope若未来有真实审计/留档需求,应作为新的 content archive 设计重新评审。client 不应依赖“V2”这个阶段名推断功能而应读取 capability。
当前能力:
1. Metadata restore默认恢复 artifact 的结构化 metadata 和资源引用,不复制实际内容。
2. Workspace integrity checkworkspace artifact 登记时记录 size + mtimeMs + sha256restore / GET 时按实时文件返回 `available` / `missing` / `changed`
对应 capability
- `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。
- `session_artifacts_content_retention`:当前不声明;后续如果重启 content archive 设计,必须在复制/托管内容、配额、manifest 和 GC/fsck 都完成后再声明。
核心原则:
- V1 的 `SessionArtifactStore` 仍是 live session 的权威内存索引。
- V2 增加 JSONL artifact journal/snapshot用于在 daemon 侧创建 live store 时 seed 初始状态JSONL append 必须由当前拥有 chat recording 的 core/ACP child 路径完成daemon-side store 不能直接写 transcript。
- V2 默认 JSONL-only。sidecar cache 不进入 V2 发布门槛;只有实测 session load 成本不可接受时,才另行设计可删除缓存。
- 不把远端 URL 内容抓取到本地。
- 不默认复制 workspace 文件。
- 不把 client 传入的 `source``clientId``trustedPublisher` 当授权依据。
- 恢复时必须重新校验,不信任磁盘上的旧 metadata。
当前 PR 的重要收窄:
- Content retention public API、managed content store、pin/unpin、deleteContent、quota/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。
- 下文保留的 pin/save、content quota、managed content GC/fsck 细节是 future content archive 蓝图,不是 PR #6259 的 wire contract 或验收项;除非小节明确标注为 PR #6259 HTTP mapping / metadata behavior否则实现不得在 #6259 中暴露这些 API 或 capability。
- 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event这等价于本实现的 metadata prune不是纯 V1 live-only hiding。
- 显式 DELETE 当前采用 live-first先从 live store 移除tombstone 写入失败时返回 warning。这样可优先隐藏敏感项失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifactclient 应把 warning 作为“删除未 durable”的信号。
- Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork再引入 begin/complete marker。
## 2. 用户可见语义
### 2.1 页面刷新、切换和重启
V2 后的行为应是:
- 页面刷新:和 V1 一样,只要 daemon/session 还活着,前端重新 `GET /session/:id/artifacts` 即可。
- 切换 session每个 live session 仍有独立 artifact store。
- 前端实例重启daemon 还在时可 GET 当前 live store。
- daemon/bridge 重启:如果 session 被重新 loadV2 从持久化 metadata 恢复 artifact list。
- 历史 load/replay如果该 session 有 V2 persistence records恢复 artifact list没有则返回空 list。
V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning不能让用户误以为已有 live artifacts 已经可恢复。
backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级形成完整 candidate snapshot 后,再一次性 append `session_artifact_snapshot`。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。当前 PR 不实现 V1 live-store backfill如果后续补齐应把 candidate 条目数、跳过条目数和校验失败原因写入结构化 telemetry 或 snapshot metadata便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。
### 2.2 retention 分层
新增 optional field。PR #6259 的 public mutation path 只接受 `ephemeral``restorable`;旧 journal 中的 `pinned` 会在 restore / fork 时降级为 metadata-only `restorable`
```ts
type ArtifactRetention = 'ephemeral' | 'restorable';
```
含义:
- `ephemeral`:只存在于 live store。daemon/session 消失后不恢复。
- `restorable`metadata 写入持久化 journal。session load/replay 后恢复为 artifact item但不保证底层资源仍存在。
默认规则:
- Tool result、`record_artifact`、hook artifact默认 `restorable`,但只持久化 metadata。
- 用户在交互式前端手动注册的 Client POST artifact默认 `restorable`,恢复后仍出现在 artifact list 中。
- 后台/自动化 client POST如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`SDK 应提供明确的 ephemeral helper。
- `published` artifact默认 `restorable`;当前只恢复 published locator不托管内容。
如果 chat recording 被禁用metadata persistence 默认禁用capability 不声明。
### 2.3 用户注册 artifact 恢复语义
用户手动注册的 artifact 在 V2 恢复后应该继续存在但恢复的是“artifact metadata item”不是无条件内容备份。
恢复后的结果按资源状态区分:
- `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URLURL 是否仍可打开由 client 点击时决定。
- `workspace`:恢复 workspacePath 和 metadata如果文件仍在 workspace 内且 size + mtimeMs 未变,或 mtime 变化后 sha256 仍与登记时一致,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"`;如果文件仍在但 size 或 sha256 与登记时不同,`status: "changed"`
- `managed`:恢复 managedId只有 managed storage manifest 仍能解析时才 `available`
- `published`:恢复 published locator只有仍满足 trusted publisher manifest 校验时才保留 published trust。
因此,“用户注册的 artifact 恢复后还存在吗”的答案是V2 中应该存在于列表里,除非用户 DELETE、metadata 被 GC/tombstone、恢复校验发现记录损坏到无法安全展示或 chat recording / persistence 被禁用。是否还能打开底层内容,则取决于 storage 类型和实时资源状态workspace 文件不会被 daemon 备份,`changed` 用来避免静默打开错误版本。
daemon 不能只凭 request payload 判断“手动”还是“后台”。实现上应由连接 principal、SDK helper 或 UI action path 标识交互式注册来源;无法确认交互意图的 client 应按显式 `retention` 处理,缺省仍接受 `restorable`,但受 session metadata quota 和审计记录约束。
## 3. 数据模型
### 3.1 Public artifact 扩展
V2 在 V1 response artifact 上增加 optional fields
```ts
interface DaemonSessionArtifact {
// V1 fields...
status: 'available' | 'missing' | 'changed';
retention?: 'ephemeral' | 'restorable';
persistedAt?: string;
restoreState?: 'live' | 'restored' | 'unverified' | 'blocked';
persistenceWarning?:
| 'persistence_unavailable'
| 'metadata_only_restore'
| 'restore_validation_failed'
| 'sticky_override_active';
metadata?: {
'qwen.workspace.sha256'?: string;
'qwen.workspace.mtimeMs'?: number;
[key: string]: string | number | boolean | null | undefined;
};
}
```
字段说明:
- `retention`artifact 的持久化级别。解析顺序为:请求体显式值优先;系统内部 artifact 按 §2.2 的 daemon 默认策略client POST 未指定时使用用户配置的 `defaultRetention`;无配置时回退为 `restorable`。只有 persistence capability 未声明或读取 V1-era 记录时,才按 V1 兼容的 live-only 处理。V2 writer 写 journal 时必须 materialize `retention`,不能依赖 optional 缺省。
- `persistedAt`metadata 最近成功落盘时间。
- `restoreState`:恢复来源提示;不替代 `status`
- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。
- `status: "changed"`:仅用于 workspace artifact。daemon 在登记时写入 `sizeBytes``metadata["qwen.workspace.sha256"]``metadata["qwen.workspace.mtimeMs"]`GET/list/restore 后 refresh 先 stat 当前文件size 变化直接返回 `changed`size/mtime 均未变化则不重读文件,只有 mtime 变化但 size 相同时才重新计算 sha256 兜底。
### 3.2 Status 与 restoreState 的关系
V1 `status` 继续表示当前资源是否可用:
- `available`
- `missing`
- `changed`
V2 只新增 `changed` 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 已变化,或 mtime 变化后 sha256 与登记时的 metadata 不一致。`blocked` 不是 `status`,只属于 `restoreState`
- `restored`:从持久化 metadata 恢复。
- `unverified`:恢复了 metadata但尚未完成 workspace/managed 校验。
- `blocked`:恢复时发现安全边界不满足,例如 workspace path 逃逸。
- `live`:当前进程内新产生或已刷新确认。
## 4. 持久化存储设计
### 4.1 JSONL-only source of truth
V2 默认只使用 Chat JSONL system records
1. JSONL journal 是审计源、恢复源和跨版本迁移源。
2. `session_artifact_snapshot` 是 JSONL 内的恢复加速点,不是独立文件。
3. 不在 V2 中引入 sidecar cache。sidecar 会增加路径同步、陈旧校验、archive/unarchive/delete 联动、orphan GC 和缓存信任问题;当前 session load 已经读取 JSONLartifact records 可以在同一轮 parse 中提取。
如果未来实测需要 sidecar它必须作为单独设计进入并满足两个约束
- sidecar 只能是可删除缓存,不能承载协议正确性。
- 即使 sidecar 命中,也必须对每个 artifact 执行恢复校验,不能绕过 JSONL restore validation。
sidecar 对 V2 持久化不是 correctness requirement。当前 `loadSession()` 为恢复会读取完整 session JSONL 并重建对话树artifact restore 在同一轮读取里提取 snapshot/event records 时,不会增加额外文件 I/O。因此sidecar 在当前架构下只能节省 artifact records 的少量 parse/replay 成本,不能消除 session load 的主要读取成本。
把 sidecar 纳入当前 PR 会明显扩大实现面:
- JSONL 与 sidecar 的双写顺序、fsync 和 crash recovery。
- stale/corrupt sidecar 的校验、失效和 fallback。
- archive/unarchive/delete/fork/remap 时 sidecar 生命周期同步。
- sidecar 是否可信、是否可能绕过 restore validation 的安全边界。
- orphan sidecar/cache cleanup 和额外测试矩阵。
因此 V2 发布门槛保持 JSONL-only。sidecar 只在以下任一条件被 profiling 或产品需求证明后再进入独立设计:
- `loadSession()` 不再需要读取完整 JSONLsidecar 可以避免一次 cold-start 全量扫描。
- artifact list 需要在不 load session history 的场景下冷启动展示。
- 实测 artifact restore而不是对话历史重建成为 session load 的主耗时。
- 需要跨 session/project 的 artifact 搜索或全局索引。
### 4.2 JSONL writer ownership and branch model
Artifact persistence records 是 chat transcript 的一部分,必须遵循现有 `ChatRecord` 的 parent/leaf 语义:
- JSONL append 只能通过拥有 `ChatRecordingService.appendRecord` 的进程或它暴露的明确 RPC 完成。daemon-side `SessionArtifactStore` 可以用 operation queue 协调 live state、SSE 和 persistence request 顺序,但不能自己打开并写 chat JSONL。
- 每条 `session_artifact_event` / `session_artifact_snapshot` 都必须作为普通 system `ChatRecord` 挂到当前 conversation leaf 上,并获得正常的 `uuid` / `parentUuid`
- chat tree builder 和 renderer 必须把 `session_artifact_*` system records 视为 side-effect records它们参与 parent/leaf 顺序和 replay但不渲染成用户可见 conversation node。最低支持旧版本加载包含 V2 record 的 JSONL 时也必须把未知 system subtype 当作 opaque/ignored side effect而不是让 session load 失败。
- session load/replay 只应用 active leaf chain 中的 artifact records。被 `/rewind` 丢到 abandoned branch 的 artifact upsert/remove 不再影响当前 artifact list。
- `/rewind` 或任何 leaf switch 发生时daemon-side live `SessionArtifactStore` 必须重新对齐新的 active-chain artifact state要么从 active-chain replay result reseed要么在 rewind 操作中向 surviving chain 写一条当前 artifact snapshot top-up。V2 默认采用 branch-scoped 语义off-branch mutation 不应继续留在 live flat map 中等待下次重启才消失。
- fork/branch 只复制 active chain 中的 artifact recordsoff-chain records 不参与目标 session 的恢复。
- 如果某个实现阶段还不能把 artifact system records 接到 active leaf chain就不能声明 `session_artifacts_persistence` capability否则 rewind 后会出现旧 upsert 或旧 tombstone 复活的问题。
这意味着 V2 不设计独立的 artifact log 文件,也不设计绕过 chat tree 的 side log。artifact persistence 的正确性来自同一条 active chat history而不是 daemon 当前内存状态。
### 4.3 JSONL system record
`ChatRecord.subtype` 增加:
```ts
'session_artifact_event' | 'session_artifact_snapshot';
```
Payload
```ts
interface SessionArtifactEventRecordPayload {
v: 2;
sessionId: string;
sequence: number;
recordedAt: string;
changes: Array<{
action: 'created' | 'updated' | 'removed';
artifactId: string;
artifact?: PersistedSessionArtifact;
reason?: 'explicit' | 'eviction' | 'unpin_to_ephemeral';
}>;
}
interface SessionArtifactSnapshotRecordPayload {
v: 2;
sessionId: string;
sequence: number;
recordedAt: string;
artifacts: PersistedSessionArtifact[];
tombstonedIds?: string[];
stickyEphemeralIds: string[];
}
type PersistedSessionArtifact = Pick<
DaemonSessionArtifact,
| 'id'
| 'kind'
| 'storage'
| 'source'
| 'status'
| 'title'
| 'description'
| 'workspacePath'
| 'managedId'
| 'url'
| 'mimeType'
| 'sizeBytes'
| 'metadata'
| 'createdAt'
| 'updatedAt'
> & {
retention: ArtifactRetention;
persistedAt: string;
clientRetained: boolean;
toolCallId?: string;
toolName?: string;
hookEventName?: string;
};
```
`sequence` 是每个 session artifact store 内的 durable mutation counter用于 snapshot/event 排序和异常诊断。恢复时仍以 active JSONL chain 顺序为准;`sequence` 不作为跨 session 授权或全局 ordering source。
`PersistedSessionArtifact` 必须是正向 allowlist显式 `Pick` 或独立 interface不能用 `Omit<DaemonSessionArtifact, ...>` 负向排除。未来如果 `DaemonSessionArtifact` 增加新的 runtime-only 字段,编译时断言应要求维护者显式决定是否进入 persisted allowlist避免 schema 污染。
只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 以及 tool/hook display hints 外,不写 V1 内部字段或运行时派生字段:
- 不写 `identityKey`
- 不写 `trustedPublisher`
- 不写绝对 `workspaceCwd`
- 不写 transport token / auth principal
- 不写 `restoreState`
- 不写 `persistenceWarning`
- 不写 `clientId` 或 live-process owner principal`source` 只作为显示/审计 hint不能用于授权
删除 artifact 必须写 tombstone change避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert直到之后出现更高 sequence 的显式 upsert。旧 journal 中的 `reason: "unpin_to_ephemeral"` 继续作为 sticky override 兼容:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有经过认证的 REST/ACP mutate route 中显式传入 `retention: "restorable"` 的请求才能 supersedetool/hook/background/default retention、restore backfill 和隐式 re-ingest 都不能 supersede sticky override。
sticky override 不能只存在于历史 tombstone event 中。snapshot writer 必须把尚未被显式 supersede 的 `unpin_to_ephemeral` 状态写入 `stickyEphemeralIds`restore reader 先恢复 snapshot 中的 sticky set再应用 snapshot 之后的 upsert/remove。否则 snapshot baseline advance 后旧 tombstone 不再需要 replaysticky override 会丢失。
### 4.4 Snapshot 与 tombstone 不变量
artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。
必须满足:
- snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。
- snapshot 是 authoritative current state它只包含 snapshot 生成时仍有效的 artifacts。
- `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones被 snapshot 覆盖的旧 tombstones 不再进入新 snapshot payload避免数组随历史无限增长。
- `stickyEphemeralIds` 记录当前仍处于 sticky ephemeral override 的 artifact id即使对应旧 tombstone 已经不需要 replay也必须保留该 override 状态。
- `stickyEphemeralIds` 必须有界,默认和 persisted metadata 上限共享同一 `maxPersistedMetadata` 数量级,并计入 artifact journal working-set budget。旧 `unpin_to_ephemeral` journal replay 若会超过 sticky set 上限restore/prune 必须记录 warning 后稍后重试,不能静默增长、随机裁剪旧 sticky override或让隐式 upsert 恢复持久化。
- snapshot 可以包含曾经被 tombstone 的 artifact id前提是该 tombstone 已被更高 sequence 的显式 upsert supersede。
- load 时从新到旧选择最新 valid snapshot然后只应用该 snapshot 之后的 artifact events。
- 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning继续尝试上一个 valid snapshot不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。
- 如果没有任何 valid snapshot允许对 active JSONL leaf chain 做一次顺序 artifact event replay。isolated corrupt artifact record 应跳过并记录 warning只有 branch ordering、record envelope 或 tombstone 状态已经无法建立可信顺序时,才丢弃该 session 的 artifact persistence records。
这里的 snapshot baseline advance 不会重写或删除 JSONL 里的旧 record。旧 `session_artifact_snapshot`、event 和 tombstone 仍保留在 append-only chat transcript 中artifact 子系统只是在最新 snapshot payload 内前移恢复基线并重置工作集计数。
### 4.5 存储消耗
V2 不双写 sidecar因此没有 JSONL + sidecar 的 metadata 重复存储。存储消耗分为 metadata journal 和 content retention
- Metadata 单条通常约 0.5 KB - 2 KB取决于 title、description、url 和 metadata 大小。
- 每 session 有效 persisted metadata 上限默认与 live store 对齐为 200 条,单个 snapshot 约 100 KB - 400 KB。
- JSONL journal 会保存增量事件、snapshot 和 tombstoneappend-only chat transcript 本身会增长。
- content retention 才是主要空间来源,例如单 artifact 50 MB、单 session 200 MB、单 project 1 GB。
控制策略:
- artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。
- artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。
- 每 session 增加 artifact journal working-set byte budget例如 4 MB。该 budget 衡量恢复必须读取和应用的 artifact 工作集,也就是最新 valid snapshot 加其后的 artifact events不能把 chat transcript 中已经被 snapshot 覆盖的旧 artifact records 计入 budget否则 append-only JSONL 会变成不可恢复的一次性上限。
- writer 必须显式跟踪 working-set bytes每次写 snapshot 后记录该 snapshot 的 artifact byte size、JSONL append position 或 line index 作为 `postSnapshotBase`,之后每个 artifact event append 增加 `postSnapshotEventBytes`。预算检查使用 `snapshotBytes + postSnapshotEventBytes`snapshot baseline advance 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot仍无法确认时降级或报错不能无界追加。
- budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget则不再写新的 restorable metadata普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`
- 不把 content bytes 写进 JSONLPR #6259 也不写 daemon-managed artifact content storage。
## 5. 写入与恢复流程
### 5.1 Ingest-time validation
任何 artifact 进入 live store 和 JSONL 之前都必须做 ingest-time validation不能只在 restore 时校验:
- `workspacePath`必须是相对路径resolve/realpath 后不能逃逸当前 workspace。
- `url`:按 storage type 校验 scheme、userinfo、secret-like query/fragment。
- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。
- `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。
- 旧 `contentRef` / `expiresAt`:只作为 legacy journal 输入兼容client payload 中出现时必须拒绝或 strip当前 PR 不能生成新的字段。
- `restoreState` / `persistenceWarning`runtime-only response 字段client payload 中出现时必须拒绝或 strip不能写入 persisted artifact。
- `clientRetained`:只能是 boolean表示用户保留意图和稳定排序 hint不是授权信号。只有显式 REST/SDK/UI action 可以设置;后台自动 ingest 不能伪造为用户保留。
- `metadata`:执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。
验证失败时:
- 明确恶意或越界输入:拒绝请求。
- 可能包含敏感 locator 但用户仍想展示 live artifact可降级为 `ephemeral`,并写 `persistenceWarning.code = "validation_downgraded"`;不能写入 JSONL。
### 5.2 Artifact 写入流程
V1 流程:
```text
ingest input -> normalize/validate -> upsert live store -> publish artifact_changed
```
V2 流程:
```text
ingest input
-> normalize/validate
-> in SessionArtifactStore operationQueue: compute effective mutation
-> for restorable changes: request chat-recording writer append
artifact journal/snapshot on the active leaf chain
-> apply live-store mutation
-> publish artifact_changed with effective retention/warning fields
```
`SessionArtifactStore` 的 operation queue 负责串行化同一 session 的 live mutation、persistence request 和 SSE 顺序;真正的 JSONL append 仍由 chat recording owner 完成。普通 tool/hook artifact 如果 persistence writer 不可用,可以降级为 live-only `ephemeral` 后进入 live store。
如果 sticky ephemeral override 抑制了隐式/default upsert 的持久化live artifact 必须带 `persistenceWarning.code = "sticky_override_active"`,并记录 structured log `action=sticky_override_suppressed` 和 counter metric。否则排障时会看到合法 upsert input 却找不到对应 durable record。
当前 PR 没有隐藏的 paged persisted metadata 视图live list 就是恢复后暴露给 client 的 metadata 集合。因此上限处理采用一个收窄策略:
- `ephemeral` artifact 可以只从 live view 丢弃,不写 journal。
- `restorable` artifact 被上限裁剪时,写 `reason: "eviction"` remove event避免下次 load/replay 把已裁剪条目全部复活。
### 5.3 写入失败语义
区分两个入口:
- 普通 tool/hook artifact持久化失败不应让工具调用失败artifact 仍可进入 live store但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`
对会影响恢复结果的删除型 mutation当前 PR 按原因区分:
- `eviction`durable remove event保证重启后仍遵守 200 条上限。
- legacy unpin-to-`ephemeral`:读取旧 journal 时继续识别 durable remove event并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only直到显式 `retention: "restorable"` supersede。
- 显式 DELETElive-first。先从 live store 移除并发布删除事件,再 best-effort 写 explicit remove tombstone。tombstone 写入失败时 response 返回 warning当前为字符串 warning表示删除没有 durable如果 daemon 在补写成功前重启,旧 journal 仍可能恢复该 artifact。
- `deleteContent: true` 不属于 PR #6259 的 public API。content-retention follow-up 才会定义 content GC 与 warning contract当前 PR 的显式 DELETE 只处理 metadata tombstone 和 live removal。
建议 warning
```text
[artifacts] session=<id> action=persist_failed artifact=<id> reason=<code>
[artifacts] session=<id> action=remove_not_persisted artifact=<id>
[artifacts] session=<id> action=sticky_override_suppressed artifact=<id> prior_reason=unpin_to_ephemeral
```
### 5.4 恢复流程
session load/replay 时:
1. `SessionService.loadSession()` 读取 JSONL并在同一轮 parse 中提取 artifact snapshot/event records。
2. 基于 active leaf chain 提取最新 valid `session_artifact_snapshot` 和之后的 `session_artifact_event`。abandoned branch 上的 artifact records 必须忽略。
3. 重建 artifact snapshot应用 tombstone。
4. 对每个 artifact 重新执行 V2 restore validation。
5. load result 携带 `artifactSnapshot` 回到 daemon-side bridge。
6. daemon bridge 在 `createSessionEntry` / restore completion 时用 snapshot 初始化 daemon 侧 `SessionArtifactStore`
7. `GET /session/:id/artifacts` 读取的就是这个 daemon-side store。
不要在 ACP child process 的 agent/session 对象里 seed `SessionArtifactStore`:生产 HTTP API 可见的 store 在 daemon-side bridge 中创建。
`loadSession()` 必须是 read-only它不能在解析过程中写 tombstone也不能直接触发 content GC。若 restore 后发现当前 live cap 或 policy 比历史更严格daemon-side store 在创建完成、persistence writer 可用后,再通过正常 operation queue 写 `eviction` remove eventwriter 不可用时只在 live view 中隐藏超限 item并记录 warning下一次 load 仍可能重新看到这些待裁剪记录。
rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf 改变flat live store 不能继续保留 off-branch artifact mutation。若当前实现没有 active-chain replay result 可直接 reseed必须在 rewind 完成时写入 artifact snapshot top-up否则不能启用 persistence capability。
具体集成点必须是显式 hook而不是靠下一次 GET 懒修复。建议由 rewind/leaf-switch 实现调用 daemon bridge 的 `onActiveLeafChanged(sessionId, artifactSnapshot)`,或在现有 session load/replay result 中携带同等事件artifact store 收到后在同一 session operation queue 中 reseed 或写 top-up snapshot。
### 5.5 恢复时校验
恢复时必须重新校验:
- `workspacePath`:仍必须是相对路径,按 restore 时的 workspace root 重新 resolve/realpath/stat不能逃逸当前 workspace。workspace 重定位后,如果相同相对路径仍存在则可恢复为 `available`;如果文件缺失或新 workspace layout 不一致,则恢复为 `missing`。V2 不做自动 path remapping。
- `external_url`:只允许 `http:` / `https:`;拒绝 username/password credentialsecret-like query/fragment 必须 redacted、降级为 non-openable locator或整条 artifact 降级/阻断。
- `published`:可以恢复 `file:` locator但只能在 trusted publisher manifest 重新校验通过、且目标属于 daemon-managed published storage 时允许。普通 `external_url` 永远不能通过 `file:`
- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。
- 旧 `contentRef`:只作为 legacy journal 输入校验并 stripPR #6259 不通过 daemon-managed manifest 解析内容,也不把旧 `contentRef` 暴露为可打开内容承诺。
- `metadata`:重新执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。
恢复失败时:
- 安全失败:保留条目但 `restoreState: "blocked"``status: "missing"`,不提供可打开 locator。
- 资源缺失:`status: "missing"`
- 非安全型字段损坏:跳过该 artifact并记录 warning。
### 5.6 Branch / fork 语义
现有 `/branch` 会复制 active JSONL record chain 并重写 `sessionId`。V2 artifact records 只从 active leaf chain 复制rewind 后落在 abandoned branch 上的 artifact records 不会进入 fork。复制时必须显式处理 artifact id
- 同一个资源在新 session 中应得到新 artifact id因为 V1 identity 包含 `sessionId`
- fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。
- tombstone 也要按目标 session 的新 id 重写。只要 tombstone 的 artifact id 可以安全 remap就应保留到目标 session即使目标 active chain 中暂时找不到对应 upsertorphan tombstone 没有匹配 upsert 时是无害的,但丢弃它可能让后续同 id upsert 丢失 suppression。
- `forkedFrom` 可以记录原 session id / 原 artifact id作为审计信息但不能参与新 session 的权限判断。
- fork 继承旧 `pinned` artifact metadata 时,必须降级为 `restorable`,并移除旧 `contentRef`
- fork copy 必须重新执行 ingest/restore validation、privacy minimization 和 redaction。workspace / url / metadata 中无法在目标 session 安全表达的 locator 必须降级、strip 或丢弃,不能因为源 session 曾经通过校验就直接复制。
- `managedId` 不能从源 session 盲目复制。目标 session 中若能从目标 workspace / daemon-managed manifest 派生新的 `managedId`,必须重新计算;不能安全派生时必须移除 `managedId` 或丢弃该 artifact metadata。
fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone就必须在 fork 时丢弃 artifact persistence records不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制artifact 也只能从 active-chain replay result 生成 top-up不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。
当前 fork 实现不是逐条 append而是先从 source active chain 生成完整目标 record 列表,再用 exclusive-create 写入目标 JSONL 文件;写入失败时目标 session 文件不会被当作成功 fork 使用。因此当前 PR 不写 `session_artifact_fork_marker`。如果未来 fork 改为 streaming append 或跨进程批量复制,再引入 begin/complete marker、count 校验和 `fork_incomplete` 恢复规则。
fork 的 rewind 语义是 branch-scoped目标 session 只复制当前 active chain 的结果。如果用户 rewind 到显式 DELETE 之前再 fork那个 DELETE tombstone 本来就不在 active chain 中artifact 在新 branch 中重新出现是预期的历史分支行为。若产品需要“全局不可 rewind 删除”或隐私擦除语义,应作为单独的 policy 设计,不能混入 V2 默认 branch model。
metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限metadata 单条较小,且不会继承 content bytes。V2 不引入 project-level metadata quota实现必须记录 forked artifact count metric/log若实际滥用再引入 project-level cap。
## 6. API 设计
### 6.1 Capability
`GET /capabilities` 增加:
```json
"session_artifacts_persistence"
```
内容保留拆分 PR 实现可用时,才同时声明:
```json
"session_artifacts_content_retention"
```
当前 `/capabilities` 是 string feature list因此不能用 `enabled: false` 表达“实现存在但当前关闭”。规则是:
- 行为可用且当前配置启用时才声明对应 feature string。
- chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`
- future content archive 的显式 workspace content 保存、quota、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。PR #6259 不声明该 capability。
- 如果 client 需要读取 limits/default retention应另设计 config endpoint 或 SDK config query不要把结构化 details 混入现有 string-only capability contract。
### 6.2 Add artifact
`POST /session/:id/artifacts` 允许 optional
```json
{
"title": "Report",
"kind": "html",
"storage": "workspace",
"workspacePath": "reports/run.html",
"retention": "restorable",
"clientRetained": true
}
```
限制:
- client 可以请求 `ephemeral``restorable`
- client 不能请求 `pinned`
- `clientRetained` 可选,仅表示用户保留意图和排序 hint服务端必须按 §5.1 校验来源,不能把它当授权。
### 6.3 Pin/save artifact
PR #6259 不暴露 pin/save endpoint。显式内容留档、content archive、pin/save 语义如未来需要,应基于新的产品需求重新设计,不能从本文当前 metadata persistence contract 推导。
### 6.4 Unpin
PR #6259 不暴露 unpin endpoint也不会生成新的 unpin tombstone。旧 journal 里的 `reason: "unpin_to_ephemeral"` 只作为兼容输入继续 replay避免历史记录恢复语义变化。若要从列表移除仍使用 V1 DELETE。
### 6.5 Delete artifact
V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义:
- 先从 live store 移除 artifact保持用户可见删除即时生效。
- 随后 best-effort append `session_artifact_event` remove tombstonetombstone 成功后metadata restore 时不再复活。
- tombstone 失败时,返回成功 mutation result 但附带 warning当前 daemon 生命周期内该 artifact 已被删除,但如果 daemon 在 tombstone 持久化前重启,旧 durable artifact 仍可能恢复。用户或上层 UI 可以在 storage 恢复后重试 DELETE。
- DELETE 对不存在的 artifact 保持幂等成功;如果已有 durable tombstone重复 DELETE 不需要再写同一 tombstone。
- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC`contentRef` metadata 只在 restore/serialization 时被降级或移除。
### 6.6 Mutation responses
PR #6259 只交付 DELETE mutation response。
成功:
- DELETE`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`
- DELETE tombstone 持久化失败时仍返回 `200 OK` mutation result并在 `warnings` 中包含持久化失败原因;当前实现使用字符串 warning例如 `remove_not_persisted`。这表示 live delete 已生效但跨重启不保证,不能把它展示成 durable delete 成功。
失败:
```json
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "retention must be ephemeral or restorable"
}
}
```
PR #6259 的 HTTP mapping
- `400 VALIDATION_FAILED`:非法 body、client 请求 `pinned`、artifact 不存在、metadata quota 已满且没有可裁剪 candidate或 writer 不可用但 mutation 必须严格 durable 完成。
- `403 FORBIDDEN`:缺少 session mutate 权限。
- DELETE 保持幂等;不存在的 artifact 返回空 mutation result 而不是错误。
- DELETE tombstone 持久化失败返回 `200 OK` + warning因为当前 live delete 已生效但跨重启不保证。
更细粒度的 `INVALID_ARGUMENT``NOT_FOUND``CONFLICT``METADATA_QUOTA_EXCEEDED``QUOTA_EXCEEDED``PERSISTENCE_UNAVAILABLE` HTTP error code 是后续 API polish不属于当前 PR 的 wire contract。
## 7. 安全设计
### 7.1 授权原则
不要把 public `clientId` 当授权边界。V2 的实际 HTTP 信任边界仍是 daemon bearer token + route-level read/mutate permission在现有 auth 模型下,`session_owner` 不能被安全 mint 或跨 daemon restart 持久化。因此 V2 不引入强于 token-holder 的 owner tier。
内部 principal 只用于审计、默认策略和防止 payload spoofing不是 durable authorization source
```ts
type ArtifactPrincipal =
| { kind: 'token_holder' }
| { kind: 'client_connection'; id: string }
| { kind: 'trusted_publisher'; id: string }
| { kind: 'hook'; extensionId: string };
```
授权规则:
- list需要 session read 权限。
- add ephemeral/restorable需要 session mutate 权限。
- delete metadata需要 session mutate 权限。V1 same-principal delete guard 只能作为 live-process UX guard 和 audit hint它依赖当前连接上下文不能跨 daemon restart 证明 artifact owner。restore 后不能从 public `clientId` 伪造 ownership删除授权退化为 session-level mutate 权限并记录 `ownership_unverified` audit。
- content archive / delete content当前 PR 不启用。未来若重启 content archive需要 session mutate 权限、独立 capability、显式 REST/SDK call以及当前进程可验证的 creator-principal match 或显式 override/admin policybackground session/hook 不能直接发起内容删除。
如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL不能在本 V2 文档中隐式假设。
### 7.2 Future content archive 边界
本节是 future content archive 蓝图,不属于 PR #6259 的实现或验收范围。
默认不复制:
- external URL 内容
- 任意 workspace 文件
- 普通 assistant link
未来若启用 content archive可考虑允许的来源
- trusted `ArtifactTool` / publisher 生成的 `published` artifact。
- 用户显式 pin 的 workspace artifact且文件在 workspace 内、类型/大小可控。
- client 上传或登记的 managed artifact前提是通过 daemon API 接收并校验。
daemon-managed artifact storage 必须有明确 root
- `managed_copy` content root 位于 daemon 数据目录下的 artifact content 区域,例如 `<daemonDataDir>/artifacts/content/`
- `published` file root 位于 daemon 数据目录下的 published artifact 区域,例如 `<daemonDataDir>/artifacts/published/`,或位于配置声明的等价 daemon-owned rootroot id 必须写入 publisher manifest。
- JSONL 里不能保存可直接信任的宿主机绝对路径。restore 时只能读取 manifest 中的 root id 和相对 locatorresolve/realpath 后必须仍位于对应 root 内,并拒绝 symlink/path escape。
- trusted publisher manifest 至少记录 publisher id、artifact id、storage root id、relative path 或 content id、sha256、sizeBytes 和 createdAt。`file:` locator 只能由该 manifest 重新生成,不能来自 client payload 或旧 JSONL 字段。
内容复制必须 race-safe
- workspace containment 校验通过。
- 只允许 regular file拒绝目录、FIFO、device、socket 和其它特殊文件。
- 打开文件时使用 no-follow 语义Linux 可用 `openat2(RESOLVE_NO_SYMLINKS)`,其它平台用可用的 no-follow/open-handle revalidation 组合。
- 打开后对 file handle 执行 fstat/revalidate确认仍是 regular file、仍在 workspace containment 内。
- 拒绝 link count 异常的 hardlink除非后续有明确 allowlist。
- 读取时按 stream 强制 max bytes不能先信任 stat size。
- hash exactly the bytes copied并保存 sha256、size、mimeType。
- 打开/下载 retained content 前重新校验 manifest/hash。
### 7.3 隐私和敏感信息
持久化前必须做最小化:
- 不保存 host 绝对路径。
- 不保存 URL username/password。
- external URL 的 secret-like query/fragment 必须拒绝、redact或将 artifact 降级为 `ephemeral` / non-openable locator不能原样写入 JSONL。
- metadata 使用 allowlist 或 secret-key denylist`token``password``secret``cookie``authorization` 等 key/value 必须拒绝、redact或降级为 `ephemeral`
- metadata 仍限制 4 KB。
- title/description/metadata 继续执行 unsafe display payload checks。
- `persistenceWarning.message` 即使只作为 live response 字段,也必须使用 path-free 模板或脱敏文本;不能把 host path、credential、token、content root、connection id 写入 warning。
后续可新增设置:
```json
{
"sessionArtifacts": {
"persistence": {
"enabled": true,
"defaultRetention": "restorable",
"maxLiveArtifacts": 200,
"maxPersistedMetadata": 200,
"snapshotThresholdMutations": 100,
"snapshotThresholdBytes": 262144,
"contentRetention": {
"enabled": false,
"maxArtifactBytes": 52428800,
"maxTotalBytes": 268435456,
"maxTtlDays": 365,
"ttlScanIntervalSeconds": 900
}
}
}
}
```
当前 PR 不新增 operator 配置 schema上述值以代码常量形式发布并通过 capability 表达行为是否可用。把这些值暴露为 operator tunables 是后续增强,不能让 client 从 capability string 推断配置细节。
## 8. 配额、GC 与稳定性
### 8.1 Metadata quota
建议默认:
- live store 上限仍为 200。
- persisted metadata 上限每 session 200与 live store 对齐。
- snapshot record 最多保留 200 个当前有效 artifacts。
live store 上限在当前实现中也是 restore 可见集合的上限:
- V2 live eviction 必须优先淘汰 `ephemeral` artifact。
- 如果必须在 durable artifacts 中选择 live view当前实现按 source reservation、source、status、retention、clientRetained 和 insertion order 做确定性选择。
- durable artifact 被 live cap 淘汰时,当前实现会写 `reason: "eviction"` 的 remove event确保下一次 restore 不反复复活已被 daemon 淘汰的 item。
- `clientRetained` 是用户保留意图,进入 `PersistedSessionArtifact`,用于 restore 后稳定排序和 live cap 选择;它是排序保护,不是绝对保护。
超过 persisted metadata 上限:
- `ephemeral` 本来不写 journal不计入 persisted metadata quota只受 live store 上限约束。
- `restorable` 必须按确定性顺序裁剪并写 `eviction` remove event先裁剪未 `clientRetained``restorable` artifact如果仍无空间再裁剪 `clientRetained``restorable` artifact。`clientRetained` 是排序保护,不是绝对保护。
restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live capdaemon-side store 按同一确定性规则 seed 可见 subset并通过 operation queue 为被裁剪的 durable item 写 `eviction` remove event。`loadSession()` parse 过程本身保持 read-only不能直接写 durable prune。
### 8.2 Content quota
本节是后续 content-retention PR 的实现范围PR #6259 不引入 content store quota。
后续拆分 PR 的建议默认:
- 单 artifact50 MB。
- content store total256 MB。
达到上限时:
- 新 pin/save 返回 `QUOTA_EXCEEDED`
- 不自动删除仍被当前 session live artifact 引用的 pinned content。
- fork 不继承 pinned contentRef避免 fork 绕过 quota。
### 8.3 GC
本节是后续 content-retention PR 的实现范围。GC 只处理 daemon 管理的 session-scoped managed copy
- content manifest 保存 `sessionId``artifactId`GC 只删除 manifest 属于当前 session 且不在当前 live `contentRefs()` 引用集合中的 content。
- `pinWorkspaceFile()`、GC、tmp cleanup 通过同一个 write queue 串行化,并用 in-flight lease 避免并发 pin/GC 删除刚复制但尚未 journal 的 content。
- `expiresAt` 到期通过 `GET /artifacts` 前的 lightweight prune 把 pinned artifact 降级为 `restorable`,移除 `contentRef` 后再触发 GC。
- close / explicit delete / unpin / explicit GC endpoint 都会 best-effort sweepGC 失败不阻塞 prompt/tool flow。
GC trigger
- artifact delete、unpin、TTL 到期检查、session close 或 explicit `POST /session/:id/artifacts/gc`
- stale `.tmp` entries are cleaned during GC.
Project-scoped reference rebuild、incomplete-scan tracking、orphan grace period 和 global artifact library 都是后续增强。future content archive 的 safety 边界应来自“不跨 session 继承 contentRef”和“只删除当前 session manifest 且当前 live refs 未引用的 content”。
### 8.4 Crash consistency
要求:
- artifact store mutation 串行。
- JSONL journal append 失败不会破坏 live store。
- explicit DELETE live-firstlive store removal must not be blocked by journal failure; response warning tells clients when the tombstone was not durable.
- explicit DELETE with `deleteContent: true` is only available in the content-retention follow-up; that PR must run best-effort session-scoped content GC after live removal and surface content delete warnings.
- live cap eviction for durable artifacts writes an `eviction` remove event so restore respects the cap.
- reader 容忍半截 JSONL 和 corrupt artifact record。
- tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。
Future content archive 的写入顺序:
1. 复制内容到 staging pathhash exactly copied bytes并 fsync bytes。
2. atomically move 到 daemon-managed content root写入并 fsync content manifest。
3. append artifact journal event引用该 contentRef并 fsync JSONL。
4. 更新 live store 并发布 `artifact_changed`
如果第 2 步成功但第 3 步前 crash会留下没有 journal 引用的 orphan content这是允许的future session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。
### 8.5 文件读取、CPU 与 I/O 成本
V2 要避免把 artifact 恢复变成 session load 的新瓶颈。
读取路径建议:
1. `SessionService.loadSession()` 已经读取 JSONL 时,在同一轮 parse 中提取 artifact records。
2. 找到最新 valid `session_artifact_snapshot`,只 replay 之后的 artifact events。
3. 没有 valid snapshot 时允许一次顺序扫描 artifact records但不能在 load 流程里反复扫同一文件。
CPU 成本边界:
- Metadata restore 只 parse JSON 和做字段校验,复杂度 O(artifact 数量 + 最新 snapshot 后事件数)。
- `external_url` 恢复不发网络请求。
- `workspace` load/replay 只恢复 metadataGET/list refresh 在 TTL/batch 限制下重新 stat 单个或一批 workspace 文件,必要时才 hash用于区分 `available` / `missing` / `changed`
- `managed` / `published` 恢复只查 manifest不读取大文件内容。
- workspace content hash 不在 `loadSession()` 的 JSONL parse 阶段全量执行。GET/list refresh 先用 size + mtimeMs 做 cheap stat gate只有 stat 显示可能同尺寸改写时才读取文件流计算 sha256。
I/O 成本边界:
- V2 不额外读 sidecar 文件。
- workspace 状态校验复用 V1 的 TTL/batch 策略,不在 GET 热路径对所有 artifact 做无限制 stat。
- 对大 workspace 文件,不在恢复阶段读内容;登记时读取实时文件流计算 sha256后续 refresh 只有 size/mtimeMs 显示可能变更时才重新读取文件流,不复制到 daemon-managed storage。
推荐默认:
- artifact snapshot 上限 200 条。
- workspace status restore batch size 20与 V1 保持一致。
- artifact journal snapshot 阈值 100 mutations 或 256 KB。
- workspace sha256 在登记时同步完成;恢复后的状态校验按 TTL/batch lazy refresh并通过 size + mtimeMs 避免对未变化文件重复做全量 hash。
### 8.6 Observability
V2 新增的失败路径必须有 structured logs格式沿用
```text
[artifacts] session=<id> action=<action> key=value
```
建议 action
- `persist_failed`
- `retention_downgraded`
- `restore_skipped`
- `restore_blocked`
- `remove_not_persisted`
- `eviction`
- `fork_artifact_discarded`
- `fork_incomplete`
- `snapshot_invalid`
- `sticky_override_suppressed`
- `tombstone_conflict`
- `v2_writer_version_gate_failed`
Future checker / content archive 可以再增加 fsck、content copy、TTL、GC 相关 actionPR #6259 不产生这些日志。
这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。
建议 metrics
- counter: `artifact_journal_append_total{result,reason}`
- counter: `artifact_restore_total{result,restore_state}`
- gauge: `artifact_pending_tombstone_count`
- gauge: `artifact_metadata_quota_used{session}`
- counter: `artifact_sticky_override_suppressed_total`
导出方式沿用 daemon 现有 telemetry/metrics 机制;如果当前没有 Prometheus endpoint至少要进入 structured telemetry sink并能按 session/project 聚合。
诊断工具是后续增强,不属于 PR #6259 的 wire contract。metadata-only checker 可扫描 artifact journal/snapshot/tombstone 与 restore validation failurefull content checker 则等 future content archive 重新设计后,再扫描 content manifests 和 daemon-managed storage。未来 CLI 或 daemon-internal API例如 `qwen artifact fsck`)应支持 dry-run
- metadata-only 模式报告 snapshot/tombstone 不一致和 restore validation failure。
- full content 模式报告 dangling `contentRef`、manifest 缺失和 orphan content。
- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot 或标记 orphan content 等待 GC。
## 9. 实现方案
以下是同一个 V2 design phase 内的实现里程碑。工程上可以按 PR 拆开;对外以 capability 声明实际可用能力。
### Milestone A: 类型和 persistence service
- 新增 artifact persistence reader/writer
- writer 位于 chat recording owner 一侧,或者由该侧暴露明确 RPC它负责 append event/snapshot record 到 active leaf chain。
- reader 位于 `SessionService.loadSession()` parse/replay 路径,负责从 active leaf chain rebuild artifact snapshot。
- 共享 restore validation、snapshot/tombstone consistency checks 和 persisted shape normalization。
- 扩展 `ChatRecord.subtype``systemPayload` union。
- 增加 load result 中的 `artifactSnapshot?`
- metadata-only checker 是后续增强,可 dry-run 检测 corrupt artifact records、snapshot/tombstone 不一致和 restore validation failure。
### Milestone B: daemon-side store 集成
- daemon bridge `createSessionEntry` 支持 seed artifacts。
- `SessionArtifactStore` 支持 seed artifacts。
- `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view再通过 writer append durable records。
- `remove()` 区分 explicit DELETE 和 evictionexplicit DELETE live-first 并 best-effort 写 tombstonedurable eviction 写 journal。旧 `unpin_to_ephemeral` 只在 journal replay / snapshot sticky state 中保留兼容。
- V1 live session 首次启用 V2 的 backfill snapshot 不在当前 PR 实现范围内;当前实现从新写入的 V2 journal/snapshot 恢复。
- 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。
### Milestone C: load/replay 集成
- `SessionService.loadSession()` 从 active leaf chain 提取 artifact snapshot/event records忽略 abandoned branches。
- load result 把 snapshot 交给 daemon bridge而不是在 ACP child process 中 seed store。
- restore over-cap prune 写入只能在 daemon-side store 创建并且 writer 可用后执行load parse 过程保持 read-only。
- rewind/leaf switch 后daemon-side live store 重新对齐 active-chain replay result或通过 artifact snapshot top-up 固化 surviving chain 的当前状态。
- rewind/leaf-switch 必须调用明确 hook例如 `onActiveLeafChanged(sessionId, artifactSnapshot)`,让 daemon-side store 在 operation queue 中完成 reseed/top-up。
- replay 历史时同 identity artifact 不重复创建。
- `/branch` 从 active chain 复制 artifact records 并 remap session id/artifact id当前 full-file exclusive-create 写入路径不需要 fork marker。
### Milestone D: REST/SDK
- SDK type 增加 optional fields。
- `POST /session/:id/artifacts` 支持 `retention: "ephemeral" | "restorable"`
- `POST /session/:id/artifacts` 支持 `clientRetained` boolean hint并拒绝 client 传入 daemon-only runtime fields。
- capability gate UI。
### Milestone E: Future content archive
不属于 PR #6259。若未来有审计/留档需求,需要单独设计 daemon-managed workspace content manifest、quota、race-safe copy、hash 校验、write-queue/lease-protected GC/fsck 和 published artifact content binding。
## 10. 测试计划
PR #6259 当前必须覆盖:
- metadata journal append 后 daemon restart/load 恢复 artifact list。
- artifact journal append 通过 chat recording owner 写入 active leaf chaindaemon-side store 不能直接写 JSONL。
- `/rewind` 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。
- `/rewind` 后 live store 立即与 active-chain artifact state 对齐;不会等到 daemon 重启才改变 artifact list。
- V1 live session 升级到 V2 时的 backfill snapshot 是后续增强;当前 PR 测试应确认未写入 V2 journal 的旧 live artifacts 不被误报为可恢复。
- DELETE tombstone 后 load 不复活 artifact。
- legacy `unpin_to_ephemeral` tombstone replay 后 load 不复活 artifact。
- legacy `unpin_to_ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only显式 `restorable` 可以 supersede sticky override。
- snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only并产生 `sticky_override_suppressed` log/metric/warning。
- `stickyEphemeralIds` 达到上限时legacy unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。
- explicit DELETE live-firstlive view 立即移除tombstone 写入失败时 response 带 warning测试覆盖 live removal 不被 persistence failure 阻断。
- durable artifact eviction 写 `eviction` remove eventrestore 后不会超过 live cap。
- snapshot baseline advanceperiodic snapshot 压缩当前 artifact listexplicit tombstone 在 snapshot 成功后不再无界增长,`stickyEphemeralIds` 保留 sticky state。
- workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。
- workspace root 重定位:相同相对路径存在时恢复为 available缺失或 layout 不一致时恢复为 missing不做 path remap。
- external URL 只恢复 metadata不发网络请求。
- secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。
- published local `file:` 只有 trusted manifest revalidation 通过时恢复。
- `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态fork 不能盲目复制源 session 的 `managedId`
- corrupt JSONL record 被跳过且不影响其它 artifacts。
- chat recording / persistence disabled 时不声明或不启用 metadata restore。
- tool artifact 持久化失败时降级为 live-only并通过 `persistenceWarning` 让 client 可见。
- branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。
- fork full-file writeactive-chain remap 后 exclusive-create 写入目标 JSONL失败不产生成功 fork如果未来改为 streaming fork再补 begin/complete marker 测试。
- fork / restore 读取旧 `pinned` artifact 时降级为 restorable不继承 contentRef。
- orphan tombstone 在 fork remap 时被保留并安全 remap无法安全 remap 的 tombstone 才丢弃。
- fork remap 重新执行 validation、privacy minimization 和 redactionunsafe locator 被 strip、降级或丢弃。
- restore seed 与 concurrent POST 串行,不丢写、不重复。
- quota 边界200 条、201 条 prune、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。
- clientRetained setterAdd artifact request 能设置 boolean hint后台自动 ingest 不能伪造用户保留。
- workspace 三态:登记时写入 size + `metadata["qwen.workspace.sha256"]` + `metadata["qwen.workspace.mtimeMs"]`GET/list refresh 能区分 `available``missing``changed`,且未变化文件只走 stat 快路径。
- authorizationtoken-holder/principal 审计路径允许和拒绝情况V1 live same-principal guard 仅作为 live UX/audit hint不作为 durable security boundary。
- JSONL snapshot baseline advancethreshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky stateJSONL 文件本身不被 artifact 子系统重写。
- corrupt latest snapshot fallback回退到较旧 valid snapshot 或一次顺序 artifact replay。
- retention defaultstool artifact 无显式 retention、client POST `pinned` 被拒绝。
- capabilitystring list 只在行为当前可用时声明;不依赖 `enabled:false` details。
- replay idempotency同一 session history replay 两次不会重复 artifact。
- SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。
- V2 -> V1 rollback compatibility旧 daemon 必须能解析或忽略 unknown `system` subtype不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。
- rollback preflight最低支持旧 daemon 版本加载包含 V2 event/snapshot 的 JSONL如果未来加入 fork marker再扩展 rollback fixture。
- PR #6259 覆盖 metadata API response contractdelete success body、metadata quota validation failure、`remove_not_persisted` / `persistence_unavailable` warning、current 400/403/200+warning mapping。
Future content archive / checker 另行覆盖:
- `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。
- pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。
- metadata-only checker dry-runcorrupt record、snapshot fallback、orphan tombstone、restore validation failure。
- full content checker dry-rundangling `contentRef`、manifest 缺失、orphan content 和 GC 修复策略。
## 11. 不建议在 V2 做的事
- 自动抓取普通 markdown link。
- 自动扫描 workspace 文件变更。
- 默认复制所有 workspace artifact 内容。
- 对 external URL 做 reachability poll。
- 把 `clientId` 作为删除授权凭证。
- 对重定位 workspace 做自动 path remapping。
- 在 GET 热路径里做大量 fs/network 校验。
- 把持久化失败变成普通 tool turn 失败。
- 在没有测量证明需要时引入 sidecar cache。
## 12. 推荐发布口径
V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露:
- `session_artifacts_persistence` 可先发布 metadata restore。
- `session_artifacts_content_retention` 当前不发布future content archive 需要重新设计并独立声明 capability。
- 默认恢复显式登记的 artifact metadata。
- 用户手动注册的 artifact 默认 `restorable`session load/replay 后继续出现在列表中。
- 用户文档明确metadata restore 恢复的是“产物索引”不是“产物内容备份”workspace 的 `changed` 状态只说明实时文件和登记时 size 不一致,或 mtime 变化后 hash 不一致。
Rollback procedure
- V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时session load 应继续工作但不恢复 artifact persistence。
- daemon-managed content storage 不属于 PR #6259;后续 content-retention PR 需要单独定义 rollback 后 retained bytes 的清理流程。
- 如果当前最低支持旧版本不能安全忽略 V2 system recordswriter 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard阻止写入 V2 records。
- 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event``session_artifact_snapshot` 的 JSONL断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate失败时拒绝写 V2 records记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker再把该 subtype 纳入 rollback fixture。
- rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。
这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/mtime/hash 避免静默打开错误版本,同时避免对未变化文件重复做全量 hash。

View file

@ -248,6 +248,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_cancel',
'session_events',
'session_artifacts',
'session_artifacts_persistence',
'slow_client_warning',
'typed_event_schema',
'session_set_model',

View file

@ -51,7 +51,13 @@ import type { ChannelFactory } from './channel.js';
import type { BridgeTelemetry } from './bridgeOptions.js';
import { createInMemoryChannel } from './inMemoryChannel.js';
import { EventBus, type BridgeEvent } from './eventBus.js';
import { ApprovalMode, ShellExecutionService } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
SESSION_ARTIFACT_PERSISTENCE_VERSION,
ShellExecutionService,
stableSessionArtifactId,
ToolNames,
} from '@qwen-code/qwen-code-core';
import {
FakeAgent,
type ChannelHandle,
@ -61,6 +67,7 @@ import {
WS_B,
SESS_A,
} from './internal/testUtils.js';
import { SessionArtifactAuthorizationError } from './sessionArtifacts.js';
function deferred<T>(): {
promise: Promise<T>;
@ -232,9 +239,9 @@ describe('createAcpSessionBridge', () => {
{
title: 'Client link',
source: 'client',
clientId: session.clientId,
},
]);
expect(snapshot.artifacts[0]).not.toHaveProperty('clientId');
expect(snapshot.artifacts[0]).not.toHaveProperty('toolName');
expect(snapshot.artifacts[0]).not.toHaveProperty('hookEventName');
expect(snapshot.artifacts[0]).not.toHaveProperty('toolCallId');
@ -260,19 +267,26 @@ describe('createAcpSessionBridge', () => {
);
const artifactId = created.changes[0]!.artifactId;
await expect(
bridge.getSessionArtifacts(first.sessionId, {
clientId: 'forged-client',
}),
).rejects.toBeInstanceOf(InvalidClientIdError);
await expect(
bridge.removeSessionArtifact(first.sessionId, artifactId, {
clientId: second.clientId,
}),
).resolves.toMatchObject({ changes: [] });
).rejects.toBeInstanceOf(SessionArtifactAuthorizationError);
await expect(
bridge.removeSessionArtifact(first.sessionId, artifactId),
).resolves.toMatchObject({ changes: [] });
).rejects.toBeInstanceOf(SessionArtifactAuthorizationError);
await expect(
bridge.getSessionArtifacts(first.sessionId),
).resolves.toMatchObject({
artifacts: [{ id: artifactId, clientId: first.clientId }],
artifacts: [{ id: artifactId }],
});
const listed = await bridge.getSessionArtifacts(first.sessionId);
expect(listed.artifacts[0]).not.toHaveProperty('clientId');
await expect(
bridge.removeSessionArtifact(first.sessionId, artifactId, {
@ -286,6 +300,27 @@ describe('createAcpSessionBridge', () => {
}
});
it('rejects invalid client artifact records instead of dropping them', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
try {
await expect(
bridge.addSessionArtifact(
session.sessionId,
{
title: 'x'.repeat(201),
url: 'https://example.com/client',
},
{ clientId: session.clientId },
),
).rejects.toThrow(/title/);
} finally {
await bridge.shutdown();
}
});
it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => {
const handle = makeChannel();
const operations: string[] = [];
@ -1365,7 +1400,11 @@ describe('createAcpSessionBridge', () => {
lastEventId: 0,
});
expect(handles[0]?.agent.loadSessionCalls).toEqual([
{ sessionId: 'persisted-1', cwd: WS_A, mcpServers: [] },
{
sessionId: 'persisted-1',
cwd: WS_A,
mcpServers: [],
},
]);
expect(bridge.sessionCount).toBe(1);
@ -1380,6 +1419,524 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('restores artifact snapshots that omit marker arrays after fork remap', async () => {
const sessionId = 'persisted-artifacts';
const artifactUrl = 'https://example.com/restored';
const artifactId = stableSessionArtifactId(sessionId, `url:${artifactUrl}`);
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
loadSessionImpl: () =>
({
artifactSnapshot: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 7,
artifacts: [
{
id: artifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Restored link',
url: artifactUrl,
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
},
],
warnings: ['skipped corrupt artifact record'],
},
}) as LoadSessionResponse,
}).channel,
});
const loaded = await bridge.loadSession({
sessionId,
workspaceCwd: WS_A,
});
expect(loaded.state).not.toHaveProperty('artifactSnapshot');
expect(loaded.artifactWarnings).toEqual([
'skipped corrupt artifact record',
]);
await expect(
bridge.getSessionArtifacts(loaded.sessionId),
).resolves.toMatchObject({
warnings: ['skipped corrupt artifact record'],
artifacts: [
{
id: artifactId,
title: 'Restored link',
restoreState: 'restored',
},
],
});
await bridge.shutdown();
});
it('normalizes artifact snapshots returned by session restore', async () => {
const sessionId = 'persisted-artifact-normalize';
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
loadSessionImpl: () =>
({
artifactSnapshot: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 7,
artifacts: [{ malformed: true }],
tombstonedIds: ['x'.repeat(201)],
stickyEphemeralIds: ['y'.repeat(201)],
warnings: ['kept warning', 'z'.repeat(1001), 123],
},
}) as LoadSessionResponse,
}).channel,
});
const loaded = await bridge.loadSession({
sessionId,
workspaceCwd: WS_A,
});
expect(loaded.state).not.toHaveProperty('artifactSnapshot');
expect(loaded.artifactWarnings).toEqual([
'skipped artifact without id/title',
'kept warning',
]);
await expect(
bridge.getSessionArtifacts(loaded.sessionId),
).resolves.toMatchObject({
warnings: ['skipped artifact without id/title', 'kept warning'],
artifacts: [],
});
await bridge.shutdown();
});
it('clears durable artifacts but keeps live ephemerals when rewind returns an empty artifact snapshot', async () => {
const persistedSnapshots: unknown[] = [];
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
extMethodImpl: (method, params) => {
if (method === 'qwen/control/session/artifacts/persist') {
if (params['kind'] === 'snapshot') {
persistedSnapshots.push(params['payload']);
}
return {};
}
expect(method).toBe('qwen/control/session/rewind');
return {
targetTurnIndex: 0,
filesChanged: [],
filesFailed: [],
artifactSnapshot: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: SESS_A,
sequence: 0,
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
},
};
},
}).channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const durable = await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Abandoned durable artifact',
url: 'https://example.com/later-durable',
},
{ clientId: session.clientId },
);
const ephemeral = await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Live ephemeral artifact',
url: 'https://example.com/later-ephemeral',
retention: 'ephemeral',
},
{ clientId: session.clientId },
);
expect(durable.changes).toHaveLength(1);
expect(ephemeral.changes).toHaveLength(1);
await expect(
bridge.rewindSession(
session.sessionId,
{ promptId: 'prompt-1' },
{ clientId: session.clientId },
),
).resolves.toMatchObject({ targetTurnIndex: 0 });
const artifacts = (await bridge.getSessionArtifacts(session.sessionId))
.artifacts;
expect(artifacts).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Live ephemeral artifact',
retention: 'ephemeral',
}),
]),
);
expect(
artifacts.some(
(artifact) => artifact.id === durable.changes[0]?.artifactId,
),
).toBe(false);
expect(persistedSnapshots).toEqual([
expect.objectContaining({
sessionId: session.sessionId,
artifacts: [],
tombstonedIds: [],
}),
]);
await bridge.shutdown();
});
it('keeps durable artifacts when rewind cannot rebuild an artifact snapshot', async () => {
const persistedSnapshots: unknown[] = [];
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
extMethodImpl: (method, params) => {
if (method === 'qwen/control/session/artifacts/persist') {
if (params['kind'] === 'snapshot') {
persistedSnapshots.push(params['payload']);
}
return {};
}
expect(method).toBe('qwen/control/session/rewind');
return {
targetTurnIndex: 0,
filesChanged: [],
filesFailed: [],
artifactSnapshotUnavailable: 'artifact journal read failed',
};
},
}).channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const durable = await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Durable artifact',
url: 'https://example.com/durable-after-failed-rebuild',
},
{ clientId: session.clientId },
);
await expect(
bridge.rewindSession(
session.sessionId,
{ promptId: 'prompt-1' },
{ clientId: session.clientId },
),
).resolves.toMatchObject({ targetTurnIndex: 0 });
await expect(
bridge.getSessionArtifacts(session.sessionId),
).resolves.toEqual(
expect.objectContaining({
artifacts: [
expect.objectContaining({
id: durable.changes[0]?.artifactId,
title: 'Durable artifact',
}),
],
}),
);
expect(persistedSnapshots).toEqual([]);
await bridge.shutdown();
});
it('keeps durable artifacts when rewind omits artifact snapshot metadata', async () => {
const persistedSnapshots: unknown[] = [];
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
extMethodImpl: (method, params) => {
if (method === 'qwen/control/session/artifacts/persist') {
if (params['kind'] === 'snapshot') {
persistedSnapshots.push(params['payload']);
}
return {};
}
expect(method).toBe('qwen/control/session/rewind');
return {
targetTurnIndex: 0,
filesChanged: [],
filesFailed: [],
};
},
}).channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const durable = await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Version-skew durable artifact',
url: 'https://example.com/durable-version-skew',
},
{ clientId: session.clientId },
);
await expect(
bridge.rewindSession(
session.sessionId,
{ promptId: 'prompt-1' },
{ clientId: session.clientId },
),
).resolves.toMatchObject({ targetTurnIndex: 0 });
await expect(
bridge.getSessionArtifacts(session.sessionId),
).resolves.toEqual(
expect.objectContaining({
artifacts: [
expect.objectContaining({
id: durable.changes[0]?.artifactId,
title: 'Version-skew durable artifact',
}),
],
}),
);
expect(persistedSnapshots).toEqual([]);
await bridge.shutdown();
});
it('restores and persists artifact snapshots returned by rewind', async () => {
const retainedUrl = 'https://example.com/retained';
const rewoundUrl = 'https://example.com/rewound';
const stickyUrl = 'https://example.com/rewound-sticky';
const rewoundArtifactId = stableSessionArtifactId(
SESS_A,
`url:${rewoundUrl}`,
);
const stickyArtifactId = stableSessionArtifactId(
SESS_A,
`url:${stickyUrl}`,
);
const persistedSnapshots: unknown[] = [];
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
extMethodImpl: (method, params) => {
if (method === 'qwen/control/session/artifacts/persist') {
if (params['kind'] === 'snapshot') {
persistedSnapshots.push(params['payload']);
}
return {};
}
expect(method).toBe('qwen/control/session/rewind');
return {
targetTurnIndex: 0,
filesChanged: [],
filesFailed: [],
artifactSnapshot: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: SESS_A,
sequence: 8,
artifacts: [
{
id: rewoundArtifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Rewound artifact',
url: rewoundUrl,
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
},
],
tombstonedIds: [],
stickyEphemeralIds: [stickyArtifactId],
markerArtifacts: [
{
id: stickyArtifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Sticky artifact',
url: stickyUrl,
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-04T00:00:00.000Z',
updatedAt: '2026-07-04T00:00:00.000Z',
},
],
warnings: [],
},
};
},
}).channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Later artifact',
url: retainedUrl,
},
{ clientId: session.clientId },
);
const ephemeral = await bridge.addSessionArtifact(
session.sessionId,
{
title: 'Live ephemeral artifact',
url: 'https://example.com/live-ephemeral-during-rewind',
retention: 'ephemeral',
},
{ clientId: session.clientId },
);
const abort = new AbortController();
const iter = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
const artifactChanges = (async () => {
const changes: unknown[] = [];
for await (const event of iter) {
if (event.type !== 'artifact_changed') continue;
changes.push((event.data as { change?: unknown }).change);
if (changes.length === 2) return changes;
}
return changes;
})();
await expect(
bridge.rewindSession(
session.sessionId,
{ promptId: 'prompt-1' },
{ clientId: session.clientId },
),
).resolves.toMatchObject({ targetTurnIndex: 0 });
await expect(artifactChanges).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'removed', reason: 'eviction' }),
expect.objectContaining({
action: 'created',
artifactId: rewoundArtifactId,
}),
]),
);
abort.abort();
const artifacts = (await bridge.getSessionArtifacts(session.sessionId))
.artifacts;
expect(artifacts).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rewoundArtifactId,
title: 'Rewound artifact',
restoreState: 'restored',
}),
expect.objectContaining({
id: ephemeral.changes[0]?.artifactId,
title: 'Live ephemeral artifact',
retention: 'ephemeral',
}),
]),
);
expect(persistedSnapshots).toEqual([
expect.objectContaining({
sessionId: session.sessionId,
artifacts: [
expect.objectContaining({
id: rewoundArtifactId,
title: 'Rewound artifact',
}),
],
markerArtifacts: [
expect.objectContaining({
id: stickyArtifactId,
title: 'Sticky artifact',
url: stickyUrl,
}),
],
}),
]);
expect(
(persistedSnapshots[0] as { artifacts?: unknown[] }).artifacts,
).toHaveLength(1);
await bridge.shutdown();
});
it('surfaces rewind artifact snapshot persistence warnings', async () => {
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({
extMethodImpl: (method, params) => {
if (method === 'qwen/control/session/artifacts/persist') {
if (params['kind'] === 'snapshot') {
throw new Error('disk full');
}
return {};
}
expect(method).toBe('qwen/control/session/rewind');
return {
targetTurnIndex: 0,
filesChanged: [],
filesFailed: [],
artifactSnapshot: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: SESS_A,
sequence: 0,
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
},
};
},
}).channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const abort = new AbortController();
const rewoundEvent = (async () => {
for await (const event of bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
})) {
if (event.type === 'session_rewound') return event;
}
return undefined;
})();
await expect(
bridge.rewindSession(
session.sessionId,
{ promptId: 'prompt-1' },
{ clientId: session.clientId },
),
).resolves.toMatchObject({
targetTurnIndex: 0,
warnings: ['artifact snapshot not persisted'],
});
await expect(rewoundEvent).resolves.toMatchObject({
type: 'session_rewound',
data: { warnings: ['artifact snapshot not persisted'] },
});
abort.abort();
await bridge.shutdown();
});
it('loads history replay from response metadata when requested', async () => {
const handles: ChannelHandle[] = [];
const factory: ChannelFactory = async () => {
@ -1450,6 +2007,73 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('restores artifacts from response-mode load replay when no snapshot is available', async () => {
const handles: ChannelHandle[] = [];
const factory: ChannelFactory = async () => {
const h = makeChannel({
loadSessionImpl: (p) => {
expect(p._meta).toMatchObject({
'qwen.session.loadReplayMode': 'bulk',
});
return {
_meta: {
'qwen.session.loadReplay': {
v: 1,
updates: [
{
sessionUpdate: 'tool_call_update',
toolCallId: 'call-replayed-artifact',
status: 'completed',
content: [],
_meta: {
toolName: ToolNames.ARTIFACT,
artifacts: [
{
title: 'Replayed artifact',
url: 'https://example.com/replayed-artifact',
retention: 'restorable',
},
],
},
},
],
},
},
};
},
});
handles.push(h);
return h.channel;
};
const bridge = makeBridge({ channelFactory: factory });
const loaded = await bridge.loadSession({
sessionId: 'persisted-replayed-artifact',
workspaceCwd: WS_A,
historyReplay: 'response',
});
expect(handles[0]?.agent.loadSessionCalls[0]).toMatchObject({
sessionId: 'persisted-replayed-artifact',
cwd: WS_A,
mcpServers: [],
_meta: { 'qwen.session.loadReplayMode': 'bulk' },
});
await expect(
bridge.getSessionArtifacts(loaded.sessionId),
).resolves.toMatchObject({
artifacts: [
{
title: 'Replayed artifact',
url: 'https://example.com/replayed-artifact',
restoreState: 'live',
},
],
});
await bridge.shutdown();
});
it('bounds response-mode load replay and emits a history_truncated marker', async () => {
const factory: ChannelFactory = async () =>
makeChannel({
@ -1922,6 +2546,7 @@ describe('createAcpSessionBridge', () => {
const first = bridge.loadSession({
sessionId: 'coalesce-replay-mode',
workspaceCwd: WS_A,
historyReplay: 'stream',
});
for (let i = 0; i < 50 && !releaseLoad; i++) {
await new Promise((r) => setTimeout(r, 10));
@ -12590,7 +13215,9 @@ describe('session idle reaper', () => {
// No detach — simulates client crash. Reaper catches all 3.
await vi.advanceTimersByTimeAsync(4_000);
expect(bridge.sessionCount).toBe(0);
await vi.waitFor(() => {
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
} finally {

View file

@ -17,11 +17,16 @@ import type {
SetSessionModelResponse,
SessionUpdate,
} from '@agentclientprotocol/sdk';
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
import type {
ApprovalMode,
RebuiltSessionArtifactSnapshot,
} from '@qwen-code/qwen-code-core';
import {
DAEMON_TRACEPARENT_META_KEY,
DAEMON_TRACESTATE_META_KEY,
SESSION_ARTIFACT_PERSISTENCE_VERSION,
TrustGateError,
normalizeSnapshotPayload,
ShellExecutionService,
type ShellOutputEvent,
} from '@qwen-code/qwen-code-core';
@ -123,6 +128,9 @@ import {
import { PermissionForbiddenError } from './bridgeErrors.js';
import {
SessionArtifactStore,
isArtifactRestoreFailureWarning,
publicArtifactsEqual,
type DaemonSessionArtifact,
type SessionArtifactChange,
type SessionArtifactInput,
type SessionArtifactMutationResult,
@ -2680,7 +2688,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
channel: ci.channel,
connection: ci.connection,
events,
artifacts: new SessionArtifactStore({ sessionId, workspaceCwd }),
artifacts: new SessionArtifactStore({
sessionId,
workspaceCwd,
persistence: createSessionArtifactPersistence(ci.connection, sessionId),
}),
promptQueue: Promise.resolve(),
pendingPromptCount: 0,
pendingPromptList: [],
@ -2730,6 +2742,46 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}
};
const artifactReseedChanges = (
before: readonly DaemonSessionArtifact[],
after: readonly DaemonSessionArtifact[],
): SessionArtifactChange[] => {
const beforeById = new Map(
before.map((artifact) => [artifact.id, artifact]),
);
const afterById = new Map(after.map((artifact) => [artifact.id, artifact]));
const changes: SessionArtifactChange[] = [];
for (const artifact of before) {
if (!afterById.has(artifact.id)) {
changes.push({
action: 'removed',
artifactId: artifact.id,
artifact,
reason: 'eviction',
});
}
}
for (const artifact of after) {
const previous = beforeById.get(artifact.id);
if (!previous) {
changes.push({
action: 'created',
artifactId: artifact.id,
artifact,
});
continue;
}
if (!publicArtifactsEqual(previous, artifact)) {
changes.push({
action: 'updated',
artifactId: artifact.id,
artifact,
});
}
}
return changes;
};
const makeClientArtifactInput = (
artifact: SessionArtifactInput,
clientId: string | undefined,
@ -2745,6 +2797,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
mimeType: artifact.mimeType,
sizeBytes: artifact.sizeBytes,
metadata: artifact.metadata,
retention: artifact.retention,
clientRetained: artifact.clientRetained,
source: 'client',
};
if (clientId) {
@ -2753,6 +2807,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return input;
};
function createSessionArtifactPersistence(
connection: ClientSideConnection,
sessionId: string,
) {
return {
recordEvent: async (payload: unknown): Promise<void> => {
await connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist,
{
sessionId,
kind: 'event',
payload,
},
);
},
recordSnapshot: async (payload: unknown): Promise<void> => {
await connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist,
{
sessionId,
kind: 'snapshot',
payload,
},
);
},
};
}
// A5: seed the snapshot caches from the agent's session-create response
// (`newSession` / `loadSession` / `resumeSession` all return `models` +
// `modes`). Without this the caches stay unset until the first change, so a
@ -2858,6 +2940,54 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return { lastEventId: snapshot.lastEventId, ...replayStatus };
};
const restoredArtifactSnapshotFromState = (
state: BridgeSessionState,
): RebuiltSessionArtifactSnapshot | undefined => {
const candidate = state.artifactSnapshot;
const warnings: string[] = [];
const snapshot = normalizeSnapshotPayload(candidate, warnings);
if (!snapshot) return undefined;
const snapshotWarnings =
isRecord(candidate) && Array.isArray(candidate['warnings'])
? candidate['warnings']
.filter(
(warning): warning is string =>
typeof warning === 'string' && warning.length <= 1000,
)
.slice(-500)
: [];
return {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: snapshot.sessionId,
sequence: snapshot.sequence,
artifacts: snapshot.artifacts,
...(snapshot.markerArtifacts
? { markerArtifacts: snapshot.markerArtifacts }
: {}),
tombstonedIds: snapshot.tombstonedIds ?? [],
stickyEphemeralIds: snapshot.stickyEphemeralIds ?? [],
warnings: [...warnings, ...snapshotWarnings],
};
};
const artifactSnapshotUnavailableReason = (
state: BridgeSessionState,
): string | undefined => {
const reason = state.artifactSnapshotUnavailable;
return typeof reason === 'string' && reason ? reason : undefined;
};
const publicRestoreState = (
state: BridgeSessionState,
): BridgeSessionState => {
const {
artifactSnapshot: _artifactSnapshot,
artifactSnapshotUnavailable: _artifactSnapshotUnavailable,
...publicState
} = state;
return publicState;
};
async function restoreSession(
action: 'load' | 'resume',
req: BridgeRestoreSessionRequest,
@ -3111,16 +3241,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
},
);
releaseAdmissionOnce();
entry.restoreState = state;
const restoredArtifactSnapshot = restoredArtifactSnapshotFromState(state);
const publicState = publicRestoreState(state);
entry.restoreState = publicState;
if (replayPartial === true) {
entry.restoreReplayPartial = true;
}
if (replayError !== undefined) {
entry.restoreReplayError = replayError;
}
seedSnapshotCaches(entry, state);
seedSnapshotCaches(entry, publicState);
const artifactRestoreWarnings = await entry.artifacts.restore(
restoredArtifactSnapshot,
);
for (const warning of artifactRestoreWarnings) {
writeStderrLine(
`[artifacts] session=${entry.sessionId} action=restore_warning warning=${JSON.stringify(
warning,
)}`,
);
}
const artifactRestoreFailed = artifactRestoreWarnings.some((warning) =>
isArtifactRestoreFailureWarning(warning),
);
if (replayUpdates.length > 0) {
await ci.client.seedSessionUpdates(entry, replayUpdates);
await ci.client.seedSessionUpdates(entry, replayUpdates, {
ingestArtifacts:
restoredArtifactSnapshot === undefined || artifactRestoreFailed,
});
ci.client.drainEarlyEvents(entry.sessionId, entry);
}
const clientId = registerClient(entry, req.clientId);
@ -3145,7 +3293,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
attached: false,
clientId,
createdAt: entry.createdAt,
state,
state: publicState,
...(artifactRestoreWarnings.length > 0
? { artifactWarnings: artifactRestoreWarnings }
: {}),
hasActivePrompt: entry.promptActive,
...replayFieldsFor(entry, action),
};
@ -4601,9 +4752,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return { displayName: entry.displayName };
},
async getSessionArtifacts(sessionId) {
async getSessionArtifacts(sessionId, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
return entry.artifacts.list();
},
@ -4613,9 +4765,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const clientId = resolveTrustedClientId(entry, context?.clientId);
const input = makeClientArtifactInput(artifact, clientId);
const result: SessionArtifactMutationResult =
await entry.artifacts.upsertMany([input], { strict: true });
await entry.artifacts.upsertMany([input], {
validationStrict: true,
persistenceStrict: false,
});
publishArtifactChanges(entry, result.changes, clientId);
return result;
const warnings = [...(result.warnings ?? [])];
return warnings.length > 0 ? { ...result, warnings } : result;
},
async removeSessionArtifact(sessionId, artifactId, context) {
@ -4624,7 +4780,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const clientId = resolveTrustedClientId(entry, context?.clientId);
const result = await entry.artifacts.remove(artifactId, { clientId });
publishArtifactChanges(entry, result.changes, clientId);
return result;
const warnings = [...(result.warnings ?? [])];
return warnings.length > 0 ? { ...result, warnings } : result;
},
listWorkspaceSessions(workspaceCwd) {
@ -5975,7 +6132,58 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0;
const filesChanged = (response['filesChanged'] as string[]) ?? [];
const filesFailed = (response['filesFailed'] as string[]) ?? [];
const artifactSnapshot = restoredArtifactSnapshotFromState(
response as BridgeSessionState,
);
const artifactSnapshotUnavailable = artifactSnapshotUnavailableReason(
response as BridgeSessionState,
);
const beforeArtifacts = (await entry.artifacts.list()).artifacts;
const shouldRestoreArtifactSnapshot =
artifactSnapshot !== undefined &&
artifactSnapshotUnavailable === undefined;
const artifactRestoreWarnings =
artifactSnapshotUnavailable !== undefined
? [
`artifact snapshot rebuild unavailable during rewind: ${artifactSnapshotUnavailable}`,
]
: shouldRestoreArtifactSnapshot
? await entry.artifacts.restore(artifactSnapshot, {
preserveLiveEphemeral: true,
})
: [];
const artifactRestoreFailed = artifactRestoreWarnings.some(
isArtifactRestoreFailureWarning,
);
const shouldRecordArtifactSnapshot =
shouldRestoreArtifactSnapshot && !artifactRestoreFailed;
const artifactSnapshotWarnings = shouldRecordArtifactSnapshot
? await entry.artifacts.recordSnapshot()
: [];
const artifactWarnings = [
...artifactRestoreWarnings,
...artifactSnapshotWarnings,
];
for (const warning of artifactRestoreWarnings) {
writeStderrLine(
`[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify(
warning,
)}`,
);
}
for (const warning of artifactSnapshotWarnings) {
writeStderrLine(
`[artifacts] session=${entry.sessionId} action=rewind_snapshot_warning warning=${JSON.stringify(
warning,
)}`,
);
}
const afterArtifacts = (await entry.artifacts.list()).artifacts;
publishArtifactChanges(
entry,
artifactReseedChanges(beforeArtifacts, afterArtifacts),
originatorClientId,
);
try {
entry.events.publish({
type: 'session_rewound',
@ -5985,6 +6193,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
targetTurnIndex,
filesChanged,
filesFailed,
...(artifactWarnings.length > 0
? { warnings: artifactWarnings }
: {}),
},
...(originatorClientId ? { originatorClientId } : {}),
});
@ -5997,6 +6208,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
targetTurnIndex,
filesChanged,
filesFailed,
...(artifactWarnings.length > 0 ? { warnings: artifactWarnings } : {}),
};
},

View file

@ -952,6 +952,7 @@ describe('BridgeClient — artifact ingress', () => {
storage: 'published',
url: artifactUrl,
managedId: 'managed-1',
retention: 'ephemeral',
},
],
},
@ -975,6 +976,7 @@ describe('BridgeClient — artifact ingress', () => {
artifact: expect.objectContaining({
title: 'Dashboard',
storage: 'published',
retention: 'ephemeral',
}),
}),
},
@ -986,6 +988,7 @@ describe('BridgeClient — artifact ingress', () => {
title: 'Dashboard',
storage: 'published',
managedId: 'managed-1',
retention: 'ephemeral',
},
],
});
@ -1267,6 +1270,7 @@ describe('BridgeClient — artifact ingress', () => {
{
title: 'Hook dashboard',
url: 'https://example.com/hook-dashboard',
retention: 'ephemeral',
},
],
});
@ -1282,6 +1286,7 @@ describe('BridgeClient — artifact ingress', () => {
source: 'hook',
hookEventName: 'PostToolUse',
title: 'Hook dashboard',
retention: 'ephemeral',
}),
}),
},
@ -1293,6 +1298,7 @@ describe('BridgeClient — artifact ingress', () => {
source: 'hook',
hookEventName: 'PostToolUse',
title: 'Hook dashboard',
retention: 'ephemeral',
},
],
});

View file

@ -105,6 +105,7 @@ function artifactPayloadFields(
mimeType: artifact['mimeType'] as string | undefined,
sizeBytes: artifact['sizeBytes'] as number | undefined,
metadata: artifact['metadata'] as SessionArtifactInput['metadata'],
retention: artifact['retention'] as SessionArtifactInput['retention'],
};
}
@ -717,6 +718,7 @@ export class BridgeClient implements Client {
async seedSessionUpdates(
entry: BridgeClientSessionEntry,
updates: SessionUpdate[],
options: { ingestArtifacts?: boolean } = {},
): Promise<void> {
const frames: Array<Omit<BridgeEvent, 'id' | 'v'>> = [];
const artifactBatches: Array<{
@ -729,7 +731,7 @@ export class BridgeClient implements Client {
entry,
);
frames.push(...prepared.frames);
if (prepared.artifacts.length > 0) {
if (options.ingestArtifacts !== false && prepared.artifacts.length > 0) {
artifactBatches.push({
artifacts: prepared.artifacts,
trustedPublisher: prepared.trustedPublisher,

View file

@ -62,6 +62,7 @@ export interface RewindResponse {
targetTurnIndex: number;
filesChanged: string[];
filesFailed: string[];
warnings?: string[];
}
export interface BridgeSpawnRequest {
@ -107,7 +108,7 @@ export interface BridgeRestoreSessionRequest {
workspaceCwd: string;
/** Optional echo of a daemon-issued client id for this session. */
clientId?: string;
/** Internal replay transport for `session/load`; defaults to ACP streaming. */
/** Internal replay transport for `session/load`; defaults to bulk response. */
historyReplay?: 'stream' | 'response';
}
@ -123,11 +124,19 @@ export interface BridgeLoadReplayEnvelope {
replayError?: string;
}
export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse;
export type BridgeSessionState = (
| LoadSessionResponse
| ResumeSessionResponse
) & {
artifactSnapshot?: unknown;
artifactSnapshotUnavailable?: unknown;
};
export interface BridgeRestoredSession extends BridgeSession {
/** ACP state returned by `session/load` / `session/resume`. */
state: BridgeSessionState;
/** Artifact restore warnings surfaced during session load/resume. */
artifactWarnings?: string[];
/** True when response-mode history replay aborted after emitting a prefix. */
partial?: true;
/** Agent-provided replay failure detail when `partial` is true. */
@ -571,7 +580,10 @@ export interface AcpSessionBridge {
* List the structured artifacts registered for a live session. Throws
* `SessionNotFoundError` when the id is unknown.
*/
getSessionArtifacts(sessionId: string): Promise<SessionArtifactsEnvelope>;
getSessionArtifacts(
sessionId: string,
context?: BridgeClientRequestContext,
): Promise<SessionArtifactsEnvelope>;
/**
* Register a client-supplied artifact for the session. Client artifacts use

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -138,6 +138,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
sessionRewind: 'qwen/control/session/rewind',
sessionContinue: 'qwen/control/session/continue',
sessionTitle: 'qwen/control/session/title',
sessionArtifactsPersist: 'qwen/control/session/artifacts/persist',
workspaceMcpRestart: 'qwen/control/workspace/mcp/restart',
workspaceMcpManage: 'qwen/control/workspace/mcp/manage',
workspaceAgentGenerate: 'qwen/control/workspace/agents/generate',

View file

@ -128,6 +128,23 @@ vi.mock('node:stream', async (importOriginal) => {
// Mock core dependencies
vi.mock('@qwen-code/qwen-code-core', () => ({
SESSION_ARTIFACT_PERSISTENCE_VERSION: 2,
normalizeEventPayload: vi.fn((payload: unknown) =>
typeof payload === 'object' &&
payload !== null &&
!Array.isArray(payload) &&
Array.isArray((payload as { changes?: unknown }).changes)
? payload
: undefined,
),
normalizeSnapshotPayload: vi.fn((payload: unknown) =>
typeof payload === 'object' &&
payload !== null &&
!Array.isArray(payload) &&
Array.isArray((payload as { artifacts?: unknown }).artifacts)
? payload
: undefined,
),
createDebugLogger: () => mockDebugLogger,
registerAcpEventLoopLagGauge: vi.fn(),
startEventLoopLagMonitor: vi.fn(() => ({
@ -634,6 +651,7 @@ import {
unregisterGoalHook,
startEventLoopLagMonitor,
registerAcpEventLoopLagGauge,
SESSION_ARTIFACT_PERSISTENCE_VERSION,
} from '@qwen-code/qwen-code-core';
import type { McpServer } from '@agentclientprotocol/sdk';
import { AgentSideConnection } from '@agentclientprotocol/sdk';
@ -1559,6 +1577,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
reloadModelProvidersConfig: vi.fn(),
refreshAuth: vi.fn().mockResolvedValue(undefined),
getModel: vi.fn().mockReturnValue('m'),
getProjectRoot: vi.fn().mockReturnValue('/tmp'),
getTargetDir: vi.fn().mockReturnValue('/tmp'),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getAvailableModels: vi.fn().mockReturnValue([]),
@ -1573,6 +1592,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
}),
getFileSystemService: vi.fn().mockReturnValue(undefined),
getChatRecordingService: vi.fn().mockReturnValue({
flush: vi.fn().mockResolvedValue(undefined),
}),
setFileSystemService: vi.fn(),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDisableAllHooks: vi.fn().mockReturnValue(true),
@ -1827,6 +1849,241 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
return innerConfig;
}
async function bootAcpAgent() {
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
return { agent, agentPromise };
}
it('sessionArtifactsPersist rejects a missing session id', async () => {
const { agent, agentPromise } = await bootAcpAgent();
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
kind: 'event',
payload: {},
}),
).rejects.toThrowError(/Invalid or missing sessionId/);
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist rejects an invalid kind', async () => {
const { agent, agentPromise } = await bootAcpAgent();
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId: 'session-A',
kind: 'other',
payload: {},
}),
).rejects.toThrowError(/Invalid or missing artifact persist kind/);
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist rejects a missing payload', async () => {
const { agent, agentPromise } = await bootAcpAgent();
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId: 'session-A',
kind: 'event',
}),
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist rejects a missing live session', async () => {
const { agent, agentPromise } = await bootAcpAgent();
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId: 'session-A',
kind: 'event',
payload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: 'session-A',
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
},
}),
).rejects.toThrowError(/Session not found for id: session-A/);
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist rejects when chat recording is unavailable', async () => {
const sessionId = 'session-A';
const innerConfig = await setupSessionMocks(sessionId);
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(undefined);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId,
kind: 'event',
payload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
},
}),
).rejects.toThrowError(/Chat recording service unavailable/);
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist records artifact events and snapshots', async () => {
const sessionId = 'session-A';
const recording = {
flush: vi.fn().mockResolvedValue(undefined),
recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined),
recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = await setupSessionMocks(sessionId);
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording);
const { agent, agentPromise } = await bootAcpAgent();
const eventPayload = {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
};
const snapshotPayload = {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 2,
recordedAt: '2026-07-04T00:00:01.000Z',
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
};
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId,
kind: 'event',
payload: eventPayload,
}),
).resolves.toEqual({ sessionId, persisted: true, kind: 'event' });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId,
kind: 'snapshot',
payload: snapshotPayload,
}),
).resolves.toEqual({ sessionId, persisted: true, kind: 'snapshot' });
expect(recording.recordSessionArtifactEvent).toHaveBeenCalledWith(
eventPayload,
);
expect(recording.recordSessionArtifactSnapshot).toHaveBeenCalledWith(
snapshotPayload,
);
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist rejects malformed event and snapshot payloads', async () => {
const sessionId = 'session-A';
const recording = {
flush: vi.fn().mockResolvedValue(undefined),
recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined),
recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = await setupSessionMocks(sessionId);
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId,
kind: 'event',
payload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 1,
changes: [],
},
}),
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId,
kind: 'snapshot',
payload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 2,
recordedAt: '2026-07-04T00:00:01.000Z',
changes: [],
},
}),
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
expect(recording.recordSessionArtifactEvent).not.toHaveBeenCalled();
expect(recording.recordSessionArtifactSnapshot).not.toHaveBeenCalled();
mockConnectionState.resolve();
await agentPromise;
});
it('sessionArtifactsPersist rejects payloads for a different session', async () => {
const sessionId = 'session-A';
const recording = {
flush: vi.fn().mockResolvedValue(undefined),
recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined),
recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = await setupSessionMocks(sessionId);
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
sessionId,
kind: 'event',
payload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: 'session-B',
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
},
}),
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
expect(recording.recordSessionArtifactEvent).not.toHaveBeenCalled();
mockConnectionState.resolve();
await agentPromise;
});
it('status ext methods expose workspace snapshots without secrets', async () => {
vi.mocked(getMCPDiscoveryState).mockReturnValue(
MCPDiscoveryState.COMPLETED,
@ -7187,6 +7444,21 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
it('rewindSession extension method rewinds the active session', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
await setupSessionMocks(sessionId);
const artifactSnapshot = {
v: 1,
sessionId,
sequence: 0,
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
};
vi.mocked(SessionService).mockImplementation(
() =>
({
loadSession: vi.fn().mockResolvedValue({ artifactSnapshot }),
}) as unknown as InstanceType<typeof SessionService>,
);
const agentPromise = runAcpAgent(
mockConfig,
@ -7218,12 +7490,101 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
apiTruncateIndex: 2,
filesChanged: [],
filesFailed: [],
artifactSnapshot,
});
mockConnectionState.resolve();
await agentPromise;
});
it('rewindSession extension method marks artifact snapshot unavailable when session reload is missing', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
await setupSessionMocks(sessionId);
vi.mocked(SessionService).mockImplementation(
() =>
({
loadSession: vi.fn().mockResolvedValue(undefined),
}) as unknown as InstanceType<typeof SessionService>,
);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const response = await agent.extMethod('rewindSession', {
sessionId,
targetTurnIndex: 1,
cwd: '/tmp',
});
expect(response).toMatchObject({
success: true,
artifactSnapshotUnavailable: 'session data unavailable after rewind',
});
expect(response).not.toHaveProperty('artifactSnapshot');
mockConnectionState.resolve();
await agentPromise;
});
it('rewindSession extension method returns an empty artifact snapshot when reload has no artifact records', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
await setupSessionMocks(sessionId);
vi.mocked(SessionService).mockImplementation(
() =>
({
loadSession: vi.fn().mockResolvedValue({ conversation: {} }),
}) as unknown as InstanceType<typeof SessionService>,
);
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const response = await agent.extMethod('rewindSession', {
sessionId,
targetTurnIndex: 1,
cwd: '/tmp',
});
expect(response).toMatchObject({
success: true,
artifactSnapshot: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 0,
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
},
});
expect(response).not.toHaveProperty('artifactSnapshotUnavailable');
mockConnectionState.resolve();
await agentPromise;
});
it('rewindSession extension method can skip file rewind', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
await setupSessionMocks(sessionId);

View file

@ -66,6 +66,9 @@ import {
matchesAnyServerPattern,
IMAGE_CAPABILITY,
registerAcpEventLoopLagGauge,
SESSION_ARTIFACT_PERSISTENCE_VERSION,
normalizeEventPayload,
normalizeSnapshotPayload,
startEventLoopLagMonitor,
} from '@qwen-code/qwen-code-core';
import { randomUUID } from 'node:crypto';
@ -86,6 +89,8 @@ import type {
DiscoveredMCPResource,
DiscoveredMCPPrompt,
WorkspaceRememberContextMode,
SessionArtifactEventRecordPayload,
SessionArtifactSnapshotRecordPayload,
ChatRecord,
HistoryGap,
} from '@qwen-code/qwen-code-core';
@ -330,6 +335,67 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseSessionArtifactEventPayload(
payload: unknown,
expectedSessionId: string,
): SessionArtifactEventRecordPayload {
const record = parseSessionArtifactBasePayload(payload, expectedSessionId);
const warnings: string[] = [];
const normalized = normalizeEventPayload(record, warnings);
if (
!normalized ||
warnings.length > 0 ||
normalized.changes.length !== (record['changes'] as unknown[]).length
) {
throw invalidArtifactPersistPayload();
}
return normalized;
}
function parseSessionArtifactSnapshotPayload(
payload: unknown,
expectedSessionId: string,
): SessionArtifactSnapshotRecordPayload {
const record = parseSessionArtifactBasePayload(payload, expectedSessionId);
const warnings: string[] = [];
const normalized = normalizeSnapshotPayload(record, warnings);
if (
!normalized ||
warnings.length > 0 ||
normalized.artifacts.length !== (record['artifacts'] as unknown[]).length
) {
throw invalidArtifactPersistPayload();
}
return normalized;
}
function parseSessionArtifactBasePayload(
payload: unknown,
expectedSessionId: string,
): Record<string, unknown> {
if (!isObjectRecord(payload)) {
throw invalidArtifactPersistPayload();
}
if (
payload['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION ||
payload['sessionId'] !== expectedSessionId ||
!Number.isSafeInteger(payload['sequence']) ||
(payload['sequence'] as number) < 0 ||
typeof payload['recordedAt'] !== 'string' ||
payload['recordedAt'].length === 0
) {
throw invalidArtifactPersistPayload();
}
return payload;
}
function invalidArtifactPersistPayload(): Error {
return RequestError.invalidParams(
undefined,
'Invalid or missing artifact persist payload',
);
}
function isBulkLoadReplayRequest(params: LoadSessionRequest): boolean {
const meta = isObjectRecord(params._meta) ? params._meta : undefined;
return meta?.[LOAD_REPLAY_MODE_META_KEY] === LOAD_REPLAY_BULK_MODE;
@ -3202,7 +3268,10 @@ class QwenAgent implements Agent {
modes: modesData,
models: availableModels,
configOptions,
};
...(sessionData?.artifactSnapshot
? { artifactSnapshot: sessionData.artifactSnapshot }
: {}),
} as LoadSessionResponse;
if (!replayEnvelope) {
return response;
}
@ -3255,11 +3324,15 @@ class QwenAgent implements Agent {
const availableModels = this.buildAvailableModels(config);
const configOptions = this.buildConfigOptions(config);
const sessionData = config.getResumedSessionData();
return {
modes: modesData,
models: availableModels,
configOptions,
};
...(sessionData?.artifactSnapshot
? { artifactSnapshot: sessionData.artifactSnapshot }
: {}),
} as ResumeSessionResponse;
}
/**
@ -6473,6 +6546,47 @@ class QwenAgent implements Agent {
AbortSignal.timeout(5 * 60_000),
)) as unknown as Record<string, unknown>;
}
case SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
const kind = params['kind'];
if (kind !== 'event' && kind !== 'snapshot') {
throw RequestError.invalidParams(
undefined,
'Invalid or missing artifact persist kind',
);
}
const payload = params['payload'];
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing artifact persist payload',
);
}
const session = this.sessionOrThrow(sessionId);
const recording = session.getConfig().getChatRecordingService();
if (!recording) {
throw RequestError.internalError(
undefined,
'Chat recording service unavailable',
);
}
if (kind === 'event') {
await recording.recordSessionArtifactEvent(
parseSessionArtifactEventPayload(payload, sessionId),
);
} else {
await recording.recordSessionArtifactSnapshot(
parseSessionArtifactSnapshotPayload(payload, sessionId),
);
}
return { sessionId, persisted: true, kind };
}
case SERVE_CONTROL_EXT_METHODS.sessionTitle: {
const sessionId = params['sessionId'];
const displayName = params['displayName'];
@ -7521,6 +7635,46 @@ class QwenAgent implements Agent {
filesFailed = [`file-history-rewind: ${reason}`];
}
}
let artifactSnapshot: unknown;
let artifactSnapshotUnavailable: string | undefined;
try {
await session.getConfig().getChatRecordingService()?.flush();
const cwd = session.getConfig().getProjectRoot();
const sessionData = await runWithAcpRuntimeOutputDir(
this.settings,
cwd,
async () => {
const sessionService = new SessionService(cwd);
return sessionService.loadSession(sessionId);
},
);
if (sessionData === undefined) {
artifactSnapshotUnavailable =
'session data unavailable after rewind';
} else if (sessionData.artifactSnapshot) {
artifactSnapshot = sessionData.artifactSnapshot;
} else {
// A successful reload with no artifact records is a valid empty
// artifact timeline, distinct from an unavailable reload.
artifactSnapshot = {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId,
sequence: 0,
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
};
}
} catch (err) {
artifactSnapshotUnavailable =
err instanceof Error ? err.message : String(err);
debugLogger.warn(
`[ACP] Failed to rebuild artifact snapshot after rewind for session=${sessionId}: ${
artifactSnapshotUnavailable
}`,
);
}
return {
success: true,
@ -7528,6 +7682,10 @@ class QwenAgent implements Agent {
...rewindResult,
filesChanged,
filesFailed,
...(artifactSnapshot ? { artifactSnapshot } : {}),
...(artifactSnapshotUnavailable
? { artifactSnapshotUnavailable }
: {}),
};
}
case 'qwen/session/loadUpdates': {

View file

@ -679,6 +679,25 @@ describe('HistoryReplayer', () => {
});
});
it('should replay structured artifacts from stored tool results', async () => {
const record = createToolResultRecord('read_file', 'File contents here');
const artifacts = [
{
title: 'Replay artifact',
url: 'https://example.com/replayed',
},
];
record.toolCallResult!.artifacts = artifacts;
await replayer.replay([record]);
expect(sentUpdates()[0]).toMatchObject({
_meta: {
artifacts,
},
});
});
it('should emit failed status for tool results with errors', async () => {
const records = [createToolResultRecord('failing_tool', undefined, true)];

View file

@ -295,6 +295,7 @@ export class HistoryReplayer {
success: !result?.error,
message: record.message.parts,
resultDisplay: result?.resultDisplay,
artifacts: result?.artifacts,
// For TodoWriteTool fallback, try to extract args from the record
// Note: args aren't stored in tool_result records by default
args: undefined,

View file

@ -46,7 +46,10 @@ import {
SessionShellDisabledError,
WorkspaceMismatchError,
} from '@qwen-code/acp-bridge/bridgeErrors';
import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
import {
SessionArtifactAuthorizationError,
SessionArtifactValidationError,
} from '@qwen-code/acp-bridge/sessionArtifacts';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js';
@ -411,6 +414,8 @@ function pickSessionArtifactInput(
mimeType,
sizeBytes,
metadata,
retention,
clientRetained,
} = params;
return {
@ -424,6 +429,8 @@ function pickSessionArtifactInput(
mimeType,
sizeBytes,
metadata,
retention,
clientRetained,
} as AddSessionArtifactInput;
}
@ -533,6 +540,17 @@ function toRpcError(err: unknown): {
data: { errorKind: 'client_id_required' },
};
}
if (err instanceof SessionArtifactAuthorizationError) {
return {
code: RPC.INVALID_REQUEST,
message: err.message,
data: {
errorKind: 'artifact_forbidden',
sessionId: err.sessionId,
artifactId: err.artifactId,
},
};
}
if (err instanceof SessionArtifactValidationError) {
return {
code: RPC.INVALID_PARAMS,
@ -2556,7 +2574,10 @@ export class AcpDispatcher {
case `${QWEN_METHOD_NS}session/artifacts`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
const result = await this.bridge.getSessionArtifacts(sessionId);
const result = await this.bridge.getSessionArtifacts(
sessionId,
this.sessionCtx(conn, sessionId, loopback),
);
this.replyConn(conn, id, result as unknown);
return;
}

View file

@ -20,7 +20,10 @@ import type {
BridgeEvent,
SessionReplaySnapshot,
} from '@qwen-code/acp-bridge/eventBus';
import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
import {
SessionArtifactAuthorizationError,
SessionArtifactValidationError,
} from '@qwen-code/acp-bridge/sessionArtifacts';
import {
CancelSentinelCollisionError,
InvalidClientIdError,
@ -398,6 +401,9 @@ class FakeBridge {
}
| undefined;
lastArtifactListSessionId: string | undefined;
lastArtifactListContext:
| Parameters<HttpAcpBridge['getSessionArtifacts']>[1]
| undefined;
lastRemovedArtifact:
| {
sessionId: string;
@ -405,8 +411,12 @@ class FakeBridge {
context: Parameters<HttpAcpBridge['removeSessionArtifact']>[2];
}
| undefined;
async getSessionArtifacts(sessionId: string) {
async getSessionArtifacts(
sessionId: string,
context: Parameters<HttpAcpBridge['getSessionArtifacts']>[1],
) {
this.lastArtifactListSessionId = sessionId;
this.lastArtifactListContext = context;
return {
v: 1,
sessionId,
@ -6039,6 +6049,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
},
});
expect(bridge.lastArtifactListSessionId).toBe('sess-1');
expect(bridge.lastArtifactListContext).toEqual({
clientId: 'client-1',
fromLoopback: true,
});
});
it('_qwen/session/artifacts/add forwards only public artifact fields', async () => {
@ -6063,6 +6077,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
storage: 'external_url',
url: 'https://example.test/lineage',
metadata: { table: 'fact_orders' },
retention: 'ephemeral',
clientRetained: false,
source: 'tool',
trustedPublisher: true,
clientId: 'forged-client',
@ -6081,6 +6097,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
storage: 'external_url',
url: 'https://example.test/lineage',
metadata: { table: 'fact_orders' },
retention: 'ephemeral',
clientRetained: false,
});
const artifact = bridge.lastAddedArtifact?.artifact as
| Record<string, unknown>
@ -6091,6 +6109,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(artifact).not.toHaveProperty('clientId');
expect(artifact).not.toHaveProperty('toolName');
expect(artifact).not.toHaveProperty('hookEventName');
expect(bridge.lastAddedArtifact?.context).toEqual({
clientId: 'client-1',
fromLoopback: true,
});
});
it('_qwen/session/artifacts/add maps artifact validation errors to invalid params', async () => {
@ -6144,7 +6166,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
jsonrpc: '2.0',
id: 59,
method: '_qwen/session/artifacts/remove',
params: { sessionId: 'sess-1', artifactId: 'artifact-1' },
params: {
sessionId: 'sess-1',
artifactId: 'artifact-1',
},
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
@ -6163,6 +6188,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(bridge.lastRemovedArtifact).toMatchObject({
sessionId: 'sess-1',
artifactId: 'artifact-1',
context: {
clientId: 'client-1',
fromLoopback: true,
},
});
});
@ -6193,6 +6222,46 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(bridge.lastRemovedArtifact).toBeUndefined();
});
it('_qwen/session/artifacts/remove maps artifact authorization errors', async () => {
bridge.removeSessionArtifact = async () => {
throw new SessionArtifactAuthorizationError(
'sess-1',
'artifact-1',
'client-owner',
'client-other',
);
};
const connId = await initialize();
const streamRes = openStream(connId);
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 99,
method: 'session/new',
params: {},
});
await new Promise((r) => setTimeout(r, 30));
await post(connId, {
jsonrpc: '2.0',
id: 61,
method: '_qwen/session/artifacts/remove',
params: { sessionId: 'sess-1', artifactId: 'artifact-1' },
});
const frames = await takeFrames(await streamRes, 2);
expect(frames[1]).toMatchObject({
error: {
code: -32600,
message: 'artifact artifact-1 is owned by a different client',
data: {
errorKind: 'artifact_forbidden',
sessionId: 'sess-1',
artifactId: 'artifact-1',
},
},
});
});
it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440131';
@ -6275,7 +6344,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
artifactId,
context,
) => {
bridge.lastRemovedArtifact = { sessionId, artifactId, context };
bridge.lastRemovedArtifact = {
sessionId,
artifactId,
context,
};
removeStarted();
await removeReleasedPromise;
return {

View file

@ -130,4 +130,7 @@ export {
canonicalizeWorkspace,
} from '@qwen-code/acp-bridge/workspacePaths';
export { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
export {
SessionArtifactAuthorizationError,
SessionArtifactValidationError,
} from '@qwen-code/acp-bridge/sessionArtifacts';

View file

@ -45,6 +45,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
session_cancel: { since: 'v1' },
session_events: { since: 'v1' },
session_artifacts: { since: 'v1' },
session_artifacts_persistence: { since: 'v1' },
// Daemon emits `slow_client_warning` synthetic frames at 75% queue
// fill and honors `?maxQueued=N` (range [16, 2048]) on
// `GET /session/:id/events`. Old daemons silently lack both — SDK
@ -311,6 +312,7 @@ export interface AdvertiseFeatureToggles {
persistSettingAvailable?: boolean;
voiceTranscriptionAvailable?: boolean;
sessionShellCommandEnabled?: boolean;
sessionArtifactsPersistenceAvailable?: boolean;
rateLimit?: boolean;
reloadAvailable?: boolean;
/**
@ -394,6 +396,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
'session_shell_command',
(toggles) => toggles.sessionShellCommandEnabled === true,
],
[
'session_artifacts_persistence',
(toggles) => toggles.sessionArtifactsPersistenceAvailable === true,
],
['rate_limit', (toggles) => toggles.rateLimit === true],
['workspace_reload', (toggles) => toggles.reloadAvailable === true],
[

View file

@ -39,6 +39,7 @@ export type ServeFastPathSettings = Pick<
Settings,
'advanced' | 'context' | 'env' | 'security' | 'tools'
> & {
general?: Pick<NonNullable<Settings['general']>, 'chatRecording'>;
policy?: ServeFastPathPolicyInput;
};
const V2_SETTINGS_VERSION = 2;
@ -439,6 +440,19 @@ function pickFastPathSettings(
out.env = pickStringRecord(env);
}
const general = value['general'];
if (isPlainObject(general)) {
const chatRecording = general['chatRecording'];
if (chatRecording !== undefined && typeof chatRecording !== 'boolean') {
throw new Error(
'Serve fast path settings general.chatRecording must be a boolean.',
);
}
if (chatRecording !== undefined) {
out.general = { chatRecording };
}
}
const advanced = value['advanced'];
if (isPlainObject(advanced)) {
const pickedAdvanced: NonNullable<ServeFastPathSettings['advanced']> = {};
@ -621,6 +635,9 @@ function mergeFastPathSettings(
if (source.env) {
merged.env = { ...(merged.env ?? {}), ...source.env };
}
if (source.general) {
merged.general = { ...(merged.general ?? {}), ...source.general };
}
if (source.advanced?.excludedEnvVars) {
merged.advanced = {
...(merged.advanced ?? {}),

View file

@ -213,6 +213,9 @@ function pickServeFastPathComparable(
if (settings.env) {
out.env = settings.env;
}
if (settings.general?.chatRecording !== undefined) {
out.general = { chatRecording: settings.general.chatRecording };
}
if (settings.advanced?.excludedEnvVars !== undefined) {
out.advanced = {
...(out.advanced ?? {}),
@ -1362,6 +1365,7 @@ describe('serve fast path environment bootstrap', () => {
FAST_PATH_OVERLAP: 'workspace',
},
advanced: { runtimeOutputDir: '.workspace-runtime' },
general: { chatRecording: false },
context: {
fileName: 'WORKSPACE.md',
fileFiltering: { customIgnoreFiles: ['.workspace-ignore'] },

View file

@ -81,6 +81,19 @@ interface RegisterSessionRoutesDeps {
languageCodes: string[];
}
function requireSessionArtifactClientId(
clientId: string | undefined,
res: Response,
): clientId is string {
if (clientId !== undefined) return true;
res.status(403).json({
error: 'Session artifact access requires a session-bound client id',
code: 'client_id_required',
errorKind: 'client_id_required',
});
return false;
}
function sendArtifactValidationError(res: Response, err: unknown): boolean {
if (!(err instanceof SessionArtifactValidationError)) {
return false;
@ -1042,10 +1055,17 @@ export function registerSessionRoutes(
'/session/:id/artifacts',
withOwnerReadSession(
'GET /session/:id/artifacts',
async (_req, res, sessionId, runtime) => {
async (req, res, sessionId, runtime) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
res
.status(200)
.json(await runtime.bridge.getSessionArtifacts(sessionId));
.json(
await runtime.bridge.getSessionArtifacts(
sessionId,
clientId !== undefined ? { clientId } : undefined,
),
);
},
),
);
@ -1058,6 +1078,7 @@ export function registerSessionRoutes(
async (req, res, sessionId) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
if (!requireSessionArtifactClientId(clientId, res)) return;
try {
const body = safeBody(req);
const artifact: SessionArtifactInput = {
@ -1075,12 +1096,14 @@ export function registerSessionRoutes(
mimeType: body['mimeType'] as SessionArtifactInput['mimeType'],
sizeBytes: body['sizeBytes'] as SessionArtifactInput['sizeBytes'],
metadata: body['metadata'] as SessionArtifactInput['metadata'],
retention: body['retention'] as SessionArtifactInput['retention'],
clientRetained: body[
'clientRetained'
] as SessionArtifactInput['clientRetained'],
};
const result = await bridge.addSessionArtifact(
sessionId,
artifact,
clientId !== undefined ? { clientId } : undefined,
);
const result = await bridge.addSessionArtifact(sessionId, artifact, {
clientId,
});
res.status(200).json(result);
} catch (err) {
if (sendArtifactValidationError(res, err)) return;
@ -1102,6 +1125,7 @@ export function registerSessionRoutes(
const artifactId = req.params['artifactId'];
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
if (!requireSessionArtifactClientId(clientId, res)) return;
if (!artifactId) {
res.status(400).json({
v: 1,
@ -1117,10 +1141,11 @@ export function registerSessionRoutes(
const result = await bridge.removeSessionArtifact(
sessionId,
artifactId,
clientId !== undefined ? { clientId } : undefined,
{ clientId },
);
res.status(200).json(result);
} catch (err) {
if (sendArtifactValidationError(res, err)) return;
sendBridgeError(res, err, {
route: 'DELETE /session/:id/artifacts/:artifactId',
sessionId,

View file

@ -461,6 +461,12 @@ export function extractContextFilename(value: unknown): string | undefined {
return undefined;
}
function sessionArtifactsPersistenceAvailableFromSettings(
settings: { general?: { chatRecording?: unknown } } | undefined,
): boolean {
return settings?.general?.chatRecording !== false;
}
/**
* Per-workspace promise chain that serializes settings read-modify-write
* cycles inside this process.
@ -837,6 +843,7 @@ function sessionIdleTimeoutMs(value: number | undefined): number {
function currentServeFeaturesForRunQwenServe(
opts: ServeOptions,
sessionShellCommandEnabled: boolean,
sessionArtifactsPersistenceAvailable: boolean,
): string[] {
return getAdvertisedServeFeatures(undefined, {
requireAuth: opts.requireAuth === true,
@ -851,6 +858,7 @@ function currentServeFeaturesForRunQwenServe(
: {}),
persistSettingAvailable: true,
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
rateLimit: opts.rateLimit === true,
reloadAvailable: true,
// Advertise the same WS feature flags as the runtime path (serve-features.ts)
@ -869,6 +877,7 @@ function createBootstrapCapabilities(input: {
boundWorkspace: string;
qwenCodeVersion?: string;
sessionShellCommandEnabled: boolean;
sessionArtifactsPersistenceAvailable: boolean;
permissionPolicy: PermissionPolicy | undefined;
}): CapabilitiesEnvelope {
return {
@ -881,6 +890,7 @@ function createBootstrapCapabilities(input: {
features: currentServeFeaturesForRunQwenServe(
input.opts,
input.sessionShellCommandEnabled,
input.sessionArtifactsPersistenceAvailable,
),
modelServices: [],
workspaceCwd: input.boundWorkspace,
@ -1056,6 +1066,7 @@ function createBootstrapServeApp(input: {
daemonLog: DaemonLogger;
qwenCodeVersion?: string;
sessionShellCommandEnabled: boolean;
sessionArtifactsPersistenceAvailable: boolean;
permissionPolicy: PermissionPolicy | undefined;
multiWorkspaceCapabilitiesRequireRuntime: boolean;
getRuntimeError: () => string | undefined;
@ -1072,6 +1083,7 @@ function createBootstrapServeApp(input: {
daemonLog,
qwenCodeVersion,
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
permissionPolicy,
multiWorkspaceCapabilitiesRequireRuntime,
getRuntimeError,
@ -1137,6 +1149,7 @@ function createBootstrapServeApp(input: {
boundWorkspace,
qwenCodeVersion,
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
permissionPolicy,
}),
);
@ -1218,6 +1231,7 @@ function createBootstrapServeApp(input: {
features: currentServeFeaturesForRunQwenServe(
opts,
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
),
},
runtime: {
@ -1805,9 +1819,12 @@ export async function runQwenServe(
let permissionPolicy: PermissionPolicy | undefined;
let permissionConsensusQuorum: number | undefined;
let bootSettings: ServeFastPathSettings | undefined;
let sessionArtifactsPersistenceAvailable = true;
try {
bootSettings =
deps.bootSettings ?? loadServeFastPathSettings(boundWorkspace);
sessionArtifactsPersistenceAvailable =
sessionArtifactsPersistenceAvailableFromSettings(bootSettings);
contextFilenameForInit = extractContextFilename(
bootSettings.context?.fileName,
);
@ -3184,6 +3201,10 @@ export async function runQwenServe(
persistDisabledTools: persistDisabledToolsFn,
persistSetting: persistSettingFn,
persistSettings: persistSettingsFn,
sessionArtifactsPersistenceAvailable:
sessionArtifactsPersistenceAvailableFromSettings(
runtimeBootSettings?.merged,
),
installAuthProvider: (req) =>
withSettingsLock(
boundWorkspace,
@ -3261,6 +3282,7 @@ export async function runQwenServe(
daemonLog,
qwenCodeVersion: cliVersion,
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
permissionPolicy,
multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1,
getRuntimeError: () => runtimeStartupError,

View file

@ -66,6 +66,7 @@ import {
PermissionPolicyNotImplementedError,
PromptQueueFullError,
RestoreInProgressError,
SessionArtifactAuthorizationError,
SessionArtifactValidationError,
SessionShellClientRequiredError,
SessionShellDisabledError,
@ -326,7 +327,11 @@ const EXPECTED_REGISTERED_FEATURES = [
// All four conditional tags filtered from the stage1 baseline so
// they appear here in their registry-declaration order, not the
// stage1 order.
...EXPECTED_STAGE1_FEATURES.filter(
...EXPECTED_STAGE1_FEATURES.flatMap((feature) =>
feature === 'session_artifacts'
? [feature, 'session_artifacts_persistence']
: [feature],
).filter(
(f) =>
f !== 'workspace_init' &&
f !== 'workspace_github_setup' &&
@ -665,7 +670,10 @@ interface FakeBridge extends AcpSessionBridge {
}>;
listCalls: string[];
summaryCalls: string[];
sessionArtifactsCalls: string[];
sessionArtifactsCalls: Array<{
sessionId: string;
context?: BridgeClientRequestContext;
}>;
addSessionArtifactCalls: Array<{
sessionId: string;
artifact: Parameters<AcpSessionBridge['addSessionArtifact']>[1];
@ -820,7 +828,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = [];
const listCalls: string[] = [];
const summaryCalls: string[] = [];
const sessionArtifactsCalls: string[] = [];
const sessionArtifactsCalls: FakeBridge['sessionArtifactsCalls'] = [];
const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = [];
const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] =
[];
@ -947,6 +955,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
...(artifact.workspacePath
? { workspacePath: artifact.workspacePath }
: {}),
retention: artifact.retention ?? 'ephemeral',
clientRetained: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@ -1478,9 +1487,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
summaryCalls.push(sessionId);
return summaryImpl(sessionId);
},
async getSessionArtifacts(sessionId) {
sessionArtifactsCalls.push(sessionId);
return getSessionArtifactsImpl(sessionId);
async getSessionArtifacts(sessionId, context) {
sessionArtifactsCalls.push({
sessionId,
...(context ? { context } : {}),
});
return getSessionArtifactsImpl(sessionId, context);
},
async addSessionArtifact(sessionId, artifact, context) {
addSessionArtifactCalls.push({
@ -2088,6 +2100,24 @@ describe('createServeApp', () => {
);
continue;
}
if (feature === 'session_artifacts_persistence') {
expect(
predicate({ sessionArtifactsPersistenceAvailable: true }),
).toBe(true);
expect(
predicate({ sessionArtifactsPersistenceAvailable: false }),
).toBe(false);
expect(predicate({})).toBe(false);
expect(
getAdvertisedServeFeatures(undefined, {
sessionArtifactsPersistenceAvailable: true,
}),
).toContain(feature);
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
feature,
);
continue;
}
if (feature === 'workspace_reload') {
expect(predicate({ reloadAvailable: true })).toBe(true);
expect(predicate({ reloadAvailable: false })).toBe(false);
@ -2521,6 +2551,7 @@ describe('createServeApp', () => {
expect(res.body.features).toEqual(
getAdvertisedServeFeatures(undefined, {
mcpPoolActive: true,
sessionArtifactsPersistenceAvailable: true,
}),
);
expect(res.body.modelServices).toEqual([]);
@ -2529,6 +2560,17 @@ describe('createServeApp', () => {
});
});
it('omits artifact persistence when the durable sink is unavailable', async () => {
const app = createServeApp(baseOpts, undefined, {
sessionArtifactsPersistenceAvailable: false,
});
const res = await request(app)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.body.features).not.toContain('session_artifacts_persistence');
});
it('advertises workspace voice transcription when a batch ASR model is configured', async () => {
const previousQwenHome = process.env['QWEN_HOME'];
const tempHome = await fsp.mkdtemp(
@ -8329,7 +8371,9 @@ describe('createServeApp', () => {
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).get('/session/session-A/artifacts'));
const res = await auth(
request(app).get('/session/session-A/artifacts'),
).set('X-Qwen-Client-Id', 'client-1');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
@ -8337,7 +8381,26 @@ describe('createServeApp', () => {
sessionId: 'session-A',
artifacts: [{ id: 'artifact-1', title: 'Lineage' }],
});
expect(bridge.sessionArtifactsCalls).toEqual(['session-A']);
expect(bridge.sessionArtifactsCalls).toEqual([
{ sessionId: 'session-A', context: { clientId: 'client-1' } },
]);
});
it('GET /session/:id/artifacts allows missing client id', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).get('/session/session-A/artifacts'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
v: 1,
sessionId: 'session-A',
artifacts: [],
});
expect(bridge.sessionArtifactsCalls).toEqual([
{ sessionId: 'session-A' },
]);
});
it('GET /session/:id/artifacts returns 404 for an unknown session', async () => {
@ -8348,11 +8411,16 @@ describe('createServeApp', () => {
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).get('/session/ghost/artifacts'));
const res = await auth(request(app).get('/session/ghost/artifacts')).set(
'X-Qwen-Client-Id',
'client-1',
);
expect(res.status).toBe(404);
expect(res.body.sessionId).toBe('ghost');
expect(bridge.sessionArtifactsCalls).toEqual(['ghost']);
expect(bridge.sessionArtifactsCalls).toEqual([
{ sessionId: 'ghost', context: { clientId: 'client-1' } },
]);
});
it('POST /session/:id/artifacts requires strict mutation auth', async () => {
@ -8369,13 +8437,31 @@ describe('createServeApp', () => {
expect(bridge.addSessionArtifactCalls).toHaveLength(0);
});
it('POST /session/:id/artifacts requires a client id', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).post('/session/session-A/artifacts'),
).send({ title: 'Lineage', url: 'https://example.com/lineage' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('client_id_required');
expect(bridge.addSessionArtifactCalls).toEqual([]);
});
it('POST /session/:id/artifacts forwards body and client context', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(request(app).post('/session/session-A/artifacts'))
.set('X-Qwen-Client-Id', 'client-1')
.send({ title: 'Lineage', url: 'https://example.com/lineage' });
.send({
title: 'Lineage',
url: 'https://example.com/lineage',
retention: 'ephemeral',
clientRetained: false,
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
@ -8394,6 +8480,8 @@ describe('createServeApp', () => {
artifact: {
title: 'Lineage',
url: 'https://example.com/lineage',
retention: 'ephemeral',
clientRetained: false,
},
context: { clientId: 'client-1' },
},
@ -8411,9 +8499,9 @@ describe('createServeApp', () => {
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).post('/session/session-A/artifacts'),
).send({ title: 'Bad link', url: 'javascript:alert(1)' });
const res = await auth(request(app).post('/session/session-A/artifacts'))
.set('X-Qwen-Client-Id', 'client-1')
.send({ title: 'Bad link', url: 'javascript:alert(1)' });
expect(res.status).toBe(400);
expect(res.body).toEqual({
@ -8439,6 +8527,19 @@ describe('createServeApp', () => {
expect(bridge.removeSessionArtifactCalls).toHaveLength(0);
});
it('DELETE /session/:id/artifacts/:artifactId requires a client id', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).delete('/session/session-A/artifacts/artifact-1'),
);
expect(res.status).toBe(403);
expect(res.body.code).toBe('client_id_required');
expect(bridge.removeSessionArtifactCalls).toEqual([]);
});
it('DELETE /session/:id/artifacts/:artifactId is idempotent for missing artifacts', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
@ -8461,6 +8562,50 @@ describe('createServeApp', () => {
},
]);
});
it('DELETE /session/:id/artifacts/:artifactId forwards client context', async () => {
const bridge = fakeBridge();
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).delete('/session/session-A/artifacts/artifact-1'),
).set('X-Qwen-Client-Id', 'client-1');
expect(res.status).toBe(200);
expect(bridge.removeSessionArtifactCalls).toEqual([
{
sessionId: 'session-A',
artifactId: 'artifact-1',
context: { clientId: 'client-1' },
},
]);
});
it('DELETE /session/:id/artifacts/:artifactId maps artifact authorization errors', async () => {
const bridge = fakeBridge({
removeSessionArtifactImpl: async () => {
throw new SessionArtifactAuthorizationError(
'session-A',
'artifact-1',
'client-a',
'client-b',
);
},
});
const app = createServeApp(tokenOpts, undefined, { bridge });
const res = await auth(
request(app).delete('/session/session-A/artifacts/artifact-1'),
).set('X-Qwen-Client-Id', 'client-b');
expect(res.status).toBe(403);
expect(res.body).toEqual({
error: 'artifact artifact-1 is owned by a different client',
code: 'session_artifact_forbidden',
sessionId: 'session-A',
artifactId: 'artifact-1',
});
});
});
describe('POST /session/:id/model', () => {

View file

@ -287,6 +287,7 @@ export interface ServeAppDeps {
value: unknown;
}>,
) => Promise<void>;
sessionArtifactsPersistenceAvailable?: boolean;
/**
* Reverse tool channel (issue #5626, Phase 2). Shared sender registry that
* bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP
@ -499,6 +500,8 @@ export function createServeApp(
opts,
boundWorkspace,
persistSettingAvailable: deps.persistSetting !== undefined,
sessionArtifactsPersistenceAvailable:
deps.sessionArtifactsPersistenceAvailable !== false,
// Registry injection supplies the primary workspace service through the
// runtime, so it has the same reload surface as legacy deps.workspace.
reloadAvailable:

View file

@ -27,6 +27,7 @@ import {
PermissionPolicyNotImplementedError,
PromptQueueFullError,
RestoreInProgressError,
SessionArtifactAuthorizationError,
SessionArchivedError,
SessionArchivingError,
SessionBusyError,
@ -279,6 +280,15 @@ export function sendBridgeError(
});
return;
}
if (err instanceof SessionArtifactAuthorizationError) {
res.status(403).json({
error: err.message,
code: 'session_artifact_forbidden',
sessionId: err.sessionId,
artifactId: err.artifactId,
});
return;
}
if (err instanceof SessionShellDisabledError) {
res.status(403).json({
error: err.message,

View file

@ -41,6 +41,7 @@ interface CreateServeFeaturesDeps {
opts: ServeOptions;
boundWorkspace: string;
persistSettingAvailable: boolean;
sessionArtifactsPersistenceAvailable: boolean;
reloadAvailable: boolean;
sessionShellCommandEnabled: boolean;
multiWorkspaceSessionsEnabled: boolean;
@ -59,6 +60,7 @@ export function createServeFeatures(
opts,
boundWorkspace,
persistSettingAvailable,
sessionArtifactsPersistenceAvailable,
reloadAvailable,
sessionShellCommandEnabled,
multiWorkspaceSessionsEnabled,
@ -90,6 +92,7 @@ export function createServeFeatures(
: {}),
persistSettingAvailable,
sessionShellCommandEnabled,
sessionArtifactsPersistenceAvailable,
rateLimit: opts.rateLimit === true,
reloadAvailable,
multiWorkspaceSessionsEnabled,

View file

@ -228,6 +228,7 @@ export * from './services/visionBridge/vision-bridge-service.js';
export * from './services/visionBridge/image-part-utils.js';
export * from './services/visionBridge/image-capability.js';
export * from './services/sessionRecap.js';
export * from './services/session-artifact-persistence.js';
export * from './services/sessionService.js';
export * from './utils/conversation-chain.js';
export * from './services/sessionTitle.js';

View file

@ -1113,6 +1113,94 @@ describe('ChatRecordingService', () => {
}
});
it('does not rewind the shared parent pointer when strict artifact writes race with normal appends', async () => {
let rejectStrict!: (error: Error) => void;
const strictWrite = new Promise<void>((_resolve, reject) => {
rejectStrict = reject;
});
vi.mocked(jsonl.writeLine)
.mockImplementationOnce(() => strictWrite)
.mockResolvedValue(undefined);
const strict = chatRecordingService.recordSessionArtifactEvent({
v: 2,
sessionId: 'test-session-id',
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
});
chatRecordingService.recordUserMessage([{ text: 'after strict write' }]);
rejectStrict(new Error('disk full'));
await expect(strict).rejects.toThrow('disk full');
await chatRecordingService.flush();
chatRecordingService.recordUserMessage([{ text: 'next message' }]);
await chatRecordingService.flush();
const first = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
const second = vi.mocked(jsonl.writeLine).mock.calls[1][1] as ChatRecord;
const third = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord;
expect(second.parentUuid).not.toBe(first.uuid);
expect(third.parentUuid).toBe(second.uuid);
});
it('appends strict artifact records after a previous strict write failed', async () => {
vi.mocked(jsonl.writeLine)
.mockRejectedValueOnce(new Error('corrupt journal'))
.mockResolvedValue(undefined);
await expect(
chatRecordingService.recordSessionArtifactEvent({
v: 2,
sessionId: 'test-session-id',
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
}),
).rejects.toThrow('corrupt journal');
await expect(
chatRecordingService.recordSessionArtifactSnapshot({
v: 2,
sessionId: 'test-session-id',
sequence: 2,
recordedAt: '2026-07-04T00:00:01.000Z',
artifacts: [],
tombstonedIds: [],
stickyEphemeralIds: [],
}),
).resolves.toBeUndefined();
expect(jsonl.writeLine).toHaveBeenCalledTimes(2);
const snapshotRecord = vi.mocked(jsonl.writeLine).mock
.calls[1][1] as ChatRecord;
expect(snapshotRecord.subtype).toBe('session_artifact_snapshot');
});
it('keeps artifact journal records out of the active conversation chain', async () => {
chatRecordingService.recordUserMessage([{ text: 'before artifact' }]);
await chatRecordingService.flush();
await chatRecordingService.recordSessionArtifactEvent({
v: 2,
sessionId: 'test-session-id',
sequence: 1,
recordedAt: '2026-07-04T00:00:00.000Z',
changes: [],
});
chatRecordingService.recordUserMessage([{ text: 'after artifact' }]);
await chatRecordingService.flush();
const before = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
const artifact = vi.mocked(jsonl.writeLine).mock
.calls[1][1] as ChatRecord;
const after = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord;
expect(artifact.parentUuid).toBe(before.uuid);
expect(after.parentUuid).toBe(before.uuid);
expect(after.parentUuid).not.toBe(artifact.uuid);
});
// appendRecord can throw SYNCHRONOUSLY before returning a promise
// (e.g. ensureConversationFile fails because the conversation
// file can't be created). Without rollback in the outer catch,

View file

@ -34,6 +34,10 @@ import type {
SerializedFileHistorySnapshot,
} from './fileHistoryService.js';
import { serializeSnapshot } from './fileHistoryService.js';
import type {
SessionArtifactEventRecordPayload,
SessionArtifactSnapshotRecordPayload,
} from './session-artifact-persistence.js';
const debugLogger = createDebugLogger('CHAT_RECORDING');
@ -251,7 +255,9 @@ export interface ChatRecord {
| 'rewind'
| 'agent_bootstrap'
| 'agent_launch_prompt'
| 'file_history_snapshot';
| 'file_history_snapshot'
| 'session_artifact_event'
| 'session_artifact_snapshot';
/** Working directory at time of message */
cwd: string;
/** CLI version for compatibility tracking */
@ -298,7 +304,9 @@ export interface ChatRecord {
| NotificationRecordPayload
| RewindRecordPayload
| AgentBootstrapRecordPayload
| FileHistorySnapshotRecordPayload;
| FileHistorySnapshotRecordPayload
| SessionArtifactEventRecordPayload
| SessionArtifactSnapshotRecordPayload;
/** Background subagent that produced this record (e.g. "explore-7f3c"). */
agentId?: string;
@ -769,6 +777,39 @@ export class ChatRecordingService {
this.updateTitleAnchorTracking(record);
}
private async appendRecordStrict(
record: ChatRecord,
options?: { updateActiveTail?: boolean },
): Promise<void> {
const previousLastRecordUuid = this.lastRecordUuid;
const updateActiveTail = options?.updateActiveTail !== false;
let conversationFile: string;
try {
conversationFile = this.ensureConversationFile();
} catch (error) {
debugLogger.error('Error appending record:', error);
throw error;
}
if (updateActiveTail) {
this.lastRecordUuid = record.uuid;
}
this.writeChain = this.writeChain
.catch(() => {})
.then(() => jsonl.writeLine(conversationFile, record));
try {
await this.writeChain;
this.updateTitleAnchorTracking(record);
} catch (error) {
if (updateActiveTail && this.lastRecordUuid === record.uuid) {
this.lastRecordUuid = previousLastRecordUuid;
}
debugLogger.error('Error appending record (async):', error);
throw error;
}
}
/**
* Maintain the "title is always in the tail window" invariant by
* counting bytes appended since the last `custom_title` record and
@ -1477,4 +1518,28 @@ export class ChatRecordingService {
debugLogger.error('Error saving file history snapshot batch:', error);
}
}
async recordSessionArtifactEvent(
payload: SessionArtifactEventRecordPayload,
): Promise<void> {
const record: ChatRecord = {
...this.createBaseRecord('system'),
type: 'system',
subtype: 'session_artifact_event',
systemPayload: payload,
};
await this.appendRecordStrict(record, { updateActiveTail: false });
}
async recordSessionArtifactSnapshot(
payload: SessionArtifactSnapshotRecordPayload,
): Promise<void> {
const record: ChatRecord = {
...this.createBaseRecord('system'),
type: 'system',
subtype: 'session_artifact_snapshot',
systemPayload: payload,
};
await this.appendRecordStrict(record, { updateActiveTail: false });
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,10 @@ import {
getResumeTokenCounts,
type ConversationRecord,
} from './sessionService.js';
import {
SESSION_ARTIFACT_PERSISTENCE_VERSION,
stableSessionArtifactId,
} from './session-artifact-persistence.js';
import { SessionOrganizationService } from './session-organization-service.js';
import { CompressionStatus } from '../core/turn.js';
import type { ChatRecord } from './chatRecordingService.js';
@ -468,6 +472,354 @@ describe('SessionService', () => {
expect(loaded?.lastCompletedUuid).toBe('b2');
});
it('loads artifact side records attached to the active branch', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
const artifactId = stableSessionArtifactId(
sessionIdB,
'url:https://example.com/report',
);
const artifactRecord: ChatRecord = {
...recordB1,
uuid: 'artifact-1',
parentUuid: 'b1',
type: 'system',
subtype: 'session_artifact_event',
message: undefined,
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: sessionIdB,
sequence: 1,
recordedAt: '2026-07-06T00:00:00.000Z',
changes: [
{
action: 'created',
artifactId,
artifact: {
id: artifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Report',
url: 'https://example.com/report',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-06T00:00:00.000Z',
updatedAt: '2026-07-06T00:00:00.000Z',
persistedAt: '2026-07-06T00:00:00.000Z',
},
},
],
},
};
vi.mocked(jsonl.read).mockResolvedValue([
recordB1,
artifactRecord,
recordB2,
]);
const loaded = await sessionService.loadSession(sessionIdB);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['b1', 'b2']);
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
expect.objectContaining({
id: artifactId,
title: 'Report',
}),
]);
});
it('loads artifact side records after a tail-neutral title reanchor', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
const artifactId = stableSessionArtifactId(
sessionIdB,
'url:https://example.com/reanchored-report',
);
const titleRecord: ChatRecord = {
...recordB1,
uuid: 'title-reanchor',
parentUuid: 'b1',
type: 'system',
subtype: 'custom_title',
message: undefined,
systemPayload: {
customTitle: 'Reanchored title',
titleSource: 'auto',
},
};
const artifactRecord: ChatRecord = {
...recordB1,
uuid: 'artifact-after-title',
parentUuid: 'b1',
type: 'system',
subtype: 'session_artifact_event',
message: undefined,
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: sessionIdB,
sequence: 1,
recordedAt: '2026-07-06T00:00:00.000Z',
changes: [
{
action: 'created',
artifactId,
artifact: {
id: artifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Reanchored report',
url: 'https://example.com/reanchored-report',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-06T00:00:00.000Z',
updatedAt: '2026-07-06T00:00:00.000Z',
persistedAt: '2026-07-06T00:00:00.000Z',
},
},
],
},
};
vi.mocked(jsonl.read).mockResolvedValue([
recordB1,
titleRecord,
artifactRecord,
recordB2,
]);
const loaded = await sessionService.loadSession(sessionIdB);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['b1', 'b2']);
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
expect.objectContaining({
id: artifactId,
title: 'Reanchored report',
}),
]);
});
it('loads chained artifact side records attached to the active branch', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
const artifactId = stableSessionArtifactId(
sessionIdB,
'url:https://example.com/chained-report',
);
const createRecord: ChatRecord = {
...recordB1,
uuid: 'artifact-create',
parentUuid: 'b1',
type: 'system',
subtype: 'session_artifact_event',
message: undefined,
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: sessionIdB,
sequence: 1,
recordedAt: '2026-07-06T00:00:00.000Z',
changes: [
{
action: 'created',
artifactId,
artifact: {
id: artifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Chained report',
url: 'https://example.com/chained-report',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-06T00:00:00.000Z',
updatedAt: '2026-07-06T00:00:00.000Z',
persistedAt: '2026-07-06T00:00:00.000Z',
},
},
],
},
};
const removeRecord: ChatRecord = {
...recordB1,
uuid: 'artifact-remove',
parentUuid: 'artifact-create',
type: 'system',
subtype: 'session_artifact_event',
message: undefined,
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: sessionIdB,
sequence: 2,
recordedAt: '2026-07-06T00:00:01.000Z',
changes: [
{
action: 'removed',
artifactId,
reason: 'explicit',
},
],
},
};
vi.mocked(jsonl.read).mockResolvedValue([
recordB1,
createRecord,
removeRecord,
recordB2,
]);
const loaded = await sessionService.loadSession(sessionIdB);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['b1', 'b2']);
expect(loaded?.artifactSnapshot?.artifacts).toEqual([]);
expect(loaded?.artifactSnapshot?.tombstonedIds).toContain(artifactId);
});
it('does not load artifact side records from abandoned branches', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
const artifactId = stableSessionArtifactId(
sessionIdB,
'url:https://example.com/abandoned-report',
);
const artifactRecord: ChatRecord = {
...recordB1,
uuid: 'artifact-abandoned',
parentUuid: 'b1',
type: 'system',
subtype: 'session_artifact_event',
message: undefined,
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: sessionIdB,
sequence: 1,
recordedAt: '2026-07-06T00:00:00.000Z',
changes: [
{
action: 'created',
artifactId,
artifact: {
id: artifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Abandoned report',
url: 'https://example.com/abandoned-report',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-06T00:00:00.000Z',
updatedAt: '2026-07-06T00:00:00.000Z',
persistedAt: '2026-07-06T00:00:00.000Z',
},
},
],
},
};
const abandonedChild: ChatRecord = {
...recordB2,
uuid: 'abandoned-child',
parentUuid: 'b1',
};
vi.mocked(jsonl.read).mockResolvedValue([
recordB1,
artifactRecord,
abandonedChild,
recordB2,
]);
const loaded = await sessionService.loadSession(sessionIdB);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['b1', 'b2']);
expect(loaded?.artifactSnapshot).toBeUndefined();
});
it('does not treat trailing artifact side records as the conversation leaf', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
const artifactId = stableSessionArtifactId(
sessionIdB,
'url:https://example.com/trailing-report',
);
const artifactRecord: ChatRecord = {
...recordB2,
uuid: 'artifact-tail',
parentUuid: 'b2',
type: 'system',
subtype: 'session_artifact_event',
message: undefined,
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: sessionIdB,
sequence: 1,
recordedAt: '2026-07-06T00:00:00.000Z',
changes: [
{
action: 'created',
artifactId,
artifact: {
id: artifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Trailing report',
url: 'https://example.com/trailing-report',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-07-06T00:00:00.000Z',
updatedAt: '2026-07-06T00:00:00.000Z',
persistedAt: '2026-07-06T00:00:00.000Z',
},
},
],
},
};
vi.mocked(jsonl.read).mockResolvedValue([
recordB1,
recordB2,
artifactRecord,
]);
const loaded = await sessionService.loadSession(sessionIdB);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['b1', 'b2']);
expect(loaded?.lastCompletedUuid).toBe('b2');
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
expect.objectContaining({
id: artifactId,
title: 'Trailing report',
}),
]);
});
it('keeps the latest file history snapshot for a prompt id', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
@ -2511,6 +2863,324 @@ describe('SessionService', () => {
expect(srcLines.every((r) => !r.forkedFrom)).toBe(true);
});
it('copies artifact side records from the active branch', async () => {
const oldId = '71717171-7171-7171-7171-717171717171';
const newId = '81818181-8181-8181-8181-818181818181';
const { file, lines } = seedSession(oldId);
const oldArtifactId = stableSessionArtifactId(
oldId,
'url:https://example.com/forked',
);
const artifactRecord = {
uuid: 'artifact-1',
parentUuid: 'u1',
sessionId: oldId,
type: 'system',
subtype: 'session_artifact_event',
timestamp: '2026-04-22T00:00:00.500Z',
cwd,
version: 'test',
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: oldId,
sequence: 1,
recordedAt: '2026-04-22T00:00:00.500Z',
changes: [
{
action: 'created',
artifactId: oldArtifactId,
artifact: {
id: oldArtifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Forked artifact',
url: 'https://example.com/forked',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-04-22T00:00:00.500Z',
updatedAt: '2026-04-22T00:00:00.500Z',
persistedAt: '2026-04-22T00:00:00.500Z',
},
},
],
},
};
fs.writeFileSync(
file,
[lines[0], artifactRecord, lines[1]]
.map((line) => JSON.stringify(line))
.join('\n') + '\n',
);
const result = await service.forkSession(oldId, newId);
const loaded = await service.loadSession(newId);
const forkedLines = fs
.readFileSync(result.filePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(result.copiedCount).toBe(3);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['u1', 'u2']);
expect(
forkedLines.find((record) => record.uuid === 'artifact-1'),
).toMatchObject({
parentUuid: 'u1',
});
expect(forkedLines.find((record) => record.uuid === 'u2')).toMatchObject({
parentUuid: 'u1',
});
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
expect.objectContaining({
id: stableSessionArtifactId(newId, 'url:https://example.com/forked'),
title: 'Forked artifact',
}),
]);
});
it('does not copy artifact side records from abandoned branches', async () => {
const oldId = '74747474-7474-7474-7474-747474747474';
const newId = '84848484-8484-8484-8484-848484848484';
const { file, lines } = seedSession(oldId);
const oldArtifactId = stableSessionArtifactId(
oldId,
'url:https://example.com/abandoned-forked',
);
const artifactRecord = {
uuid: 'artifact-abandoned',
parentUuid: 'u1',
sessionId: oldId,
type: 'system',
subtype: 'session_artifact_event',
timestamp: '2026-04-22T00:00:00.500Z',
cwd,
version: 'test',
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: oldId,
sequence: 1,
recordedAt: '2026-04-22T00:00:00.500Z',
changes: [
{
action: 'created',
artifactId: oldArtifactId,
artifact: {
id: oldArtifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Abandoned forked artifact',
url: 'https://example.com/abandoned-forked',
retention: 'restorable',
clientRetained: true,
createdAt: '2026-04-22T00:00:00.500Z',
updatedAt: '2026-04-22T00:00:00.500Z',
persistedAt: '2026-04-22T00:00:00.500Z',
},
},
],
},
};
const abandonedChild = {
...lines[1],
uuid: 'abandoned-child',
parentUuid: 'u1',
};
fs.writeFileSync(
file,
[lines[0], artifactRecord, abandonedChild, lines[1]]
.map((line) => JSON.stringify(line))
.join('\n') + '\n',
);
const result = await service.forkSession(oldId, newId);
const loaded = await service.loadSession(newId);
const forkedLines = fs
.readFileSync(result.filePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(result.copiedCount).toBe(2);
expect(
forkedLines.some((record) => record.uuid === 'artifact-abandoned'),
).toBe(false);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['u1', 'u2']);
expect(loaded?.artifactSnapshot).toBeUndefined();
});
it('does not treat trailing artifact side records as the fork leaf', async () => {
const oldId = '73737373-7373-7373-7373-737373737373';
const newId = '83838383-8383-8383-8383-838383838383';
const { file, lines } = seedSession(oldId);
const url = 'https://example.com/trailing-forked';
const oldArtifactId = stableSessionArtifactId(oldId, `url:${url}`);
const artifactRecord = {
uuid: 'artifact-tail',
parentUuid: 'u2',
sessionId: oldId,
type: 'system',
subtype: 'session_artifact_event',
timestamp: '2026-04-22T00:00:01.500Z',
cwd,
version: 'test',
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: oldId,
sequence: 1,
recordedAt: '2026-04-22T00:00:01.500Z',
changes: [
{
action: 'created',
artifactId: oldArtifactId,
artifact: {
id: oldArtifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Trailing forked artifact',
url,
retention: 'restorable',
clientRetained: true,
createdAt: '2026-04-22T00:00:01.500Z',
updatedAt: '2026-04-22T00:00:01.500Z',
persistedAt: '2026-04-22T00:00:01.500Z',
},
},
],
},
};
fs.writeFileSync(
file,
[...lines, artifactRecord]
.map((line) => JSON.stringify(line))
.join('\n') + '\n',
);
const result = await service.forkSession(oldId, newId);
const loaded = await service.loadSession(newId);
expect(result.copiedCount).toBe(3);
expect(
loaded?.conversation.messages.map((record) => record.uuid),
).toEqual(['u1', 'u2']);
expect(loaded?.lastCompletedUuid).toBe('u2');
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
expect.objectContaining({
id: stableSessionArtifactId(newId, `url:${url}`),
title: 'Trailing forked artifact',
}),
]);
});
it('does not resurrect artifacts removed by later side records when forking', async () => {
const oldId = '72727272-7272-7272-7272-727272727272';
const newId = '82828282-8282-8282-8282-828282828282';
const { file, lines } = seedSession(oldId);
const url = 'https://example.com/forked-then-removed';
const oldArtifactId = stableSessionArtifactId(oldId, `url:${url}`);
const forkedArtifactId = stableSessionArtifactId(newId, `url:${url}`);
const createRecord = {
uuid: 'artifact-create',
parentUuid: 'u1',
sessionId: oldId,
type: 'system',
subtype: 'session_artifact_event',
timestamp: '2026-04-22T00:00:00.500Z',
cwd,
version: 'test',
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: oldId,
sequence: 1,
recordedAt: '2026-04-22T00:00:00.500Z',
changes: [
{
action: 'created',
artifactId: oldArtifactId,
artifact: {
id: oldArtifactId,
kind: 'link',
storage: 'external_url',
source: 'client',
status: 'available',
title: 'Forked artifact',
url,
retention: 'restorable',
clientRetained: true,
createdAt: '2026-04-22T00:00:00.500Z',
updatedAt: '2026-04-22T00:00:00.500Z',
persistedAt: '2026-04-22T00:00:00.500Z',
},
},
],
},
};
const removeRecord = {
uuid: 'artifact-remove',
parentUuid: 'u1',
sessionId: oldId,
type: 'system',
subtype: 'session_artifact_event',
timestamp: '2026-04-22T00:00:00.750Z',
cwd,
version: 'test',
systemPayload: {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: oldId,
sequence: 2,
recordedAt: '2026-04-22T00:00:00.750Z',
changes: [
{
action: 'removed',
artifactId: oldArtifactId,
reason: 'explicit',
},
],
},
};
fs.writeFileSync(
file,
[lines[0], createRecord, removeRecord, lines[1]]
.map((line) => JSON.stringify(line))
.join('\n') + '\n',
);
const result = await service.forkSession(oldId, newId);
const loaded = await service.loadSession(newId);
const forkedLines = fs
.readFileSync(result.filePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const forkedRemovePayload = forkedLines.find(
(record) => record.uuid === 'artifact-remove',
)?.systemPayload;
expect(result.copiedCount).toBe(4);
expect(loaded?.artifactSnapshot?.artifacts).toEqual([]);
expect(loaded?.artifactSnapshot?.tombstonedIds).toContain(
forkedArtifactId,
);
expect(forkedRemovePayload).toMatchObject({
changes: [
{
action: 'removed',
artifactId: forkedArtifactId,
reason: 'explicit',
},
],
});
});
it('preserves file history snapshots on the forked session', async () => {
const oldId = '31313131-3131-3131-3131-313131313131';
const newId = '41414141-4141-4141-4141-414141414141';

View file

@ -39,6 +39,12 @@ import {
readLastJsonStringFieldsSync,
} from '../utils/sessionStorageUtils.js';
import { getUsageOutputTokenCountForPromptEstimate } from './tokenEstimation.js';
import {
isSessionArtifactRecord,
rebuildSessionArtifactSnapshot,
remapSessionArtifactPayloadForFork,
type RebuiltSessionArtifactSnapshot,
} from './session-artifact-persistence.js';
import { SessionOrganizationService } from './session-organization-service.js';
const debugLogger = createDebugLogger('SESSION');
@ -183,6 +189,8 @@ export interface ResumedSessionData {
lastCompletedUuid: string | null;
/** Deserialized file history snapshots for resume (enables /rewind across sessions) */
fileHistorySnapshots?: FileHistorySnapshot[];
/** Persisted session artifact metadata reconstructed from JSONL records. */
artifactSnapshot?: RebuiltSessionArtifactSnapshot;
/**
* Breaks in the persisted parentUuid chain that were detected during
* reconstruction (an earlier segment of history was physically lost and could
@ -976,6 +984,16 @@ export class SessionService {
return { messages, gaps };
}
private lastConversationRecordUuid(records: ChatRecord[]): string | null {
for (let index = records.length - 1; index >= 0; index--) {
const record = records[index];
if (record && !isSessionArtifactRecord(record)) {
return record.uuid;
}
}
return null;
}
/**
* Loads a session by its session ID.
* Reconstructs the full conversation from tree-structured records.
@ -1007,7 +1025,13 @@ export class SessionService {
}
// Reconstruct linear history
const leafUuid = this.lastConversationRecordUuid(records);
if (!leafUuid) {
return;
}
const { messages, gaps } = this.reconstructHistory(records, {
leafUuid,
detectGaps: true,
});
if (messages.length === 0) {
@ -1073,6 +1097,14 @@ export class SessionService {
fileHistorySnapshots.length > MAX_SNAPSHOTS
? fileHistorySnapshots.slice(-MAX_SNAPSHOTS)
: fileHistorySnapshots;
const activeBranchRecords = includeActiveSideArtifactRecords(
records,
messages,
);
const artifactSnapshot = rebuildSessionArtifactSnapshot(
activeBranchRecords,
firstRecord.sessionId,
);
return {
conversation,
@ -1080,6 +1112,7 @@ export class SessionService {
lastCompletedUuid: lastMessage.uuid,
fileHistorySnapshots:
cappedSnapshots.length > 0 ? cappedSnapshots : undefined,
...(artifactSnapshot ? { artifactSnapshot } : {}),
historyGaps: gaps.length > 0 ? gaps : undefined,
};
}
@ -1433,8 +1466,17 @@ export class SessionService {
// Copy only the active branch. Rewind leaves old records in the JSONL as
// abandoned parentUuid branches; copying raw records would resurrect them.
// detectGaps stays off here so a fork copies exactly the active branch.
const { messages: sourceRecords } = this.reconstructHistory(records);
const leafUuid = this.lastConversationRecordUuid(records);
if (!leafUuid) {
throw new Error(`Source session not found or empty: ${sourceSessionId}`);
}
const { messages: activeMessages } = this.reconstructHistory(records, {
leafUuid,
});
const sourceRecords = includeActiveSideArtifactRecords(
records,
activeMessages,
);
if (sourceRecords.length === 0) {
throw new Error(`Source session not found or empty: ${sourceSessionId}`);
}
@ -1443,27 +1485,29 @@ export class SessionService {
// clean linear descendant. `forkedFrom` captures the origin of each
// message.
let prevUuid: string | null = null;
const remappedArtifactIds = new Map<string, string>();
const forked: ChatRecord[] = sourceRecords.map((record) => {
const systemPayload =
record.type === 'system' && record.subtype === 'file_history_snapshot'
? remapFileHistorySnapshotPayload(
record.systemPayload,
sourceSessionId,
newSessionId,
)
: record.systemPayload;
const isArtifactRecord = isSessionArtifactRecord(record);
const systemPayload = remapSystemPayloadForFork(
record,
sourceSessionId,
newSessionId,
remappedArtifactIds,
);
const next: ChatRecord = {
...record,
sessionId: newSessionId,
cwd: this.projectRoot,
systemPayload,
parentUuid: prevUuid,
parentUuid: isArtifactRecord ? record.parentUuid : prevUuid,
forkedFrom: {
sessionId: sourceSessionId,
messageUuid: record.uuid,
},
};
prevUuid = record.uuid;
if (!isArtifactRecord) {
prevUuid = record.uuid;
}
return next;
});
@ -1937,6 +1981,118 @@ function remapFileHistorySnapshotPayload(
}
}
function remapSystemPayloadForFork(
record: ChatRecord,
sourceSessionId: string,
newSessionId: string,
remappedArtifactIds: Map<string, string>,
): ChatRecord['systemPayload'] {
if (record.type !== 'system') return record.systemPayload;
if (record.subtype === 'file_history_snapshot') {
return remapFileHistorySnapshotPayload(
record.systemPayload,
sourceSessionId,
newSessionId,
);
}
if (
record.subtype === 'session_artifact_event' ||
record.subtype === 'session_artifact_snapshot'
) {
return remapSessionArtifactPayloadForFork(
record.systemPayload,
sourceSessionId,
newSessionId,
remappedArtifactIds,
) as ChatRecord['systemPayload'];
}
return record.systemPayload;
}
function includeActiveSideArtifactRecords(
records: ChatRecord[],
activeRecords: ChatRecord[],
): ChatRecord[] {
const activeByUuid = new Map(
activeRecords.map((record) => [record.uuid, record]),
);
const activeUuids = new Set(activeByUuid.keys());
const firstActiveUuid = activeRecords[0]?.uuid;
const firstActiveIndex =
firstActiveUuid === undefined
? -1
: records.findIndex((record) => record.uuid === firstActiveUuid);
const nextActiveUuidByIndex = new Map<number, string>();
const nextBlockingUuidByIndex = new Map<number, string>();
let nextActiveUuid: string | undefined;
let nextBlockingUuid: string | undefined;
for (let index = records.length - 1; index >= 0; index--) {
if (nextActiveUuid !== undefined) {
nextActiveUuidByIndex.set(index, nextActiveUuid);
}
if (nextBlockingUuid !== undefined) {
nextBlockingUuidByIndex.set(index, nextBlockingUuid);
}
if (activeUuids.has(records[index]!.uuid)) {
nextActiveUuid = records[index]!.uuid;
nextBlockingUuid = undefined;
} else if (
!isSessionArtifactRecord(records[index]!) &&
!isTailNeutralSideRecord(records[index]!)
) {
nextBlockingUuid = records[index]!.uuid;
}
}
const selected: ChatRecord[] = [];
const includedSideArtifactUuids = new Set<string>();
let previousActiveUuid: string | undefined;
for (let index = 0; index < records.length; index++) {
const record = records[index]!;
const activeRecord = activeByUuid.get(record.uuid);
if (activeRecord) {
selected.push(activeRecord);
activeByUuid.delete(record.uuid);
previousActiveUuid = record.uuid;
continue;
}
if (!isSessionArtifactRecord(record)) {
continue;
}
const nextUuid = nextActiveUuidByIndex.get(index);
const hasBlockingRecordBeforeNextActive =
nextBlockingUuidByIndex.has(index);
const isInActiveSegment =
!hasBlockingRecordBeforeNextActive &&
(nextUuid !== undefined
? activeUuids.has(nextUuid)
: previousActiveUuid !== undefined &&
activeUuids.has(previousActiveUuid));
if (
record.parentUuid !== null &&
(activeUuids.has(record.parentUuid) ||
includedSideArtifactUuids.has(record.parentUuid)) &&
isInActiveSegment &&
(record.parentUuid === previousActiveUuid ||
includedSideArtifactUuids.has(record.parentUuid))
) {
selected.push(record);
includedSideArtifactUuids.add(record.uuid);
} else if (
record.parentUuid === null &&
index < firstActiveIndex &&
isInActiveSegment
) {
selected.push(record);
includedSideArtifactUuids.add(record.uuid);
}
}
return selected;
}
function isTailNeutralSideRecord(record: ChatRecord): boolean {
return record.type === 'system' && record.subtype === 'custom_title';
}
function collectFileHistorySnapshotPromptIds(
records: ChatRecord[],
): Set<string> {

View file

@ -41,11 +41,15 @@ const rootDir = join(__dirname, '..');
// workspace remember (managed memory client methods + event validation).
// Bumped from 133KB to 136KB after merging session artifacts plus sessionless
// workspace memory forget/dream APIs and event validation.
// Bumped from 136KB to 137KB for EventBus byte-backlog telemetry validation.
// Bumped from 137KB to 138KB for history_truncated event validation and
// Bumped from 136KB to 138KB for persistent session artifact APIs after
// merging the upstream daemon SDK surface.
// Bumped from 138KB to 139KB for EventBus byte-backlog telemetry validation.
// Bumped from 139KB to 140KB for history_truncated event validation and
// transcript status projection.
// Bumped from 138KB to 139KB for workspace ACP status/preheat APIs.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 139 * 1024;
// Bumped from 139KB to 141KB after merging main (ACP status/preheat) into
// the history_truncated/transcript-status branch.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 141 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't

View file

@ -286,10 +286,16 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)$/,
mapping: {
method: '_qwen/session/artifacts/remove',
extractParams: (segs) => ({
sessionId: segs[0],
artifactId: segs[1],
}),
extractParams: (segs, body) => {
const record = isRecord(body) ? body : {};
return {
sessionId: segs[0],
artifactId: segs[1],
...(typeof record.clientId === 'string'
? { clientId: record.clientId }
: {}),
};
},
},
},
// POST /session/:id/recap → _qwen/session/recap

View file

@ -542,12 +542,25 @@ export type {
PromptTextContent,
SetModelResult,
SetSessionLanguageResult,
KnownDaemonSessionArtifactChangeAction,
KnownDaemonSessionArtifactKind,
KnownDaemonSessionArtifactPersistenceWarning,
KnownDaemonSessionArtifactRemovalReason,
KnownDaemonSessionArtifactRestoreState,
KnownDaemonSessionArtifactRetention,
KnownDaemonSessionArtifactSource,
KnownDaemonSessionArtifactStatus,
KnownDaemonSessionArtifactStorage,
DaemonSessionArtifact,
DaemonSessionArtifactChange,
DaemonSessionArtifactChangeAction,
DaemonSessionArtifactInput,
DaemonSessionArtifactKind,
DaemonSessionArtifactMutationResult,
DaemonSessionArtifactPersistenceWarning,
DaemonSessionArtifactRemovalReason,
DaemonSessionArtifactRestoreState,
DaemonSessionArtifactRetention,
DaemonSessionArtifactsEnvelope,
DaemonSessionArtifactSource,
DaemonSessionArtifactStatus,

View file

@ -465,6 +465,7 @@ export interface DaemonSessionState {
/** Returned from `POST /session/:id/load` and `POST /session/:id/resume`. */
export interface DaemonRestoredSession extends DaemonSession {
state: DaemonSessionState;
artifactWarnings?: string[];
/** Compacted events for completed turns (load only). */
compactedReplay?: DaemonEvent[];
/** Raw events since last turn boundary — current incomplete turn (load only). */
@ -641,11 +642,37 @@ export type KnownDaemonSessionArtifactSource = 'tool' | 'hook' | 'client';
export type DaemonSessionArtifactSource =
OpenStringUnion<KnownDaemonSessionArtifactSource>;
export type KnownDaemonSessionArtifactStatus = 'available' | 'missing';
export type KnownDaemonSessionArtifactStatus =
| 'available'
| 'missing'
| 'changed';
export type DaemonSessionArtifactStatus =
OpenStringUnion<KnownDaemonSessionArtifactStatus>;
export type KnownDaemonSessionArtifactRetention = 'ephemeral' | 'restorable';
export type DaemonSessionArtifactRetention =
OpenStringUnion<KnownDaemonSessionArtifactRetention>;
export type KnownDaemonSessionArtifactRestoreState =
| 'live'
| 'restored'
| 'unverified'
| 'blocked';
export type DaemonSessionArtifactRestoreState =
OpenStringUnion<KnownDaemonSessionArtifactRestoreState>;
export type KnownDaemonSessionArtifactPersistenceWarning =
| 'persistence_unavailable'
| 'metadata_only_restore'
| 'restore_validation_failed'
| 'sticky_override_active';
export type DaemonSessionArtifactPersistenceWarning =
OpenStringUnion<KnownDaemonSessionArtifactPersistenceWarning>;
export interface DaemonSessionArtifactInput {
kind?: KnownDaemonSessionArtifactKind;
storage?: Exclude<KnownDaemonSessionArtifactStorage, 'published'>;
@ -657,6 +684,8 @@ export interface DaemonSessionArtifactInput {
mimeType?: string;
sizeBytes?: number;
metadata?: Record<string, string | number | boolean | null>;
retention?: KnownDaemonSessionArtifactRetention;
clientRetained?: boolean;
}
export interface DaemonSessionArtifact {
@ -673,6 +702,10 @@ export interface DaemonSessionArtifact {
mimeType?: string;
sizeBytes?: number;
metadata?: Record<string, string | number | boolean | null>;
retention: DaemonSessionArtifactRetention;
restoreState?: DaemonSessionArtifactRestoreState;
persistenceWarning?: DaemonSessionArtifactPersistenceWarning;
persistedAt?: string;
clientRetained: boolean;
createdAt: string;
updatedAt: string;
@ -689,7 +722,10 @@ export type KnownDaemonSessionArtifactChangeAction =
export type DaemonSessionArtifactChangeAction =
OpenStringUnion<KnownDaemonSessionArtifactChangeAction>;
export type KnownDaemonSessionArtifactRemovalReason = 'eviction' | 'explicit';
export type KnownDaemonSessionArtifactRemovalReason =
| 'eviction'
| 'explicit'
| 'unpin_to_ephemeral';
export type DaemonSessionArtifactRemovalReason =
OpenStringUnion<KnownDaemonSessionArtifactRemovalReason>;
@ -708,12 +744,25 @@ export interface DaemonSessionArtifactsEnvelope {
limits: {
maxArtifacts: number;
};
warnings?: string[];
warningDetails?: DaemonSessionArtifactWarningDetail[];
}
export interface DaemonSessionArtifactMutationResult {
v: 1;
sessionId: string;
changes: DaemonSessionArtifactChange[];
warnings?: string[];
warningDetails?: DaemonSessionArtifactWarningDetail[];
}
export interface DaemonSessionArtifactWarningDetail {
code: string;
operation: 'upsert' | 'remove' | 'restore' | (string & {});
artifactIds?: string[];
durability?: 'durable' | 'live_only' | 'unavailable' | (string & {});
retryable?: boolean;
message: string;
}
export type DaemonStatus =

View file

@ -359,7 +359,26 @@ describe('acpRouteTable matchRoute', () => {
expect(result!.mapping.method).toBe('_qwen/session/artifacts/remove');
expect(
result!.mapping.extractParams(result!.segments, undefined, 'DELETE'),
).toEqual({ sessionId: 's8', artifactId: 'art 1' });
).toEqual({
sessionId: 's8',
artifactId: 'art 1',
});
expect(
result!.mapping.extractParams(
result!.segments,
{
clientId: 'client-a',
sessionId: 'body-session',
artifactId: 'body-artifact',
deleteContent: true,
},
'DELETE',
),
).toEqual({
sessionId: 's8',
artifactId: 'art 1',
clientId: 'client-a',
});
});
it('POST /session/:id/recap maps to _qwen/session/recap', () => {