Skip to content

fix(acp): preserve file attachments in custom-agent history - #780

Merged
xintaofei merged 4 commits into
xintaofei:mainfrom
tangsenfei:codex/fix-acp-history-attachments
Sep 21, 2026
Merged

xintaofei merged 4 commits into
xintaofei:mainfrom
tangsenfei:codex/fix-acp-history-attachments

Conversation

@tangsenfei

Copy link
Copy Markdown
Contributor

问题与影响

自定义 ACP 智能体的普通文件在发送时已送达,原始 ACP transcript 也保留了资源块,但刷新/重新打开会话后,历史接口返回的用户消息会遗漏这些附件。Web、桌面以及其他读取同一历史接口的客户端都会受到该解析路径的影响;这不是模型未收到文件,也不意味着所有内置智能体的专用解析器都有同一问题。

最小复现:给自定义 ACP 智能体发送文字和一个文件,等待回复,再重新读取会话。原始 prompt 例如:

[
  {"type":"text","text":"Review this file"},
  {"type":"resource","resource":{"uri":"attachment:///note.txt","mimeType":"text/plain","blob":"aGVsbG8="}}
]

修复前 acp_native::prompt_blocks 只处理原生图片或顶层 text,会跳过嵌套的 resourceresource_link;只有附件的消息可能变成空消息。session/loaduser_message_chunk 重放也只取文字,会丢掉普通二进制资源和用户图片。

修复方案

  • 在 ACP 原生历史解析器中共用用户内容块转换,覆盖直接记录的 prompt 和会话重放。
  • 普通嵌入文件保留 [uri](uri) 标记,文件链接保留 [name](uri),与现有实时用户消息的轻量表示一致;不把文件正文或 base64 二进制展开到聊天文字中。
  • 带 image MIME 的嵌入资源恢复为图片,保留原生图片及旧 snake_case MIME 字段兼容。
  • 同一条重放用户消息中的文字、资源、图片保持顺序并归入同一个用户回合;保留已记录 prompt 的回显去重。

不修改数据库 schema、原始 transcript、请求协议或任何客户端。已有原始记录可在重新解析时恢复标记;本 PR 不新增附件下载/持久文件管理能力,也不改变智能体会话的上下文恢复机制。

验证

新增回归覆盖混合文件/图片的历史读取、通知重放一致性、回显去重、只有附件及畸形/空资源、旧图片 MIME 字段。测试使用合成资料,不依赖真实模型或凭据。

已在 Linux 使用实际上游 crate 编译运行:

cargo test --manifest-path src-tauri/Cargo.toml --locked --no-default-features --lib parsers::acp_native::tests

结果:20 passed,0 failed(包含 4 个新增回归测试);git diff --check 通过。未将该定向解析测试声明为 Web/移动端浏览器交互验收。

tangsenfei and others added 4 commits September 19, 2026 22:50
Recorded prompts contain resource/resource_link blocks, but the history
projection only kept top-level text and native images. Session/load replay
also discarded binary resources and user images.

Use one user-content projection for recorded prompts and replay: preserve
file markers, promote image resources, and keep mixed chunks in one user
turn without duplicating recorded prompt echoes. No transcript/schema or
client protocol changes; existing raw records can be reparsed.

Validated with all 20 acp_native parser tests, including four regressions.
The attachment-preserving history projection turns a non-image resource
into a `[uri](uri)` marker block. On the `session/load` replay path the
text-coalescing rule then appended the NEXT user chunk onto it, so an
agent that stores file context before the prose ("here is the file" +
"what does this do?") read back as one run-on paragraph with the prose
welded onto the end of a markdown link. The live projection
(`user_blocks_from_prompt`) emits one block per prompt block, and this
parser exists to match it.

Coalesce only onto prose — text a previous `user_message_chunk`
streamed — so a marker or an image always closes its block.

Adds the two regression cases that the change turns on (prose after a
marker; an attachment-only chunk ending the previous assistant turn) and
a parity test that pins this projection to `user_blocks_from_prompt`
through the real `map_prompt_blocks` wire encoding, so the two
implementations of one contract cannot drift silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three surfaces showed a user's own prompt back and each decided for
itself how an attachment looks: the live broadcast
(`user_blocks_from_prompt`), the ACP-native history parser, and the grok
history parser. They had already drifted — grok had no `resource_link`
case at all, so a plain attached file came back from its history as an
empty block, the same defect the ACP-native parser just had fixed.

Collapse the rule into `acp::types::project_user_prompt_block`, with
`prompt_block_from_wire` to read a recorded ACP block back into the
`PromptInputBlock` it was sent as. The live path becomes a thin adapter
over it (it only drops an image's `uri`, which its carrier cannot hold),
and both parsers go through `parsers::user_turn_block_from_wire`.

Three things fall out:

- The replay path no longer serializes an already-typed content block
  back to JSON just to re-read it untyped — a round trip this file's own
  comments say it avoids, and one that copied every embedded image's
  base64 twice. It now converts typed, moving the payload.
- Attachment markers are escaped the way the composer escapes its own
  `@`-file links, so `file:///a/b (1).ts` or a Windows `file:///C:\dir\`
  stays a well-formed link instead of closing early and rendering as raw
  `[…](…)` source. The frontend already parses that form
  (`src/lib/reference-link.ts`); a test there pins the two escapers to
  each other across the language boundary.
- Grok's user turns keep their file attachments.

Also: a prompt that is nothing but attachments is titled after what was
attached. ACP has no title channel, so such a conversation was
previously untitled forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The parsed title is authoritative: `get_folder_conversation_core` hands
it to `refresh_auto_title`, which replaces any unlocked title the row
holds — including one a custom ACP agent published over
`session_info_update`. So returning an attachment filename from the
history parse meant "Quarterly report analysis" could be overwritten
with "report.pdf" on the next detail load, where before the parse
returned nothing and left the agent's name alone.

Put the attachment name where it is a fallback rather than a verdict:
the first-prompt seed in `acp::manager`, which reaches
`seed_auto_title_if_empty` and a fresh row's `title` and nothing else.
A row with no title at all gets named after its file; a row that already
has one keeps it. The parse goes back to prose-only.

Also from the same review pass:

- grok latched `first_user_text` off the projected block, so an
  attachment ahead of the prose would title the chat `[report.pdf](…)`.
  It now reads the block as sent and latches only on real prose.
- `project_user_prompt_block` borrows instead of consuming. Taking it by
  value made `user_blocks_from_prompt` clone a whole embedded resource —
  body and all — only to render its uri. It now clones just the fields
  the projection keeps, which is what it cost before.
- The raw wire reader no longer drops an empty `mimeType`, so it agrees
  with the typed replay reader on that input too (asserted).
- `escape_markdown_text` collapses newline runs the way its TypeScript
  original does, so a label with a line break stays one inline token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xintaofei

Copy link
Copy Markdown
Owner

codeg work task 207 is done — #780 (7 files, +923/-181).

@xintaofei
xintaofei merged commit 1bf2efb into xintaofei:main Sep 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants