Bladeren bron

fix: harden subagent image follow-up admission

creatixchu 2 weken geleden
bovenliggende
commit
ba810b3539

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md
-2026-08-27-steer-followup-image-delivery.md: fb93612254381b589c8adfe44e46c51dfe232c3e
-2026-08-27-steer-followup-image-delivery.zh.md: 35284aba89a7df5da5d472f078d2c2d3ea2e307d
+2026-08-27-steer-followup-image-delivery.md: 71f1d470fa6435758a22baaf9630f5c241aabc47
+2026-08-27-steer-followup-image-delivery.zh.md: 72c47d5e3366b181b1ffbdb112cac06292616f2f

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md

@@ -18,7 +18,7 @@ Third, the browser queue projection reduced a queued image to the text `[image]`
 
 **Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule.
 
-**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]`, whose single home moved from `dsh-api-session-controller` to `dsh-attachment`; the shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone.
+**Host-side subagent image admission.** `SubagentPromptRequest.content` is now upload-shaped `PromptContentPart[]` (updating the wire contract in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)), whose single home moved from `dsh-api-session-controller` to `dsh-attachment`; the shared `durablePromptContent()` conversion lives in `dsh-llm/content` and is used by both the Session prompt endpoint and `SubagentRuntime.prompt`. The subagent route admits and persists image batches through `ctx.attachments` before `followup()`, and the continuation manager refuses delivery inside the per-child lock when the child's `agent.options` route resolves to a model without image input (`MODEL_DOES_NOT_SUPPORT_IMAGES`, surfaced as `attachment-error` with the same reason vocabulary as the Session route). A child without a fixed options route, or a deployment without the LLM registry, delivers and relies on the LLM layer's text-only projection. The Client forwards image parts unchanged and the `SUBAGENT_IMAGE_UNSUPPORTED` copy is gone.
 
 **Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072).
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.zh.md

@@ -18,7 +18,7 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186),
 
 **轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。
 
-**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`,该类型的唯一定义处从 `dsh-api-session-controller` 移到 `dsh-attachment`;共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。
+**Host 侧子代理图片准入。** `SubagentPromptRequest.content` 改为上传形态的 `PromptContentPart[]`(同步更新 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 的 wire 契约),该类型的唯一定义处从 `dsh-api-session-controller` 移到 `dsh-attachment`;共享的 `durablePromptContent()` 转换位于 `dsh-llm/content`,Session prompt 端点与 `SubagentRuntime.prompt` 共用。子代理路由在 `followup()` 之前经 `ctx.attachments` 完成整批图片的准入与持久化;continuation 管理器在逐子级锁内,当子级 `agent.options` 路由解析到不接受图片输入的模型时拒绝投递(`MODEL_DOES_NOT_SUPPORT_IMAGES`,以与 Session 路由一致的 `attachment-error` 词汇表上抛)。子级没有固定 options 路由,或部署未挂载 LLM 注册表时照常投递,交给 LLM 层的纯文本投影。客户端原样转发图片部分,`SUBAGENT_IMAGE_UNSUPPORTED` 文案删除。
 
 **队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95
-2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2
+2026-07-27-web-subagent-conversations.md: 8998d6c5b828efbe3a817f50b272ee17ef5afb46
+2026-07-27-web-subagent-conversations.zh.md: f890bca9eb7a0d78813109fcee771b0e2518dc19

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -55,9 +55,9 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part
 
 - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`.
 - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent.
-- `subagent.prompt` accepts only a `mode: 'continuable'` address and `ContentBlock[]`. It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`.
+- `subagent.prompt` accepts only a `mode: 'continuable'` address and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`.
 
-The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
+The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`attachment-error`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
 
 Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -55,9 +55,9 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 
 - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。
 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。
-- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与 `ContentBlock[]`。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。
+- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../bug-fix/2026-08-27-steer-followup-image-delivery.zh.md))。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。
 
-网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
+网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`attachment-error`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
 
 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
 

+ 7 - 0
apps/web/tests/queue-image.e2e.ts

@@ -119,6 +119,13 @@ describe('web e2e: queued image submission', () => {
     await input.fill('Continue with the queued comparison')
     await input.press('Enter')
     await settled
+    // The queued image message and the waking text run as two further turns;
+    // wait for both to end so the final snapshot never captures a mid-reply
+    // frame (the aborted first turn precedes them).
+    await expect.poll(
+      () => sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []),
+      { timeout: 15_000 },
+    ).toEqual(['aborted', 'completed', 'completed'])
 
     // The delivered user message renders its image in Chat from the durable
     // reference, and the dock row is gone.

+ 123 - 120
packages/client/ui-conversation/src/client/queue/QueueDock.tsx

@@ -128,133 +128,136 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
           </button>
         )}
         <ul id={listId} className={css.list} hidden={!listVisible}>
-          {listVisible && queue.map(row => (
-            <li key={row.id} className={css.row}>
-              {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
-              {queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
-              {editing?.id === row.id
-                ? (
-                  <input
-                    autoFocus
-                    className={css.editor}
-                    aria-label={t('queue.edit')}
-                    value={editing.text}
-                    onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
-                    onKeyDown={(event) => {
-                      if (event.key === 'Escape') {
-                        setEditing(null)
-                        return
-                      }
-                      if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
-                        event.preventDefault()
-                        void saveEdit()
-                      }
-                    }}
-                  />
-                )
-                : (
-                  <>
-                    {queueImageRefs(row.content).length > 0 && (
-                      <span className={css.thumbs}>
-                        {queueImageRefs(row.content).map((attachment, index) => (
-                          <QueueThumb
-                            key={`${attachment.attachmentId}:${index}`}
-                            attachment={attachment}
-                            loadImage={loadImage}
-                            label={t('queue.image')}
-                          />
-                        ))}
-                      </span>
-                    )}
-                    <span className={css.preview}>{projectUserText(row.preview, [])}</span>
-                  </>
-                )}
-              {queueMutable && <div className={css.actions}>
+          {listVisible && queue.map((row) => {
+            const imageRefs = queueImageRefs(row.content)
+            return (
+              <li key={row.id} className={css.row}>
+                {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
+                {queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
                 {editing?.id === row.id
                   ? (
-                    <>
-                      <Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
-                        <button
-                          type="button"
-                          className={css.action}
-                          aria-label={t('queue.save')}
-                          disabled={busy !== null || editing.text.trim() === ''}
-                          onClick={() => { void saveEdit() }}
-                        >
-                          <IconCheckOutline16 size={14} />
-                        </button>
-                      </Tooltip>
-                      <Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
-                        <button
-                          type="button"
-                          className={css.action}
-                          aria-label={t('queue.cancelEdit')}
-                          disabled={busy !== null}
-                          onClick={() => { setEditing(null) }}
-                        >
-                          <IconCloseOutline16 size={14} />
-                        </button>
-                      </Tooltip>
-                    </>
+                    <input
+                      autoFocus
+                      className={css.editor}
+                      aria-label={t('queue.edit')}
+                      value={editing.text}
+                      onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
+                      onKeyDown={(event) => {
+                        if (event.key === 'Escape') {
+                          setEditing(null)
+                          return
+                        }
+                        if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
+                          event.preventDefault()
+                          void saveEdit()
+                        }
+                      }}
+                    />
                   )
                   : (
                     <>
-                      <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
-                        <button
-                          type="button"
-                          className={css.action}
-                          aria-label={t('queue.edit')}
-                          // Disabled buttons fire no hover events, so the
-                          // unsupported hint stays a native title.
-                          title={row.text === null ? t('queue.edit.unsupported') : undefined}
-                          disabled={busy !== null || row.text === null}
-                          onClick={() => {
-                            if (row.text !== null) setEditing({ id: row.id, text: row.text })
-                          }}
-                        >
-                          <IconEditOutline16 size={14} />
-                        </button>
-                      </Tooltip>
-                      <Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
-                        <button
-                          type="button"
-                          className={css.action}
-                          aria-label={t('queue.remove')}
-                          disabled={busy !== null}
-                          onClick={() => {
-                            void applyAction(
-                              row.id,
-                              { kind: 'remove' },
-                              t('queue.removeFailed'),
-                            )
-                          }}
-                        >
-                          <IconTrashOutline16 size={14} />
-                        </button>
-                      </Tooltip>
-                      <Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
-                        <button
-                          type="button"
-                          className={css.action}
-                          aria-label={t('queue.steer')}
-                          title={running ? undefined : t('queue.steer.unavailable')}
-                          disabled={busy !== null || !running}
-                          onClick={() => {
-                            void applyAction(
-                              row.id,
-                              { kind: 'steer' },
-                              t('queue.steerFailed'),
-                            )
-                          }}
-                        >
-                          <IconSendOutline14 />
-                        </button>
-                      </Tooltip>
+                      {imageRefs.length > 0 && (
+                        <span className={css.thumbs}>
+                          {imageRefs.map((attachment, index) => (
+                            <QueueThumb
+                              key={`${attachment.attachmentId}:${index}`}
+                              attachment={attachment}
+                              loadImage={loadImage}
+                              label={t('queue.image')}
+                            />
+                          ))}
+                        </span>
+                      )}
+                      <span className={css.preview}>{projectUserText(row.preview, [])}</span>
                     </>
                   )}
-              </div>}
-            </li>
-          ))}
+                {queueMutable && <div className={css.actions}>
+                  {editing?.id === row.id
+                    ? (
+                      <>
+                        <Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
+                          <button
+                            type="button"
+                            className={css.action}
+                            aria-label={t('queue.save')}
+                            disabled={busy !== null || editing.text.trim() === ''}
+                            onClick={() => { void saveEdit() }}
+                          >
+                            <IconCheckOutline16 size={14} />
+                          </button>
+                        </Tooltip>
+                        <Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
+                          <button
+                            type="button"
+                            className={css.action}
+                            aria-label={t('queue.cancelEdit')}
+                            disabled={busy !== null}
+                            onClick={() => { setEditing(null) }}
+                          >
+                            <IconCloseOutline16 size={14} />
+                          </button>
+                        </Tooltip>
+                      </>
+                    )
+                    : (
+                      <>
+                        <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
+                          <button
+                            type="button"
+                            className={css.action}
+                            aria-label={t('queue.edit')}
+                            // Disabled buttons fire no hover events, so the
+                            // unsupported hint stays a native title.
+                            title={row.text === null ? t('queue.edit.unsupported') : undefined}
+                            disabled={busy !== null || row.text === null}
+                            onClick={() => {
+                              if (row.text !== null) setEditing({ id: row.id, text: row.text })
+                            }}
+                          >
+                            <IconEditOutline16 size={14} />
+                          </button>
+                        </Tooltip>
+                        <Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
+                          <button
+                            type="button"
+                            className={css.action}
+                            aria-label={t('queue.remove')}
+                            disabled={busy !== null}
+                            onClick={() => {
+                              void applyAction(
+                                row.id,
+                                { kind: 'remove' },
+                                t('queue.removeFailed'),
+                              )
+                            }}
+                          >
+                            <IconTrashOutline16 size={14} />
+                          </button>
+                        </Tooltip>
+                        <Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
+                          <button
+                            type="button"
+                            className={css.action}
+                            aria-label={t('queue.steer')}
+                            title={running ? undefined : t('queue.steer.unavailable')}
+                            disabled={busy !== null || !running}
+                            onClick={() => {
+                              void applyAction(
+                                row.id,
+                                { kind: 'steer' },
+                                t('queue.steerFailed'),
+                              )
+                            }}
+                          >
+                            <IconSendOutline14 />
+                          </button>
+                        </Tooltip>
+                      </>
+                    )}
+                </div>}
+              </li>
+            )
+          })}
         </ul>
       </div>
     </div>

+ 4 - 2
packages/core/agent-loop/src/agent.ts

@@ -72,8 +72,10 @@ export class ReactLoopAgent implements Agent {
   private activityDone: Promise<void> = Promise.resolve()
   /**
    * Identities of waking sends still awaiting a claim. Claim and discard
-   * notifications prune the set, and {@link cancel} clears it because a
-   * cancellation parks accepted-but-unclaimed input for a later waking send.
+   * notifications prune the set per message, while {@link cancel} and a
+   * pre-step rejection clear the set. Parking consumes every outstanding
+   * wake, including follow-ups unrelated to the rejected claim,
+   * because the next waking send resumes the complete parked queue anyway.
    * A non-empty set at driver exit therefore means a steer or follow-up lost
    * the race with a normally or erroneously closing turn, and the exit must
    * start a fresh driver to deliver it. Injected context never enters the

+ 18 - 4
packages/subagent/subagent/src/continuation.ts

@@ -516,17 +516,25 @@ export class SubagentContinuationManager {
         if (activation === undefined) return this.coldResume(parent, childId, content, options)
         // A delivery that arrives after the disposal transaction began must not
         // reach a handle being torn down; wait for release, then cold-resume.
+        const disposal = activation.disposal
         /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a
          * delivery to observe the transaction inside the same critical section that opened it,
          * which no test can schedule deterministically. The behavior is covered end-to-end by
          * "cold-resumes a delivery that lost the race with final disposal". */
-        if (activation.disposal !== undefined) {
-          return activation.disposal.then(() => undefined, () => undefined)
+        if (disposal !== undefined) {
+          return disposal.then(() => undefined, () => undefined)
         }
-        // Guarded call: text-only delivery must not gain an await hop inside
-        // the per-child lock, where it would reorder against drain admission.
+        // Text-only delivery stays await-free, so the disposal-cutoff check
+        // above and the submit share one critical window. The image path
+        // awaits a capability read, so it re-checks the cutoff afterwards; a
+        // disposal that began during the read is waited out and retried like
+        // one observed on entry.
         if (contentHasImage(content)) {
           await this.assertImageCapable(activation.handle.agent, options.signal)
+          if (activation.disposal !== undefined) {
+            await Promise.allSettled([activation.disposal])
+            return undefined
+          }
         }
         return this.submitAdmitted(activation, content, options.source, parent, options.signal)
       })
@@ -1027,7 +1035,13 @@ export class SubagentContinuationManager {
   ): Promise<MessageId> {
     try {
       if (contentHasImage(content)) {
+        // The capability read awaits with the activation already published, so
+        // the disposal cutoff is re-checked before the submit; a drain that
+        // began during the read turns into a clean closing rejection.
         await this.assertImageCapable(activation.handle.agent, signal)
+        if (activation.disposal !== undefined) {
+          throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING')
+        }
       }
       return this.submitAdmitted(activation, content, source, parent, signal)
     } catch (error: unknown) {

+ 8 - 1
packages/subagent/subagent/src/index.ts

@@ -459,7 +459,14 @@ export class SubagentRuntime extends TypertRemoteService {
     try {
       // Admission precedes delivery: image parts become durable references
       // here, so the child inbox only ever accepts Host-persisted attachments.
-      const content: ContentBlock[] = await durablePromptContent(this.ctx.attachments, request.content)
+      let content: ContentBlock[]
+      if (request.content.every((part): part is { readonly type: 'text'; readonly text: string } => part.type === 'text')) {
+        content = request.content.map(part => ({ type: 'text', text: part.text }))
+      } else {
+        const attachments = this.ctx.get('attachments')
+        if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store')
+        content = await durablePromptContent(attachments, request.content)
+      }
       return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) }
     } catch (error: unknown) {
       return rejectPrompt(error, childSessionId, signal)

+ 55 - 3
packages/subagent/subagent/tests/continuation.spec.ts

@@ -550,10 +550,17 @@ describe('continuable image follow-ups', () => {
     await drainManager(ctx)
   })
 
-  it('delivers an image follow-up when the child model accepts image input', async () => {
-    const { ctx, parent } = await setup([textResponse('child work'), textResponse('image reply')])
+  it('delivers an image follow-up to a resident child when its model accepts image input', async () => {
+    const releaseFirst = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([
+      { chunks: textResponse('child work'), gate: releaseFirst.promise },
+      { chunks: textResponse('image reply') },
+    ])
+    const { ctx, parent } = await setupWith(adapter)
     const started = await ctx.subagents.startContinuable(startSpec(parent))
-    await waitNoActivation(ctx, started.childId)
+    await vi.waitFor(() => {
+      expect(adapter.requests).toHaveLength(1)
+    })
     vi.spyOn(ctx.llm, 'resolveModelInfo')
       .mockResolvedValue({ inputModalities: ['text', 'image'] } as never)
 
@@ -561,6 +568,7 @@ describe('continuable image follow-ups', () => {
       { type: 'text' as const, text: 'compare' },
       imageBlock,
     ], { source: { kind: 'user' }, signal: testSignal })
+    releaseFirst.resolve(undefined)
     await waitNoActivation(ctx, started.childId)
 
     const loaded = await ctx.sessionPersistence.load(started.childId)
@@ -573,6 +581,50 @@ describe('continuable image follow-ups', () => {
     await drainManager(ctx)
   })
 
+  it('re-checks the disposal cutoff when a drain begins during a live image capability read', async () => {
+    const releaseFirst = Promise.withResolvers<undefined>()
+    const adapter = new GatedAdapter([{ chunks: textResponse('child work'), gate: releaseFirst.promise }])
+    const { ctx, parent } = await setupWith(adapter)
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
+    const capability = Promise.withResolvers<{ inputModalities: string[] }>()
+    const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo').mockReturnValue(capability.promise as never)
+
+    const delivery = ctx.subagents.followup(parent, started.childId, [imageBlock], {
+      source: { kind: 'user' }, signal: testSignal,
+    })
+    delivery.catch(() => undefined)
+    await vi.waitFor(() => { expect(resolve).toHaveBeenCalled() })
+    releaseFirst.resolve(undefined)
+    const draining = drainManager(ctx)
+    capability.resolve({ inputModalities: ['text', 'image'] })
+
+    await expect(delivery).rejects.toMatchObject({ code: 'DRAINING' })
+    await draining
+  })
+
+  it('rejects a materialized image follow-up whose capability read raced a drain', async () => {
+    const { ctx, parent } = await setup([textResponse('child work')])
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await waitNoActivation(ctx, started.childId)
+    const capability = Promise.withResolvers<{ inputModalities: string[] }>()
+    const resolve = vi.spyOn(ctx.llm, 'resolveModelInfo').mockReturnValue(capability.promise as never)
+
+    const delivery = ctx.subagents.followup(parent, started.childId, [imageBlock], {
+      source: { kind: 'user' }, signal: testSignal,
+    })
+    delivery.catch(() => undefined)
+    await vi.waitFor(() => { expect(resolve).toHaveBeenCalled() })
+    const draining = drainManager(ctx)
+    capability.resolve({ inputModalities: ['text', 'image'] })
+
+    await expect(delivery).rejects.toMatchObject({ code: 'ACTIVATION_CLOSING' })
+    await draining
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(loaded.events.some(event => event.type === 'user/message'
+      && event.data.content.some(block => block.type === 'image'))).toBe(false)
+  })
+
   it('defers to the text-only projection when the descriptor declares no model route', async () => {
     const { ctx } = await setup([])
     const routeless = ctx.agentLoop.create(SessionId('routeless-image'), {})

+ 13 - 0
packages/subagent/subagent/tests/control.spec.ts

@@ -216,6 +216,19 @@ describe('subagent prompt Remote', () => {
     expect(followup).not.toHaveBeenCalled()
   })
 
+  it('rejects an image prompt when no attachment store is composed', async () => {
+    const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
+    const followup = vi.spyOn(subagents, 'followup')
+
+    await expect(subagents.prompt({
+      ...promptRequest(),
+      content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=' }],
+    }, signal)).rejects.toMatchObject({
+      failure: { code: 'internal', message: 'subagent prompt failed' },
+    })
+    expect(followup).not.toHaveBeenCalled()
+  })
+
   it('maps a text-only child model refusal to attachment-error', async () => {
     const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
     vi.spyOn(subagents, 'followup').mockRejectedValue(