Skip to content

Commit 67b2f99

Browse files
committed
fix: prevent async rewrite from corrupting adapter state + honor config.model
1. nonInteractiveCli: rewrite promises now return data only, adapter emission happens synchronously via emitSettledRewrites() at safe boundaries (before next turn starts, before cron next turn, before final result). Prevents concurrent startAssistantMessage corruption. 2. LlmRewriter: use rewriteConfig.model when set, fallback to config.getModel(). Previously model field was defined but ignored.
1 parent add9970 commit 67b2f99

File tree

2 files changed

+47
-30
lines changed

2 files changed

+47
-30
lines changed

packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ export class LlmRewriter {
4141
/** Last successful rewrite output, used as context for next turn */
4242
private lastOutput: string | null = null;
4343

44+
private readonly rewriteModel: string | undefined;
45+
4446
constructor(
4547
private readonly config: Config,
4648
rewriteConfig: MessageRewriteConfig,
4749
) {
50+
this.rewriteModel = rewriteConfig.model || undefined;
4851
// promptFile takes precedence over inline prompt
4952
if (rewriteConfig.promptFile) {
5053
const filePath = resolve(rewriteConfig.promptFile);
@@ -105,7 +108,7 @@ export class LlmRewriter {
105108
return null;
106109
}
107110

108-
const model = this.config.getModel();
111+
const model = this.rewriteModel || this.config.getModel();
109112

110113
const result = await contentGenerator.generateContent(
111114
{

packages/cli/src/nonInteractiveCli.ts

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,33 @@ export async function runNonInteractive(
261261
const rewriteTarget = rewriteConfig?.target ?? 'both';
262262
const turnBuffer = rewriter ? new TurnBuffer() : null;
263263
let rewriteTurnIndex = 0;
264-
const pendingRewrites: Array<Promise<void>> = [];
264+
const pendingRewrites: Array<
265+
Promise<{ turnIdx: number; text: string } | null>
266+
> = [];
267+
268+
/**
269+
* Emit all settled rewrite results via the adapter.
270+
* Must be called from the main control flow (not inside async promises)
271+
* to avoid concurrent adapter state corruption.
272+
*/
273+
const emitSettledRewrites = async () => {
274+
if (pendingRewrites.length === 0) return;
275+
const results = await Promise.allSettled(pendingRewrites);
276+
pendingRewrites.length = 0;
277+
for (const r of results) {
278+
if (r.status === 'fulfilled' && r.value) {
279+
adapter.startAssistantMessage();
280+
adapter.processEvent({
281+
type: GeminiEventType.Content,
282+
value: r.value.text,
283+
_meta: { rewritten: true, turnIndex: r.value.turnIdx },
284+
} as unknown as Parameters<
285+
JsonOutputAdapterInterface['processEvent']
286+
>[0]);
287+
adapter.finalizeAssistantMessage();
288+
}
289+
}
290+
};
265291

266292
if (rewriter) {
267293
debugLogger.info('Message rewrite enabled in non-interactive mode');
@@ -277,6 +303,9 @@ export async function runNonInteractive(
277303
handleMaxTurnsExceededError(config);
278304
}
279305

306+
// Emit any settled rewrites before starting the next turn
307+
await emitSettledRewrites();
308+
280309
const toolCallRequests: ToolCallRequestInfo[] = [];
281310
const apiStartTime = Date.now();
282311
const responseStream = geminiClient.sendMessageStream(
@@ -344,7 +373,9 @@ export async function runNonInteractive(
344373
adapter.finalizeAssistantMessage();
345374
totalApiDurationMs += Date.now() - apiStartTime;
346375

347-
// Rewrite turn content (async, parallel with tool execution)
376+
// Rewrite turn content (async, parallel with tool execution).
377+
// Only collects rewritten text — emission happens at safe boundaries
378+
// via emitSettledRewrites() to avoid concurrent adapter state corruption.
348379
if (rewriter && turnBuffer) {
349380
const content = turnBuffer.flush();
350381
if (content) {
@@ -365,21 +396,14 @@ export async function runNonInteractive(
365396
debugLogger.info(
366397
`Turn ${turnIdx}: rewritten ${rewritten.length} chars`,
367398
);
368-
adapter.startAssistantMessage();
369-
adapter.processEvent({
370-
type: GeminiEventType.Content,
371-
value: rewritten,
372-
_meta: { rewritten: true, turnIndex: turnIdx },
373-
} as unknown as Parameters<
374-
JsonOutputAdapterInterface['processEvent']
375-
>[0]);
376-
adapter.finalizeAssistantMessage();
399+
return { turnIdx, text: rewritten };
377400
}
378401
} catch (err) {
379402
debugLogger.warn(
380403
`Turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`,
381404
);
382405
}
406+
return null;
383407
})(),
384408
);
385409
}
@@ -544,7 +568,7 @@ export async function runNonInteractive(
544568
adapter.finalizeAssistantMessage();
545569
totalApiDurationMs += Date.now() - cronApiStartTime;
546570

547-
// Flush turn buffer and rewrite for cron path (async)
571+
// Flush turn buffer and rewrite for cron path (async, collect only)
548572
if (rewriter && turnBuffer) {
549573
const content = turnBuffer.flush();
550574
if (content) {
@@ -565,29 +589,22 @@ export async function runNonInteractive(
565589
debugLogger.info(
566590
`Cron turn ${turnIdx}: rewritten ${rewritten.length} chars`,
567591
);
568-
adapter.startAssistantMessage();
569-
adapter.processEvent({
570-
type: GeminiEventType.Content,
571-
value: rewritten,
572-
_meta: {
573-
rewritten: true,
574-
turnIndex: turnIdx,
575-
},
576-
} as unknown as Parameters<
577-
JsonOutputAdapterInterface['processEvent']
578-
>[0]);
579-
adapter.finalizeAssistantMessage();
592+
return { turnIdx, text: rewritten };
580593
}
581594
} catch (err) {
582595
debugLogger.warn(
583596
`Cron turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`,
584597
);
585598
}
599+
return null;
586600
})(),
587601
);
588602
}
589603
}
590604

605+
// Emit settled rewrites before next cron turn
606+
await emitSettledRewrites();
607+
591608
if (cronToolCallRequests.length > 0) {
592609
const cronToolResponseParts: Part[] = [];
593610

@@ -654,11 +671,8 @@ export async function runNonInteractive(
654671
});
655672
}
656673

657-
// Wait for all pending async rewrites before emitting result
658-
if (pendingRewrites.length > 0) {
659-
await Promise.allSettled(pendingRewrites);
660-
pendingRewrites.length = 0;
661-
}
674+
// Emit all remaining rewrites before emitting result
675+
await emitSettledRewrites();
662676

663677
const metrics = uiTelemetryService.getMetrics();
664678
const usage = computeUsageFromMetrics(metrics);

0 commit comments

Comments
 (0)