Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d9ee3a8
fix(cli): Preserve mid-turn image messages
doudouOUC Jun 16, 2026
4edf714
Merge branch 'main' into fix/mid-turn-image-preservation
yiliang114 Jun 16, 2026
50c7576
codex: address PR review feedback (#5183)
doudouOUC Jun 16, 2026
b23bfd2
fix(cli): Harden mid-turn message drain
doudouOUC Jun 16, 2026
25d04fb
fix(cli): Enforce mid-turn resolve timeout
doudouOUC Jun 16, 2026
f188796
fix(cli): Address mid-turn review suggestions
doudouOUC Jun 16, 2026
d030bee
codex: address PR review feedback (#5183)
doudouOUC Jun 16, 2026
f4e2205
codex: address PR review feedback (#5183)
doudouOUC Jun 17, 2026
f12054d
codex: address PR review feedback (#5183)
doudouOUC Jun 17, 2026
e221102
Merge branch 'main' into fix/mid-turn-image-preservation
doudouOUC Jun 17, 2026
3540048
Merge branch 'main' into fix/mid-turn-image-preservation
doudouOUC Jun 17, 2026
8c663de
fix(cli): Address mid-turn review feedback
doudouOUC Jun 17, 2026
869f9f2
fix(cli): Harden mid-turn drain edge cases
doudouOUC Jun 17, 2026
9344672
test(cli): Cover mid-turn drain review cases
doudouOUC Jun 17, 2026
c5d637c
fix(desktop): Remove unused mid-turn attachment argument
doudouOUC Jun 17, 2026
4dd05a6
fix(desktop): Harden mid-turn drain acknowledgements
doudouOUC Jun 17, 2026
e9269af
fix(cli): Skip failed mid-turn at-command injection
doudouOUC Jun 17, 2026
0fae7cf
fix(desktop): Simplify mid-turn drain acknowledgements
doudouOUC Jun 17, 2026
ba634c6
chore: merge main into mid-turn image branch
doudouOUC Jun 17, 2026
df5da5f
fix(desktop): Use stable mid-turn drain ack keys
doudouOUC Jun 17, 2026
b26aa48
fix(cli): Handle mid-turn image drain failures
doudouOUC Jun 17, 2026
7d6a0e3
fix(cli): Bound mid-turn drain fallbacks
doudouOUC Jun 17, 2026
8f6f642
fix: Bound mid-turn attachment handling
doudouOUC Jun 17, 2026
855a268
fix: Harden mid-turn attachment edge cases
doudouOUC Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
813 changes: 810 additions & 3 deletions packages/cli/src/acp-integration/session/Session.test.ts

Large diffs are not rendered by default.

285 changes: 257 additions & 28 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import type {
import type { LoadedSettings } from '../../config/settings.js';
import { z } from 'zod';
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
import { prefixMidTurnUserMessageParts } from '../../utils/midTurnUserMessage.js';
import {
handleSlashCommand,
getAvailableCommands,
Expand Down Expand Up @@ -184,12 +185,205 @@ const ASK_USER_QUESTION_CANCEL_SKIP_MESSAGE =
// means the client silently drops unknown methods; without a deadline the
// await would wedge the prompt turn forever.
const MID_TURN_QUEUE_DRAIN_TIMEOUT_MS = 2_000;
const MID_TURN_QUEUE_RESOLVE_TIMEOUT_MS = 10_000;
const MAX_MID_TURN_DRAIN_ITEMS = 10;
const MID_TURN_ATTACHMENT_PROCESSING_FAILURE_TEXT =
'[Attachment could not be processed]';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] MID_TURN_ATTACHMENT_PROCESSING_FAILURE_TEXT is defined here with the identical value '[Attachment could not be processed]' as in packages/desktop/packages/shared/src/agent/qwen-agent.ts:107. The MID_TURN_USER_MESSAGE_PREFIX duplication was already resolved by extracting it into midTurnUserMessage.ts, but this second user-facing string constant was not included in that extraction.

If the failure message needs to change (e.g., for localization or UX polish), two sites must be updated in lockstep. A mismatch produces inconsistent user-facing messages between the CLI ACP path and the desktop path.

Consider extracting to a shared constants module, or at minimum add a // SYNC: also defined in qwen-agent.ts comment at both sites.

— qwen3.7-max via Qwen Code /review

const MAX_MID_TURN_RESOURCE_TEXT_LENGTH = 100_000;
// Latch the drain off only after this many consecutive timeouts: one slow
// answer must not permanently disable mid-turn messages for a
// conforming-but-busy client, while a client that never answers stops
// costing a stall per tool batch after a few batches.
const MID_TURN_QUEUE_DRAIN_MAX_TIMEOUT_STRIKES = 3;

type DrainedMidTurnMessage =
| { kind: 'text'; message: string }
| { kind: 'structured'; content: ContentBlock[]; displayText: string };

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}

function isContentBlock(value: unknown): value is ContentBlock {
if (!isRecord(value) || typeof value['type'] !== 'string') return false;

switch (value['type']) {
case 'text':
return typeof value['text'] === 'string';
Comment thread
doudouOUC marked this conversation as resolved.
case 'image':
return (
Comment thread
doudouOUC marked this conversation as resolved.
typeof value['mimeType'] === 'string' &&
value['mimeType'].startsWith('image/') &&
typeof value['data'] === 'string'
);
case 'audio':
Comment thread
doudouOUC marked this conversation as resolved.
return (
typeof value['mimeType'] === 'string' &&
value['mimeType'].startsWith('audio/') &&
typeof value['data'] === 'string'
);
case 'resource_link':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isContentBlock rejects resource_link blocks (return false), but #resolvePrompt at line 4307 fully handles them (resolving URIs via readManyFiles, falling back to @<uri> text). This validation/resolution inconsistency means resource_link content blocks in mid-turn drain items are silently filtered out by getValidMidTurnContentBlocks before #resolvePrompt ever sees them. The ACP spec states "All agents MUST support ContentBlock::ResourceLink in prompts."

Suggested change
case 'resource_link':
case 'resource_link':
return typeof value['uri'] === 'string' && typeof value['name'] === 'string';

— qwen3.7-max via Qwen Code /review

return false;
case 'resource':
Comment thread
doudouOUC marked this conversation as resolved.
return isEmbeddedResourceResource(value['resource']);
default:
debugLogger.warn(`Unknown ContentBlock type: ${value['type']}`);
return false;
}
}

async function withTimeoutSignal<T>(
Comment thread
doudouOUC marked this conversation as resolved.
parentSignal: AbortSignal,
timeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const signal = AbortSignal.any([
parentSignal,
AbortSignal.timeout(timeoutMs),
]);

const toAbortError = () =>
signal.reason instanceof Error
? signal.reason
: new Error('Mid-turn message resolution aborted');

if (signal.aborted) throw toAbortError();

let rejectOnAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
rejectOnAbort = () => reject(toAbortError());
signal.addEventListener('abort', rejectOnAbort, { once: true });
if (signal.aborted) rejectOnAbort();
});

try {
return await Promise.race([fn(signal), abortPromise]);
Comment thread
doudouOUC marked this conversation as resolved.
} finally {
if (rejectOnAbort) signal.removeEventListener('abort', rejectOnAbort);
}
}

function isEmbeddedResourceResource(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isEmbeddedResourceResource caps text at MAX_MID_TURN_RESOURCE_TEXT_LENGTH = 100_000 characters but the blob field has no size limit. Similarly, isContentBlock doesn't cap data for image/audio blocks. A malicious or buggy ACP client could send arbitrarily large base64 blobs that get decoded and processed without bounds. Consider adding a MAX_MID_TURN_RESOURCE_BLOB_LENGTH constant and validating data.length / blob.length.

— qwen3.7-max via Qwen Code /review

value: unknown,
): value is EmbeddedResourceResource {
if (!isRecord(value) || typeof value['uri'] !== 'string') return false;
if (typeof value['text'] === 'string') {
return value['text'].length <= MAX_MID_TURN_RESOURCE_TEXT_LENGTH;
}
return typeof value['blob'] === 'string';
}
Comment thread
doudouOUC marked this conversation as resolved.

function hasInlineMediaContentBlock(content: ContentBlock[]): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] hasInlineMediaContentBlock only checks for image or audio types. When #resolvePrompt fails for a message with only resource blocks (no images/audio), this function returns false and the [Attachment could not be processed] marker is NOT appended. The user and model see only the display text with no indication that resource content was lost. Consider broadening the check to cover any non-text content block type.

— qwen3.7-max via Qwen Code /review

return content.some((part) => part.type === 'image' || part.type === 'audio');
}

function capMidTurnDrainItems<T>(items: T[], fieldName: string): T[] {
if (items.length <= MAX_MID_TURN_DRAIN_ITEMS) return items;

debugLogger.warn(
`Mid-turn drain response had ${items.length} ${fieldName}; processing first ${MAX_MID_TURN_DRAIN_ITEMS}`,
);
return items.slice(0, MAX_MID_TURN_DRAIN_ITEMS);
}

function getMidTurnItemDisplayTextForLog(displayText: unknown): string {
if (typeof displayText !== 'string' || displayText.trim().length === 0) {
return '(no display text)';
}
return JSON.stringify(displayText.trim().slice(0, 120));
}

function getValidMidTurnContentBlocks(
content: unknown,
displayText: unknown,
): ContentBlock[] {
if (!Array.isArray(content)) {
debugLogger.warn(
`Dropped invalid mid-turn item: ${getMidTurnItemDisplayTextForLog(
displayText,
)}`,
);
return [];
}

const validBlocks = content.filter(isContentBlock);
const invalidBlockCount = content.length - validBlocks.length;
if (invalidBlockCount > 0) {
debugLogger.warn(
`Dropped ${invalidBlockCount} invalid mid-turn content block(s): ${getMidTurnItemDisplayTextForLog(
displayText,
)}`,
);
}

return validBlocks;
}

function getStructuredMidTurnDisplayText(
content: ContentBlock[],
displayText: unknown,
): string {
if (typeof displayText === 'string' && displayText.trim().length > 0) {
return displayText.trim();
}

const text = content
.filter(
(part): part is Extract<ContentBlock, { type: 'text' }> =>
part.type === 'text',
)
.map((part) => part.text)
.join('\n')
.trim();

return text || '[User message with attachments]';
}

function parseMidTurnDrainResponse(response: unknown): DrainedMidTurnMessage[] {
Comment thread
doudouOUC marked this conversation as resolved.
if (!isRecord(response)) return [];

if (Array.isArray(response['items'])) {
return capMidTurnDrainItems(response['items'], 'item(s)').flatMap(
(item): DrainedMidTurnMessage[] => {
if (!isRecord(item)) {
return [];
}
const content = getValidMidTurnContentBlocks(
item['content'],
item['displayText'],
);
if (content.length === 0) return [];
return [
{
kind: 'structured',
content,
displayText: getStructuredMidTurnDisplayText(
content,
item['displayText'],
),
},
];
},
);
}

if (!Array.isArray(response['messages'])) {
debugLogger.warn(
`Mid-turn drain response had no recognized 'items' or 'messages' field; keys: ${Object.keys(
response,
).join(', ')}`,
);
return [];
}

return capMidTurnDrainItems(response['messages'], 'message(s)')
.filter(
(message): message is string =>
typeof message === 'string' && message.trim().length > 0,
)
.map((message) => ({ kind: 'text', message }));
}

class MidTurnDrainTimeoutError extends Error {
constructor() {
super(
Expand Down Expand Up @@ -1334,14 +1528,17 @@ export class Session implements SessionContext {
if (toolRun.stopAfterUserQuestionCancel) {
await this.#preserveCancelledAskUserQuestionToolRun(
toolRun,
pendingSend.signal,
);
return { stopReason: 'end_turn' };
}
nextMessage = {
role: 'user',
parts: [
...toolRun.parts,
...(await this.#drainMidTurnUserMessages()),
...(await this.#drainMidTurnUserMessages(
pendingSend.signal,
)),
],
};
}
Expand Down Expand Up @@ -1603,14 +1800,17 @@ export class Session implements SessionContext {
functionCalls,
);
if (toolRun.stopAfterUserQuestionCancel) {
await this.#preserveCancelledAskUserQuestionToolRun(toolRun);
await this.#preserveCancelledAskUserQuestionToolRun(
toolRun,
pendingSend.signal,
);
return { stopReason: 'end_turn' };
}
nextMessage = {
role: 'user',
parts: [
...toolRun.parts,
...(await this.#drainMidTurnUserMessages()),
...(await this.#drainMidTurnUserMessages(pendingSend.signal)),
],
};
}
Expand Down Expand Up @@ -1775,11 +1975,15 @@ export class Session implements SessionContext {

async #preserveCancelledAskUserQuestionToolRun(
toolRun: RunToolResult,
abortSignal: AbortSignal,
): Promise<void> {
this.#preserveUnsentMessageHistory(
{
role: 'user',
parts: [...toolRun.parts, ...(await this.#drainMidTurnUserMessages())],
parts: [
...toolRun.parts,
...(await this.#drainMidTurnUserMessages(abortSignal)),
],
},
true,
);
Expand Down Expand Up @@ -1892,7 +2096,7 @@ export class Session implements SessionContext {
});
}

async #drainMidTurnUserMessages(): Promise<Part[]> {
async #drainMidTurnUserMessages(abortSignal: AbortSignal): Promise<Part[]> {
Comment thread
doudouOUC marked this conversation as resolved.
if (this.midTurnDrainUnavailable) return [];

let drainPromise: ReturnType<AgentSideConnection['extMethod']> | undefined;
Expand All @@ -1914,28 +2118,49 @@ export class Session implements SessionContext {
clearTimeout(timeoutHandle);
}
this.midTurnDrainTimeoutStrikes = 0;
// A client may legally resolve with `result: null` (passed through
// unwrapped by the ACP SDK); guard the object access so that doesn't
// throw a TypeError and get misclassified as a transient drain error.
const messages =
response &&
typeof response === 'object' &&
Array.isArray(response['messages'])
? response['messages'].filter(
(message): message is string =>
typeof message === 'string' && message.trim().length > 0,
)
: [];

return messages.map((message) => {
const part = {
text: `\n[User message received during tool execution]: ${message}`,
};
const drainedMessages = parseMidTurnDrainResponse(response);
const drainedParts: Part[] = [];
for (const message of drainedMessages) {
Comment thread
doudouOUC marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] #resolvePrompt is called sequentially in this for loop. Each message waits up to MID_TURN_QUEUE_RESOLVE_TIMEOUT_MS (10s). With N structured mid-turn messages, total resolution latency is N × (file_read_time), worst case N × 10s.

Consider resolving in parallel with Promise.allSettled, then processing results in order. The same pattern applies to the CLI path in useGeminiStream.ts:2472.

— qwen3.7-max via Qwen Code /review

const displayText =
message.kind === 'text' ? message.message : message.displayText;
let rawParts: Part[];
try {
rawParts =
message.kind === 'text'
Comment thread
doudouOUC marked this conversation as resolved.
? [{ text: message.message }]
: await withTimeoutSignal(
abortSignal,
MID_TURN_QUEUE_RESOLVE_TIMEOUT_MS,
(signal) => this.#resolvePrompt(message.content, signal),
);
} catch (messageError) {
Comment thread
doudouOUC marked this conversation as resolved.
if (abortSignal.aborted) return drainedParts;
const errorMessage = this.#formatError(messageError);
debugLogger.warn(
Comment thread
doudouOUC marked this conversation as resolved.
`Failed to resolve mid-turn message: ${errorMessage}`,
);
Comment thread
doudouOUC marked this conversation as resolved.
rawParts = [
{
text: displayText,
},
];
if (
message.kind === 'structured' &&
Comment thread
doudouOUC marked this conversation as resolved.
hasInlineMediaContentBlock(message.content)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test verifies the text-only structured message failure path. The existing failure tests ('keeps later structured mid-turn messages when one resolution fails' and 'adds a fallback marker when audio resolution fails') use items with image or audio content blocks, so hasInlineMediaContentBlock always returns true in failure paths.

The branch where it returns false (text-only structured message fails) is untested. A future refactor could accidentally remove the hasInlineMediaContentBlock guard, causing text-only messages to receive a misleading [Attachment could not be processed] marker on resolution failure.

Add a test case where a structured item has only { type: 'text', text: '...' } content blocks and #resolvePrompt throws. Assert the resulting parts contain the prefix + displayText fallback but do NOT contain the failure marker.

— qwen3.7-max via Qwen Code /review

) {
rawParts.push({
text: MID_TURN_ATTACHMENT_PROCESSING_FAILURE_TEXT,
});
}
}
const parts = prefixMidTurnUserMessageParts(rawParts, displayText);
this.config
.getChatRecordingService()
?.recordMidTurnUserMessage([part], message);
return part;
});
?.recordMidTurnUserMessage(parts, displayText);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nice to have] recordMidTurnUserMessage(parts, displayText) now receives Part[] containing inlineData with raw base64 image content. The chat recording service writes this to session JSONL, which could significantly inflate recording file sizes (base64 adds ~33% overhead over raw binary) and embed binary blobs in the session history file.

Consider whether the recording service should strip inlineData from persisted parts (replacing with a placeholder or reference) to keep recording files manageable.

drainedParts.push(...parts);
}

return drainedParts;
} catch (error) {
// The ACP SDK rejects with the raw JSON-RPC error object
// (`{ code, message, data }`), which is not an `Error` instance, so
Expand Down Expand Up @@ -2196,14 +2421,15 @@ export class Session implements SessionContext {
if (toolRun.stopAfterUserQuestionCancel) {
await this.#preserveCancelledAskUserQuestionToolRun(
toolRun,
ac.signal,
);
return;
}
nextMessage = {
role: 'user',
parts: [
...toolRun.parts,
...(await this.#drainMidTurnUserMessages()),
...(await this.#drainMidTurnUserMessages(ac.signal)),
],
};
}
Expand Down Expand Up @@ -2506,15 +2732,18 @@ export class Session implements SessionContext {
functionCalls,
);
if (toolRun.stopAfterUserQuestionCancel) {
await this.#preserveCancelledAskUserQuestionToolRun(toolRun);
await this.#preserveCancelledAskUserQuestionToolRun(
toolRun,
ac.signal,
);
await this.#emitBackgroundNotificationEndTurn('end_turn');
return;
}
nextMessage = {
role: 'user',
parts: [
...toolRun.parts,
...(await this.#drainMidTurnUserMessages()),
...(await this.#drainMidTurnUserMessages(ac.signal)),
],
};
}
Expand Down
Loading
Loading