Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 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
634 changes: 631 additions & 3 deletions packages/cli/src/acp-integration/session/Session.test.ts

Large diffs are not rendered by default.

271 changes: 243 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,193 @@ 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 MID_TURN_IMAGE_PROCESSING_FAILURE_TEXT =
'[Image attachment could not be processed]';
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.
Resolved
case 'image':
return (
typeof value['mimeType'] === 'string' &&
value['mimeType'].startsWith('image/') &&
typeof value['data'] === 'string'
);
case 'audio':
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
return (
typeof value['mimeType'] === 'string' &&
value['mimeType'].startsWith('audio/') &&
typeof value['data'] === 'string'
);
case 'resource_link':
return false;
case 'resource':
return isEmbeddedResourceResource(value['resource']);
default:
debugLogger.warn(`Unknown ContentBlock type: ${value['type']}`);
return false;
}
}

async function withTimeoutSignal<T>(
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]);
} finally {
if (rejectOnAbort) signal.removeEventListener('abort', rejectOnAbort);
}
}

function isEmbeddedResourceResource(
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';
}

function hasImageContentBlock(content: ContentBlock[]): boolean {
return content.some((part) => part.type === 'image');
}

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.
Resolved
if (!isRecord(response)) return [];

if (Array.isArray(response['items'])) {
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
return response['items'].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 response['messages']
.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 +1516,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 +1788,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 +1963,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 +2084,7 @@ export class Session implements SessionContext {
});
}

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

let drainPromise: ReturnType<AgentSideConnection['extMethod']> | undefined;
Expand All @@ -1914,28 +2106,47 @@ 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) {

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.
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.
Resolved
if (abortSignal.aborted) return drainedParts;
const errorMessage = this.#formatError(messageError);
debugLogger.warn(
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
`Failed to resolve mid-turn message: ${errorMessage}`,
);
rawParts = [
{
text: displayText,
},
];
if (
message.kind === 'structured' &&
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
hasImageContentBlock(message.content)
) {
rawParts.push({ text: MID_TURN_IMAGE_PROCESSING_FAILURE_TEXT });
}
}
const parts = prefixMidTurnUserMessageParts(rawParts, displayText);
this.config
.getChatRecordingService()
?.recordMidTurnUserMessage([part], message);
return part;
});
?.recordMidTurnUserMessage(parts, displayText);
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 +2407,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 +2718,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