fix: harden subagent image follow-up admission
This commit is contained in:
1 parent
2951512e18
commit
ba810b3539
13 files changed
+238
-140
No files matched your search
+2
-2
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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` 重建已寻址窗口。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'), {})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user