From 8b16bfafc81bbda6533f8c6cdd8274649422af5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 10:41:11 +0800 Subject: [PATCH 01/25] feat(acp): LLM-based message rewrite middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MessageRewriteMiddleware that intercepts ACP messages and appends LLM-rewritten versions with _meta.rewritten=true at turn boundaries. Original messages pass through unmodified. At the end of each turn (before tool calls or at response end), accumulated thought/message chunks are sent to LLM for rewriting into business-friendly text. - TurnBuffer: accumulates chunks per turn - LlmRewriter: calls LLM with configurable prompt - MessageRewriteMiddleware: orchestrates intercept → buffer → rewrite → emit - BaseEmitter.sendUpdate: routes through middleware when configured - Session: initializes middleware from settings.messageRewrite config Enable via settings.json: { "messageRewrite": { "enabled": true, "target": "both", "prompt": "custom system prompt for rewriter" } } Rewritten messages carry _meta.rewritten=true for frontend to prioritize display. Original messages remain for debugging. --- .../src/acp-integration/session/Session.ts | 24 ++++ .../session/emitters/BaseEmitter.ts | 5 + .../session/rewrite/LlmRewriter.ts | 114 +++++++++++++++ .../rewrite/MessageRewriteMiddleware.ts | 133 ++++++++++++++++++ .../session/rewrite/TurnBuffer.ts | 62 ++++++++ .../acp-integration/session/rewrite/index.ts | 8 ++ .../acp-integration/session/rewrite/types.ts | 29 ++++ .../cli/src/acp-integration/session/types.ts | 3 + 8 files changed, 378 insertions(+) create mode 100644 packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts create mode 100644 packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts create mode 100644 packages/cli/src/acp-integration/session/rewrite/TurnBuffer.ts create mode 100644 packages/cli/src/acp-integration/session/rewrite/index.ts create mode 100644 packages/cli/src/acp-integration/session/rewrite/types.ts diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 1871e0f3fad..2b4283ba124 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -88,6 +88,10 @@ import { buildPermissionRequestContent, toPermissionOptions, } from './permissionUtils.js'; +import { + MessageRewriteMiddleware, + type MessageRewriteConfig, +} from './rewrite/index.js'; const debugLogger = createDebugLogger('SESSION'); @@ -124,6 +128,9 @@ export class Session implements SessionContext { private readonly planEmitter: PlanEmitter; private readonly messageEmitter: MessageEmitter; + // Message rewrite middleware (optional) + readonly messageRewriter?: MessageRewriteMiddleware; + // Implement SessionContext interface readonly sessionId: string; @@ -137,6 +144,18 @@ export class Session implements SessionContext { this.sessionId = id; this.runtimeBaseDir = Storage.getRuntimeBaseDir(); + // Initialize message rewrite middleware if configured + const rewriteConfig = (settings as Record) + .messageRewrite as MessageRewriteConfig | undefined; + if (rewriteConfig?.enabled) { + debugLogger.info('Message rewrite middleware enabled'); + this.messageRewriter = new MessageRewriteMiddleware( + config, + rewriteConfig, + (update) => this.sendUpdate(update), + ); + } + // Initialize modular components with this session as context this.toolCallEmitter = new ToolCallEmitter(this); this.planEmitter = new PlanEmitter(this); @@ -391,6 +410,11 @@ export class Session implements SessionContext { } if (usageMetadata) { + // Flush rewrite buffer before emitting usage (marks turn boundary) + if (this.messageRewriter) { + await this.messageRewriter.flushTurn(pendingSend.signal); + } + const durationMs = Date.now() - streamStartTime; await this.messageEmitter.emitUsageMetadata( usageMetadata, diff --git a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts index dd752968679..fbd4d274c2e 100644 --- a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts @@ -31,8 +31,13 @@ export abstract class BaseEmitter { /** * Sends a session update to the ACP client. + * If a message rewriter is configured, updates pass through it first + * (original messages are sent as-is, rewritten versions are appended). */ protected async sendUpdate(update: SessionUpdate): Promise { + if (this.ctx.messageRewriter) { + return this.ctx.messageRewriter.interceptUpdate(update); + } return this.ctx.sendUpdate(update); } diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts new file mode 100644 index 00000000000..d2693ece0ee --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import type { TurnContent, MessageRewriteConfig } from './types.js'; + +const debugLogger = createDebugLogger('MESSAGE_REWRITER'); + +const DEFAULT_REWRITE_PROMPT = `你是 ACP 消息改写助手。将 Agent 的原始输出改写为业务人员可读的版本。 + +规则: +- 保留:分析结论、数据发现、计算口径说明、建议、表格数据 +- 过滤:文件路径、工具名称、SQL 语句、代码片段、技术调试信息、QWEN.md 指令复述 +- 如果输入是纯技术操作(修复错误、创建目录、读取文件等),输出空字符串 +- 风格:简洁、结论先行、中文 +- 保持数据准确性,不要改写数字 + +只输出改写后的文本,不要解释。如果无有价值内容可输出,返回空字符串。`; + +/** + * Uses LLM to rewrite turn content into business-friendly text. + * Called at the end of each model turn (after all chunks accumulated). + */ +export class LlmRewriter { + private readonly prompt: string; + + constructor( + private readonly config: Config, + rewriteConfig: MessageRewriteConfig, + ) { + this.prompt = rewriteConfig.prompt || DEFAULT_REWRITE_PROMPT; + } + + /** + * Rewrite a turn's content using LLM. + * Returns null if the turn has no valuable content for users. + */ + async rewrite( + turnContent: TurnContent, + signal?: AbortSignal, + ): Promise { + // Build input text from turn content + const inputParts: string[] = []; + + if (turnContent.thoughts.length > 0) { + inputParts.push( + '[内部推理]\n' + turnContent.thoughts.join('\n'), + ); + } + if (turnContent.messages.length > 0) { + inputParts.push( + '[回复文本]\n' + turnContent.messages.join('\n'), + ); + } + + const inputText = inputParts.join('\n\n'); + if (!inputText.trim()) return null; + + // Skip very short turns that are likely just transitions + if (inputText.length < 10) return null; + + try { + const contentGenerator = this.config.getContentGenerator(); + if (!contentGenerator) { + debugLogger.warn('No content generator available for rewriting'); + return null; + } + + const model = + this.config.getSmallFastModel?.() || this.config.getModel(); + + const result = await contentGenerator.generateContent( + { + model, + config: { + systemInstruction: this.prompt, + abortSignal: signal, + temperature: 0.3, + maxOutputTokens: 1024, + }, + contents: [ + { + role: 'user', + parts: [{ text: inputText }], + }, + ], + }, + `rewrite-turn`, + ); + + const rewritten = + result.candidates?.[0]?.content?.parts + ?.map((p) => p.text) + .filter(Boolean) + .join('') ?? ''; + + // If LLM returns empty or very short, skip + if (!rewritten.trim() || rewritten.trim().length < 5) { + return null; + } + + return rewritten.trim(); + } catch (error) { + debugLogger.warn( + `LLM rewrite failed, skipping: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } +} diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts new file mode 100644 index 00000000000..4c1639f527f --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import type { MessageRewriteConfig } from './types.js'; +import { TurnBuffer } from './TurnBuffer.js'; +import { LlmRewriter } from './LlmRewriter.js'; + +const debugLogger = createDebugLogger('MESSAGE_REWRITE'); + +/** + * Middleware that intercepts ACP messages and appends LLM-rewritten + * versions with _meta.rewritten=true. + * + * Original messages are sent as-is (no modification). + * At the end of each turn, a rewritten message is appended. + * + * Flow: + * 1. Original chunks pass through unmodified + * 2. Chunks are accumulated in TurnBuffer + * 3. When a turn ends (tool_call starts, or session ends), + * LlmRewriter rewrites the accumulated content + * 4. Rewritten text is emitted as agent_message_chunk with _meta.rewritten=true + */ +export class MessageRewriteMiddleware { + private readonly turnBuffer: TurnBuffer; + private readonly rewriter: LlmRewriter; + private readonly target: MessageRewriteConfig['target']; + private turnIndex = 0; + + constructor( + config: Config, + private readonly rewriteConfig: MessageRewriteConfig, + private readonly sendUpdate: (update: SessionUpdate) => Promise, + ) { + this.turnBuffer = new TurnBuffer(); + this.rewriter = new LlmRewriter(config, rewriteConfig); + this.target = rewriteConfig.target; + } + + /** + * Intercept an ACP update. Original messages pass through, + * thought/message chunks are also accumulated for turn-end rewriting. + */ + async interceptUpdate( + update: SessionUpdate, + signal?: AbortSignal, + ): Promise { + const updateType = (update as Record) + .sessionUpdate as string; + + // tool_call signals turn boundary — flush before passing through + if (updateType === 'tool_call') { + await this.flushTurn(signal); + this.turnBuffer.markToolCall(); + return this.sendUpdate(update); + } + + // tool_call_update, plan, available_commands, etc. → pass through + if ( + updateType !== 'agent_thought_chunk' && + updateType !== 'agent_message_chunk' + ) { + return this.sendUpdate(update); + } + + const text = + ((update as Record).content as Record) + ?.text ?? ''; + + // Always send original message as-is + await this.sendUpdate(update); + + // Accumulate for turn-end rewriting + if (updateType === 'agent_thought_chunk') { + if (this.target === 'thought' || this.target === 'both') { + this.turnBuffer.appendThought(text); + } + } else if (updateType === 'agent_message_chunk') { + if (this.target === 'message' || this.target === 'both') { + this.turnBuffer.appendMessage(text); + } + } + } + + /** + * Flush the turn buffer: rewrite accumulated content and emit + * a rewritten message with _meta.rewritten=true. + * + * Called when: + * - A tool_call is about to be emitted (turn boundary) + * - Usage metadata is emitted (end of model response) + * - Session prompt ends + */ + async flushTurn(signal?: AbortSignal): Promise { + const content = this.turnBuffer.flush(); + if (!content) return; + + this.turnIndex++; + + try { + const rewritten = await this.rewriter.rewrite(content, signal); + if (!rewritten) { + debugLogger.info(`Turn ${this.turnIndex}: no rewrite output`); + return; + } + + debugLogger.info( + `Turn ${this.turnIndex}: rewritten ${rewritten.length} chars`, + ); + + // Emit rewritten message with special _meta + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: rewritten }, + _meta: { + rewritten: true, + turnIndex: this.turnIndex, + }, + } as SessionUpdate); + } catch (error) { + debugLogger.warn( + `Turn ${this.turnIndex}: rewrite failed: ${error instanceof Error ? error.message : String(error)}`, + ); + // On failure, original messages already sent — just skip rewrite + } + } +} diff --git a/packages/cli/src/acp-integration/session/rewrite/TurnBuffer.ts b/packages/cli/src/acp-integration/session/rewrite/TurnBuffer.ts new file mode 100644 index 00000000000..7ab66fe3ec5 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/TurnBuffer.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TurnContent } from './types.js'; + +/** + * Accumulates thought and message chunks for a single model turn. + * A turn ends when tool calls begin or the model stops generating. + */ +export class TurnBuffer { + private thoughts: string[] = []; + private messages: string[] = []; + private _hasToolCalls = false; + + appendThought(text: string): void { + if (text) this.thoughts.push(text); + } + + appendMessage(text: string): void { + if (text) this.messages.push(text); + } + + markToolCall(): void { + this._hasToolCalls = true; + } + + /** + * Returns accumulated content and resets the buffer. + * Returns null if buffer is empty. + */ + flush(): TurnContent | null { + const thoughtText = this.thoughts.join(''); + const messageText = this.messages.join(''); + + if (!thoughtText.trim() && !messageText.trim()) { + this.reset(); + return null; + } + + const content: TurnContent = { + thoughts: this.thoughts.filter((t) => t.trim()), + messages: this.messages.filter((m) => m.trim()), + hasToolCalls: this._hasToolCalls, + }; + + this.reset(); + return content; + } + + private reset(): void { + this.thoughts = []; + this.messages = []; + this._hasToolCalls = false; + } + + get isEmpty(): boolean { + return this.thoughts.length === 0 && this.messages.length === 0; + } +} diff --git a/packages/cli/src/acp-integration/session/rewrite/index.ts b/packages/cli/src/acp-integration/session/rewrite/index.ts new file mode 100644 index 00000000000..8b98f0b1202 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { MessageRewriteMiddleware } from './MessageRewriteMiddleware.js'; +export type { MessageRewriteConfig, TurnContent } from './types.js'; diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts new file mode 100644 index 00000000000..cce740f21c7 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Configuration for ACP message rewriting. + * Loaded from .qwen/settings.json under "messageRewrite" key. + */ +export interface MessageRewriteConfig { + /** Whether message rewriting is enabled */ + enabled: boolean; + /** Which message types to rewrite */ + target: 'message' | 'thought' | 'both'; + /** LLM rewrite prompt (system prompt for the rewriter) */ + prompt: string; + /** Model to use for rewriting (empty = use current model) */ + model?: string; +} + +/** + * Accumulated content for a single turn. + */ +export interface TurnContent { + thoughts: string[]; + messages: string[]; + hasToolCalls: boolean; +} diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index ea75c06b574..75cff5b8255 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -11,6 +11,7 @@ import type { ToolCallLocation, ToolKind, } from '@agentclientprotocol/sdk'; +import type { MessageRewriteMiddleware } from './rewrite/index.js'; export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; @@ -29,6 +30,8 @@ export interface SessionUpdateSender { export interface SessionContext extends SessionUpdateSender { readonly sessionId: string; readonly config: Config; + /** Optional message rewrite middleware for ACP message transformation */ + readonly messageRewriter?: MessageRewriteMiddleware; } /** From 554b7a1a0f4a587dc86af6669ce3854a36195373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 10:51:05 +0800 Subject: [PATCH 02/25] =?UTF-8?q?fix:=20TypeScript=20=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BF=AE=E5=A4=8D=20+=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=94=B9=E5=86=99=20prompt=EF=BC=88=E5=8F=82?= =?UTF-8?q?=E8=80=83=E7=AB=9E=E5=93=81=E9=A3=8E=E6=A0=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 1 - .../src/acp-integration/session/Session.ts | 5 +- .../session/rewrite/LlmRewriter.ts | 51 +++++++++++++------ .../rewrite/MessageRewriteMiddleware.ts | 13 ++--- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e211f6fc56..33f01cb65b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12850,7 +12850,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2b4283ba124..00d6f41ae3d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -145,8 +145,9 @@ export class Session implements SessionContext { this.runtimeBaseDir = Storage.getRuntimeBaseDir(); // Initialize message rewrite middleware if configured - const rewriteConfig = (settings as Record) - .messageRewrite as MessageRewriteConfig | undefined; + const rewriteConfig = (settings as unknown as Record)[ + 'messageRewrite' + ] as MessageRewriteConfig | undefined; if (rewriteConfig?.enabled) { debugLogger.info('Message rewrite middleware enabled'); this.messageRewriter = new MessageRewriteMiddleware( diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index d2693ece0ee..527d5b034c8 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -10,16 +10,40 @@ import type { TurnContent, MessageRewriteConfig } from './types.js'; const debugLogger = createDebugLogger('MESSAGE_REWRITER'); -const DEFAULT_REWRITE_PROMPT = `你是 ACP 消息改写助手。将 Agent 的原始输出改写为业务人员可读的版本。 +const DEFAULT_REWRITE_PROMPT = `你是数据分析过程的展示优化助手。将 Agent 的原始输出改写为结构化的、面向业务同学的分析过程展示。 -规则: -- 保留:分析结论、数据发现、计算口径说明、建议、表格数据 -- 过滤:文件路径、工具名称、SQL 语句、代码片段、技术调试信息、QWEN.md 指令复述 -- 如果输入是纯技术操作(修复错误、创建目录、读取文件等),输出空字符串 -- 风格:简洁、结论先行、中文 -- 保持数据准确性,不要改写数字 +## 输出风格参考 -只输出改写后的文本,不要解释。如果无有价值内容可输出,返回空字符串。`; +用简洁的要点列表展示分析过程,让业务同学清晰看到"在做什么、怎么做、发现了什么": + +示例——数据理解阶段: +"数据理解完成。数据记录了全球多个游戏的销售数据及评分信息,涵盖游戏名称、平台、类型、发行商等维度。 +• 涵盖游戏基本信息(名称、平台、类型、发行年份等)及全球销量 +• 各地区销量(北美、欧洲、日本、其他地区) +• 媒体评分与用户评分数量" + +示例——分析执行阶段: +"策略类游戏市场基本面分析 +• 清洗数据:将 'tbd' 替换为空值,确保评分字段为数值型 +• 计算策略类游戏的平均媒体评分与平均用户评分,并与全品类均值对比 +• 分析评分与销量的相关性(如高分是否带动高销量),绘制评分-销量散点图并计算相关系数 +• 识别'高分低销'与'低分高销'的异常游戏案例,初步推测原因" + +示例——结论阶段: +"Central 地区盈利能力最差 +• 利润率仅 7.92%,是 West 地区(14.94%)的一半 +• 核心原因:折扣策略失控,平均折扣 24%,是其他地区的 2 倍 +• Texas 和 Illinois 两州合计亏损占 68%" + +## 规则 + +1. **保留的内容**:数据概览、分析模块名称和目标、计算口径(如"利润率 = SUM(利润)/SUM(销售额)")、分析方法选择原因、数据发现和洞察(含具体数字)、结论、建议、表格 +2. **过滤的内容**:文件路径、工具/Skill 名称、SQL 语句、Python 代码、技术报错信息、QWEN.md/工作流指令复述、"让我..."/"现在我来..."等自述性过渡语 +3. **纯技术操作**(修复代码错误、创建目录、安装依赖等)→ 输出空字符串 +4. **数据准确性**:不要改写任何数字、百分比、金额,原样保留 +5. **语言**:中文,简洁,用要点列表(•)组织 + +只输出改写后的文本。如果输入无业务价值,返回空字符串。`; /** * Uses LLM to rewrite turn content into business-friendly text. @@ -47,14 +71,10 @@ export class LlmRewriter { const inputParts: string[] = []; if (turnContent.thoughts.length > 0) { - inputParts.push( - '[内部推理]\n' + turnContent.thoughts.join('\n'), - ); + inputParts.push('[内部推理]\n' + turnContent.thoughts.join('\n')); } if (turnContent.messages.length > 0) { - inputParts.push( - '[回复文本]\n' + turnContent.messages.join('\n'), - ); + inputParts.push('[回复文本]\n' + turnContent.messages.join('\n')); } const inputText = inputParts.join('\n\n'); @@ -70,8 +90,7 @@ export class LlmRewriter { return null; } - const model = - this.config.getSmallFastModel?.() || this.config.getModel(); + const model = this.config.getModel(); const result = await contentGenerator.generateContent( { diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index 4c1639f527f..0a765966477 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -35,7 +35,7 @@ export class MessageRewriteMiddleware { constructor( config: Config, - private readonly rewriteConfig: MessageRewriteConfig, + rewriteConfig: MessageRewriteConfig, private readonly sendUpdate: (update: SessionUpdate) => Promise, ) { this.turnBuffer = new TurnBuffer(); @@ -51,8 +51,8 @@ export class MessageRewriteMiddleware { update: SessionUpdate, signal?: AbortSignal, ): Promise { - const updateType = (update as Record) - .sessionUpdate as string; + const updateRecord = update as Record; + const updateType = updateRecord['sessionUpdate'] as string; // tool_call signals turn boundary — flush before passing through if (updateType === 'tool_call') { @@ -69,9 +69,10 @@ export class MessageRewriteMiddleware { return this.sendUpdate(update); } - const text = - ((update as Record).content as Record) - ?.text ?? ''; + const content = updateRecord['content'] as + | Record + | undefined; + const text = content?.['text'] ?? ''; // Always send original message as-is await this.sendUpdate(update); From fbb889921c82f67a7d02d6250109003a51631853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 11:17:56 +0800 Subject: [PATCH 03/25] =?UTF-8?q?fix:=20=E4=BB=8E=20user/workspace=20origi?= =?UTF-8?q?nalSettings=20=E8=AF=BB=E5=8F=96=20messageRewrite=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=88=E7=BB=95=E8=BF=87=20schema=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/acp-integration/session/Session.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 00d6f41ae3d..1c89199354e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -145,9 +145,15 @@ export class Session implements SessionContext { this.runtimeBaseDir = Storage.getRuntimeBaseDir(); // Initialize message rewrite middleware if configured - const rewriteConfig = (settings as unknown as Record)[ - 'messageRewrite' - ] as MessageRewriteConfig | undefined; + // Read from user settings originalSettings (merged Settings type doesn't include unknown keys) + const userOriginal = settings.user?.originalSettings as + | Record + | undefined; + const workspaceOriginal = settings.workspace?.originalSettings as + | Record + | undefined; + const rewriteConfig = (workspaceOriginal?.['messageRewrite'] ?? + userOriginal?.['messageRewrite']) as MessageRewriteConfig | undefined; if (rewriteConfig?.enabled) { debugLogger.info('Message rewrite middleware enabled'); this.messageRewriter = new MessageRewriteMiddleware( From 56706f69cc8f5bcea4f4bd0c7108fe85f7eadfd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 11:36:18 +0800 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20=E9=9D=9E=E4=BA=A4=E4=BA=92=20CLI?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=E4=B9=9F=E6=94=AF=E6=8C=81=20message=20re?= =?UTF-8?q?write=EF=BC=88eval=20=E5=8F=AF=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/nonInteractiveCli.ts | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index bbd7fab7d83..298afd3716d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -46,6 +46,9 @@ import { createAgentToolProgressHandler, computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; +import { TurnBuffer } from './acp-integration/session/rewrite/TurnBuffer.js'; +import { LlmRewriter } from './acp-integration/session/rewrite/LlmRewriter.js'; +import type { MessageRewriteConfig } from './acp-integration/session/rewrite/types.js'; /** * Emits a final message for slash command results. @@ -250,6 +253,25 @@ export async function runNonInteractive( const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; + // Initialize message rewriter if configured + const userOriginal = settings.user?.originalSettings as + | Record + | undefined; + const workspaceOriginal = settings.workspace?.originalSettings as + | Record + | undefined; + const rewriteConfig = (workspaceOriginal?.['messageRewrite'] ?? + userOriginal?.['messageRewrite']) as MessageRewriteConfig | undefined; + const rewriter = rewriteConfig?.enabled + ? new LlmRewriter(config, rewriteConfig) + : null; + const turnBuffer = rewriter ? new TurnBuffer() : null; + let rewriteTurnIndex = 0; + + if (rewriter) { + debugLogger.info('Message rewrite enabled in non-interactive mode'); + } + let isFirstTurn = true; let modelOverride: string | undefined; while (true) { @@ -285,6 +307,23 @@ export async function runNonInteractive( } // Use adapter for all event processing adapter.processEvent(event); + + // Accumulate for turn-end rewriting + if (turnBuffer) { + if ( + event.type === GeminiEventType.Content && + typeof event.value === 'string' + ) { + turnBuffer.appendMessage(event.value); + } else if (event.type === GeminiEventType.Thought && event.value) { + const thought = event.value; + const thoughtText = thought.subject + ? `${thought.subject}: ${thought.description}` + : thought.description; + if (thoughtText) turnBuffer.appendThought(thoughtText); + } + } + if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); } @@ -306,6 +345,39 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - apiStartTime; + // Flush turn buffer and append rewritten message + if (rewriter && turnBuffer) { + const content = turnBuffer.flush(); + if (content) { + rewriteTurnIndex++; + try { + const rewritten = await rewriter.rewrite( + content, + abortController?.signal, + ); + if (rewritten) { + debugLogger.info( + `Turn ${rewriteTurnIndex}: rewritten ${rewritten.length} chars`, + ); + // Emit rewritten as a separate assistant message with _meta.rewritten + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: rewritten, + _meta: { rewritten: true, turnIndex: rewriteTurnIndex }, + } as unknown as Parameters< + JsonOutputAdapterInterface['processEvent'] + >[0]); + adapter.finalizeAssistantMessage(); + } + } catch (err) { + debugLogger.warn( + `Turn ${rewriteTurnIndex}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + if (toolCallRequests.length > 0) { const toolResponseParts: Part[] = []; From 937369c2bbff2e72bc968e1c1b5b67cad1e0a8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 12:01:54 +0800 Subject: [PATCH 05/25] =?UTF-8?q?fix:=20=E7=A6=81=E7=94=A8=20rewriter=20LL?= =?UTF-8?q?M=20=E7=9A=84=20thinking=EF=BC=8C=E8=BF=87=E6=BB=A4=20thought?= =?UTF-8?q?=20=E9=83=A8=E5=88=86=E5=8F=AA=E5=8F=96=E7=BA=AF=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/src/acp-integration/session/rewrite/LlmRewriter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index 527d5b034c8..9bd1f66e589 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -100,6 +100,8 @@ export class LlmRewriter { abortSignal: signal, temperature: 0.3, maxOutputTokens: 1024, + // Disable thinking to avoid thinking leaking into output + thinkingConfig: { includeThoughts: false }, }, contents: [ { @@ -111,9 +113,11 @@ export class LlmRewriter { `rewrite-turn`, ); + // Extract only non-thought text parts const rewritten = result.candidates?.[0]?.content?.parts - ?.map((p) => p.text) + ?.filter((p) => !p.thought) + .map((p) => p.text) .filter(Boolean) .join('') ?? ''; From 65a00ea01ac4d09a71f9e2be0f44c3e98bb23804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 13:54:12 +0800 Subject: [PATCH 06/25] =?UTF-8?q?fix:=20cron=20=E8=B7=AF=E5=BE=84=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20message=20rewrite=20flush=20+=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=B4=A8=E9=87=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session.ts cron 路径添加 messageRewriter.flushTurn() 调用 - nonInteractiveCli.ts cron 路径添加 turnBuffer 累积 + flush + rewrite - 提取 loadRewriteConfig() 共享函数,消除两处重复配置读取 - 主路径和 cron 路径添加 turnBuffer.markToolCall() - rewrite 调用添加 30s 超时保护(AbortSignal.timeout) - 修复 import 语句被 const 声明分割的问题 --- .../src/acp-integration/session/Session.ts | 16 ++-- .../acp-integration/session/rewrite/config.ts | 25 ++++++ .../acp-integration/session/rewrite/index.ts | 1 + packages/cli/src/nonInteractiveCli.ts | 86 +++++++++++++++---- 4 files changed, 103 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/acp-integration/session/rewrite/config.ts diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 1c89199354e..1ca8dab2171 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -90,7 +90,7 @@ import { } from './permissionUtils.js'; import { MessageRewriteMiddleware, - type MessageRewriteConfig, + loadRewriteConfig, } from './rewrite/index.js'; const debugLogger = createDebugLogger('SESSION'); @@ -145,15 +145,7 @@ export class Session implements SessionContext { this.runtimeBaseDir = Storage.getRuntimeBaseDir(); // Initialize message rewrite middleware if configured - // Read from user settings originalSettings (merged Settings type doesn't include unknown keys) - const userOriginal = settings.user?.originalSettings as - | Record - | undefined; - const workspaceOriginal = settings.workspace?.originalSettings as - | Record - | undefined; - const rewriteConfig = (workspaceOriginal?.['messageRewrite'] ?? - userOriginal?.['messageRewrite']) as MessageRewriteConfig | undefined; + const rewriteConfig = loadRewriteConfig(settings); if (rewriteConfig?.enabled) { debugLogger.info('Message rewrite middleware enabled'); this.messageRewriter = new MessageRewriteMiddleware( @@ -591,6 +583,10 @@ export class Session implements SessionContext { } if (usageMetadata) { + // Flush rewrite buffer before emitting usage (marks turn boundary) + if (this.messageRewriter) { + await this.messageRewriter.flushTurn(ac.signal); + } const durationMs = Date.now() - streamStartTime; await this.messageEmitter.emitUsageMetadata( usageMetadata, diff --git a/packages/cli/src/acp-integration/session/rewrite/config.ts b/packages/cli/src/acp-integration/session/rewrite/config.ts new file mode 100644 index 00000000000..05107e800e3 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/config.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { LoadedSettings } from '../../../config/settings.js'; +import type { MessageRewriteConfig } from './types.js'; + +/** + * Reads messageRewrite configuration from user/workspace originalSettings. + * Workspace settings take precedence over user settings. + */ +export function loadRewriteConfig( + settings: LoadedSettings, +): MessageRewriteConfig | undefined { + const userOriginal = settings.user?.originalSettings as + | Record + | undefined; + const workspaceOriginal = settings.workspace?.originalSettings as + | Record + | undefined; + return (workspaceOriginal?.['messageRewrite'] ?? + userOriginal?.['messageRewrite']) as MessageRewriteConfig | undefined; +} diff --git a/packages/cli/src/acp-integration/session/rewrite/index.ts b/packages/cli/src/acp-integration/session/rewrite/index.ts index 8b98f0b1202..40b18c241f7 100644 --- a/packages/cli/src/acp-integration/session/rewrite/index.ts +++ b/packages/cli/src/acp-integration/session/rewrite/index.ts @@ -5,4 +5,5 @@ */ export { MessageRewriteMiddleware } from './MessageRewriteMiddleware.js'; +export { loadRewriteConfig } from './config.js'; export type { MessageRewriteConfig, TurnContent } from './types.js'; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 298afd3716d..7331ef150a5 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -36,8 +36,6 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; - -const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); import { normalizePartList, extractPartsFromUserMessage, @@ -48,7 +46,9 @@ import { } from './utils/nonInteractiveHelpers.js'; import { TurnBuffer } from './acp-integration/session/rewrite/TurnBuffer.js'; import { LlmRewriter } from './acp-integration/session/rewrite/LlmRewriter.js'; -import type { MessageRewriteConfig } from './acp-integration/session/rewrite/types.js'; +import { loadRewriteConfig } from './acp-integration/session/rewrite/config.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); /** * Emits a final message for slash command results. @@ -254,14 +254,7 @@ export async function runNonInteractive( let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; // Initialize message rewriter if configured - const userOriginal = settings.user?.originalSettings as - | Record - | undefined; - const workspaceOriginal = settings.workspace?.originalSettings as - | Record - | undefined; - const rewriteConfig = (workspaceOriginal?.['messageRewrite'] ?? - userOriginal?.['messageRewrite']) as MessageRewriteConfig | undefined; + const rewriteConfig = loadRewriteConfig(settings); const rewriter = rewriteConfig?.enabled ? new LlmRewriter(config, rewriteConfig) : null; @@ -326,6 +319,7 @@ export async function runNonInteractive( if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); + if (turnBuffer) turnBuffer.markToolCall(); } if ( outputFormat === OutputFormat.TEXT && @@ -351,10 +345,11 @@ export async function runNonInteractive( if (content) { rewriteTurnIndex++; try { - const rewritten = await rewriter.rewrite( - content, - abortController?.signal, - ); + const rewriteSignal = AbortSignal.any([ + abortController.signal, + AbortSignal.timeout(30000), + ]); + const rewritten = await rewriter.rewrite(content, rewriteSignal); if (rewritten) { debugLogger.info( `Turn ${rewriteTurnIndex}: rewritten ${rewritten.length} chars`, @@ -512,14 +507,75 @@ export async function runNonInteractive( return; } adapter.processEvent(event); + + // Accumulate turn content for rewriting + if (turnBuffer) { + if ( + event.type === GeminiEventType.Content && + typeof event.value === 'string' + ) { + turnBuffer.appendMessage(event.value); + } else if ( + event.type === GeminiEventType.Thought && + event.value + ) { + const thought = event.value; + const thoughtText = thought.subject + ? `${thought.subject}: ${thought.description}` + : thought.description; + if (thoughtText) + turnBuffer.appendThought(thoughtText); + } + } + if (event.type === GeminiEventType.ToolCallRequest) { cronToolCallRequests.push(event.value); + if (turnBuffer) turnBuffer.markToolCall(); } } adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - cronApiStartTime; + // Flush turn buffer and append rewritten message for cron path + if (rewriter && turnBuffer) { + const content = turnBuffer.flush(); + if (content) { + rewriteTurnIndex++; + try { + const rewriteSignal = AbortSignal.any([ + abortController.signal, + AbortSignal.timeout(30000), + ]); + const rewritten = await rewriter.rewrite( + content, + rewriteSignal, + ); + if (rewritten) { + debugLogger.info( + `Cron turn ${rewriteTurnIndex}: rewritten ${rewritten.length} chars`, + ); + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: rewritten, + _meta: { + rewritten: true, + turnIndex: rewriteTurnIndex, + }, + } as unknown as Parameters< + JsonOutputAdapterInterface['processEvent'] + >[0]); + adapter.finalizeAssistantMessage(); + } + } catch (err) { + debugLogger.warn( + `Cron turn ${rewriteTurnIndex}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + if (cronToolCallRequests.length > 0) { const cronToolResponseParts: Part[] = []; From 823ac2dd73b6cd21d214b536337ff57641b0b96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 15:59:48 +0800 Subject: [PATCH 07/25] =?UTF-8?q?feat:=20rewrite=20=E6=94=AF=E6=8C=81=20as?= =?UTF-8?q?ync/sync=20=E6=A8=A1=E5=BC=8F=EF=BC=88=E9=BB=98=E8=AE=A4=20asyn?= =?UTF-8?q?c=EF=BC=8C=E4=B8=8D=E5=A2=9E=E5=8A=A0=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E9=97=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/acp-integration/session/Session.ts | 8 +- .../rewrite/MessageRewriteMiddleware.ts | 78 +++++++++++++------ .../acp-integration/session/rewrite/types.ts | 4 + packages/cli/src/nonInteractiveCli.ts | 55 +++++++------ 4 files changed, 94 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 1ca8dab2171..20810c3f896 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -409,9 +409,9 @@ export class Session implements SessionContext { } if (usageMetadata) { - // Flush rewrite buffer before emitting usage (marks turn boundary) + // Kick off rewrite in background (non-blocking, runs parallel to tools) if (this.messageRewriter) { - await this.messageRewriter.flushTurn(pendingSend.signal); + this.messageRewriter.flushTurn(pendingSend.signal); } const durationMs = Date.now() - streamStartTime; @@ -437,6 +437,10 @@ export class Session implements SessionContext { nextMessage = { role: 'user', parts: toolResponseParts }; } } + // Wait for any pending rewrite before returning + if (this.messageRewriter) { + await this.messageRewriter.waitForPendingRewrite(); + } return { stopReason: 'end_turn' }; }, ); diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index 0a765966477..e306ac9db72 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -31,6 +31,7 @@ export class MessageRewriteMiddleware { private readonly turnBuffer: TurnBuffer; private readonly rewriter: LlmRewriter; private readonly target: MessageRewriteConfig['target']; + private readonly asyncMode: boolean; private turnIndex = 0; constructor( @@ -41,6 +42,7 @@ export class MessageRewriteMiddleware { this.turnBuffer = new TurnBuffer(); this.rewriter = new LlmRewriter(config, rewriteConfig); this.target = rewriteConfig.target; + this.asyncMode = rewriteConfig.async !== false; // default true } /** @@ -89,9 +91,14 @@ export class MessageRewriteMiddleware { } } + /** Pending rewrite promise — resolved when rewrite completes and message is emitted */ + private pendingRewrite: Promise | null = null; + /** - * Flush the turn buffer: rewrite accumulated content and emit - * a rewritten message with _meta.rewritten=true. + * Flush the turn buffer: rewrite accumulated content and emit. + * + * In async mode (default): non-blocking, rewrite runs parallel to tool execution. + * In sync mode: blocks until rewrite completes, ensures strict message ordering. * * Called when: * - A tool_call is about to be emitted (turn boundary) @@ -103,32 +110,53 @@ export class MessageRewriteMiddleware { if (!content) return; this.turnIndex++; + const turnIdx = this.turnIndex; + + const doRewrite = (async () => { + try { + const rewritten = await this.rewriter.rewrite(content, signal); + if (!rewritten) { + debugLogger.info(`Turn ${turnIdx}: no rewrite output`); + return; + } - try { - const rewritten = await this.rewriter.rewrite(content, signal); - if (!rewritten) { - debugLogger.info(`Turn ${this.turnIndex}: no rewrite output`); - return; + debugLogger.info( + `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, + ); + + // Emit rewritten message with special _meta + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: rewritten }, + _meta: { + rewritten: true, + turnIndex: turnIdx, + }, + } as SessionUpdate); + } catch (error) { + debugLogger.warn( + `Turn ${turnIdx}: rewrite failed: ${error instanceof Error ? error.message : String(error)}`, + ); } + })(); - debugLogger.info( - `Turn ${this.turnIndex}: rewritten ${rewritten.length} chars`, - ); - - // Emit rewritten message with special _meta - await this.sendUpdate({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: rewritten }, - _meta: { - rewritten: true, - turnIndex: this.turnIndex, - }, - } as SessionUpdate); - } catch (error) { - debugLogger.warn( - `Turn ${this.turnIndex}: rewrite failed: ${error instanceof Error ? error.message : String(error)}`, - ); - // On failure, original messages already sent — just skip rewrite + if (this.asyncMode) { + // Non-blocking: rewrite runs parallel to tool execution + this.pendingRewrite = doRewrite; + } else { + // Blocking: wait for rewrite before proceeding (strict message order) + await doRewrite; + } + } + + /** + * Wait for any pending rewrite to complete. + * Call this before session ends to ensure all rewrites are flushed. + */ + async waitForPendingRewrite(): Promise { + if (this.pendingRewrite) { + await this.pendingRewrite; + this.pendingRewrite = null; } } } diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts index cce740f21c7..ff0c0b267e9 100644 --- a/packages/cli/src/acp-integration/session/rewrite/types.ts +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -17,6 +17,10 @@ export interface MessageRewriteConfig { prompt: string; /** Model to use for rewriting (empty = use current model) */ model?: string; + /** Whether to run rewrite async (parallel with tool execution, default true). + * async=true: no added latency, but rewritten messages may arrive after tool calls + * async=false: rewrite blocks before tools, messages in strict order */ + async?: boolean; } /** diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 7331ef150a5..05cf108a3d3 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -339,36 +339,43 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - apiStartTime; - // Flush turn buffer and append rewritten message + // Rewrite turn content (async by default, parallel with tool execution) if (rewriter && turnBuffer) { const content = turnBuffer.flush(); if (content) { rewriteTurnIndex++; - try { - const rewriteSignal = AbortSignal.any([ - abortController.signal, - AbortSignal.timeout(30000), - ]); - const rewritten = await rewriter.rewrite(content, rewriteSignal); - if (rewritten) { - debugLogger.info( - `Turn ${rewriteTurnIndex}: rewritten ${rewritten.length} chars`, + const turnIdx = rewriteTurnIndex; + const doRewrite = async () => { + try { + const rewritten = await rewriter.rewrite( + content, + abortController?.signal, + ); + if (rewritten) { + debugLogger.info( + `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, + ); + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: rewritten, + _meta: { rewritten: true, turnIndex: turnIdx }, + } as unknown as Parameters< + JsonOutputAdapterInterface['processEvent'] + >[0]); + adapter.finalizeAssistantMessage(); + } + } catch (err) { + debugLogger.warn( + `Turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, ); - // Emit rewritten as a separate assistant message with _meta.rewritten - adapter.startAssistantMessage(); - adapter.processEvent({ - type: GeminiEventType.Content, - value: rewritten, - _meta: { rewritten: true, turnIndex: rewriteTurnIndex }, - } as unknown as Parameters< - JsonOutputAdapterInterface['processEvent'] - >[0]); - adapter.finalizeAssistantMessage(); } - } catch (err) { - debugLogger.warn( - `Turn ${rewriteTurnIndex}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, - ); + }; + // Default async (fire-and-forget), sync if configured + if (rewriteConfig?.async === false) { + await doRewrite(); + } else { + void doRewrite(); } } } From 42696c74583c1658cca81fbe59bce5fcc60ba8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Mon, 13 Apr 2026 20:57:50 +0800 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20rewrite=20prompt=20=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E5=8C=96=20+=20=E4=B8=8A=E4=B8=8B=E6=96=87=E8=BF=9E?= =?UTF-8?q?=E8=B4=AF=20+=20promptFile=20+=20async=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认 prompt 改为通用英文版(适配任意 coding agent,不绑定数据分析场景) - 支持 promptFile 配置项,从文件加载自定义 prompt(优先于 inline prompt) - 上下文连贯性:lastOutput 记录上一轮改写结果,拼接到下一轮输入, 避免连续 turn 间信息重复 - 修复 CLI 非交互模式 async rewrite 丢失:void doRewrite() 改为 pendingRewrites 数组 + emitResult 前 Promise.allSettled - 增加 debug logging:REWRITE INPUT/OUTPUT 完整内容 + prev_output 长度 --- .../session/rewrite/LlmRewriter.ts | 86 ++++++++++++------- .../acp-integration/session/rewrite/types.ts | 7 +- packages/cli/src/nonInteractiveCli.ts | 11 ++- 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index 9bd1f66e589..cd3a79235d7 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -4,46 +4,33 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; import type { Config } from '@qwen-code/qwen-code-core'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import type { TurnContent, MessageRewriteConfig } from './types.js'; const debugLogger = createDebugLogger('MESSAGE_REWRITER'); -const DEFAULT_REWRITE_PROMPT = `你是数据分析过程的展示优化助手。将 Agent 的原始输出改写为结构化的、面向业务同学的分析过程展示。 +const DEFAULT_REWRITE_PROMPT = `You are an assistant that rewrites raw coding-agent output into concise, user-friendly progress updates. -## 输出风格参考 +The agent is a software engineering assistant that reads files, writes code, runs commands, and uses tools. Its raw output mixes internal reasoning with user-facing information. Your job: extract what the user cares about, drop what they don't. -用简洁的要点列表展示分析过程,让业务同学清晰看到"在做什么、怎么做、发现了什么": +## Rules -示例——数据理解阶段: -"数据理解完成。数据记录了全球多个游戏的销售数据及评分信息,涵盖游戏名称、平台、类型、发行商等维度。 -• 涵盖游戏基本信息(名称、平台、类型、发行年份等)及全球销量 -• 各地区销量(北美、欧洲、日本、其他地区) -• 媒体评分与用户评分数量" +1. **Strictly based on original**: only surface information already in the input. Never invent details, plans, or conclusions the agent didn't state. +2. **Keep**: goals, decisions, key findings, results, errors that affect the user, status updates. +3. **Drop**: file paths, tool/skill names, internal reasoning about which tool to call, code snippets, stack traces, "let me…" / "now I'll…" filler phrases. +4. **Progress turns**: if the agent is just starting a step (reading files, running a command, exploring code), output one short sentence describing what's happening — so the user isn't staring at silence. +5. **Rich content**: if the input already contains well-structured user-facing content (tables, lists, formatted results), do light cleanup only (remove stray paths/tool names) and preserve the structure. +6. **Pure internal ops**: if the input is entirely internal (fixing a typo in its own code, retrying a failed tool call, creating temp directories) → return empty string. +7. **Preserve data exactly**: never alter numbers, percentages, file sizes, error codes, or quoted output. -示例——分析执行阶段: -"策略类游戏市场基本面分析 -• 清洗数据:将 'tbd' 替换为空值,确保评分字段为数值型 -• 计算策略类游戏的平均媒体评分与平均用户评分,并与全品类均值对比 -• 分析评分与销量的相关性(如高分是否带动高销量),绘制评分-销量散点图并计算相关系数 -• 识别'高分低销'与'低分高销'的异常游戏案例,初步推测原因" +## Context continuity -示例——结论阶段: -"Central 地区盈利能力最差 -• 利润率仅 7.92%,是 West 地区(14.94%)的一半 -• 核心原因:折扣策略失控,平均折扣 24%,是其他地区的 2 倍 -• Texas 和 Illinois 两州合计亏损占 68%" +If "Previous rewrite output" is provided, the user has already seen it. Don't repeat — build on it. If this turn adds nothing new, return empty string. -## 规则 - -1. **保留的内容**:数据概览、分析模块名称和目标、计算口径(如"利润率 = SUM(利润)/SUM(销售额)")、分析方法选择原因、数据发现和洞察(含具体数字)、结论、建议、表格 -2. **过滤的内容**:文件路径、工具/Skill 名称、SQL 语句、Python 代码、技术报错信息、QWEN.md/工作流指令复述、"让我..."/"现在我来..."等自述性过渡语 -3. **纯技术操作**(修复代码错误、创建目录、安装依赖等)→ 输出空字符串 -4. **数据准确性**:不要改写任何数字、百分比、金额,原样保留 -5. **语言**:中文,简洁,用要点列表(•)组织 - -只输出改写后的文本。如果输入无业务价值,返回空字符串。`; +Output only the rewritten text, or empty string if the input has no user-facing value.`; /** * Uses LLM to rewrite turn content into business-friendly text. @@ -51,12 +38,30 @@ const DEFAULT_REWRITE_PROMPT = `你是数据分析过程的展示优化助手。 */ export class LlmRewriter { private readonly prompt: string; + /** Last successful rewrite output, used as context for next turn */ + private lastOutput: string | null = null; constructor( private readonly config: Config, rewriteConfig: MessageRewriteConfig, ) { - this.prompt = rewriteConfig.prompt || DEFAULT_REWRITE_PROMPT; + // promptFile takes precedence over inline prompt + if (rewriteConfig.promptFile) { + const filePath = resolve(rewriteConfig.promptFile); + if (existsSync(filePath)) { + this.prompt = readFileSync(filePath, 'utf-8').trim(); + debugLogger.info( + `Loaded rewrite prompt from file: ${filePath} (${this.prompt.length} chars)`, + ); + } else { + debugLogger.warn( + `Rewrite prompt file not found: ${filePath}, using default`, + ); + this.prompt = DEFAULT_REWRITE_PROMPT; + } + } else { + this.prompt = rewriteConfig.prompt || DEFAULT_REWRITE_PROMPT; + } } /** @@ -77,12 +82,22 @@ export class LlmRewriter { inputParts.push('[回复文本]\n' + turnContent.messages.join('\n')); } + // Prepend last rewrite output as context for coherence + if (this.lastOutput) { + inputParts.unshift('[上一轮改写结果]\n' + this.lastOutput); + } + const inputText = inputParts.join('\n\n'); if (!inputText.trim()) return null; // Skip very short turns that are likely just transitions if (inputText.length < 10) return null; + debugLogger.info( + `[REWRITE INPUT] system_prompt_len=${this.prompt.length} input_len=${inputText.length} prev_output=${this.lastOutput ? this.lastOutput.length : 0}\n` + + `--- INPUT TEXT ---\n${inputText}\n---`, + ); + try { const contentGenerator = this.config.getContentGenerator(); if (!contentGenerator) { @@ -123,10 +138,21 @@ export class LlmRewriter { // If LLM returns empty or very short, skip if (!rewritten.trim() || rewritten.trim().length < 5) { + debugLogger.info(`[REWRITE OUTPUT] empty or too short, skipping`); return null; } - return rewritten.trim(); + const trimmed = rewritten.trim(); + + debugLogger.info( + `[REWRITE OUTPUT] len=${trimmed.length}\n` + + `--- OUTPUT ---\n${trimmed}\n---`, + ); + + // Update context for next turn + this.lastOutput = trimmed; + + return trimmed; } catch (error) { debugLogger.warn( `LLM rewrite failed, skipping: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts index ff0c0b267e9..4a03a75a229 100644 --- a/packages/cli/src/acp-integration/session/rewrite/types.ts +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -13,8 +13,11 @@ export interface MessageRewriteConfig { enabled: boolean; /** Which message types to rewrite */ target: 'message' | 'thought' | 'both'; - /** LLM rewrite prompt (system prompt for the rewriter) */ - prompt: string; + /** LLM rewrite prompt (system prompt for the rewriter). Inline string. */ + prompt?: string; + /** Path to a file containing the rewrite prompt. Resolved relative to CWD. + * Takes precedence over `prompt` if both are set. */ + promptFile?: string; /** Model to use for rewriting (empty = use current model) */ model?: string; /** Whether to run rewrite async (parallel with tool execution, default true). diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 05cf108a3d3..4273a5cce6d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -260,6 +260,7 @@ export async function runNonInteractive( : null; const turnBuffer = rewriter ? new TurnBuffer() : null; let rewriteTurnIndex = 0; + const pendingRewrites: Array> = []; if (rewriter) { debugLogger.info('Message rewrite enabled in non-interactive mode'); @@ -371,11 +372,11 @@ export async function runNonInteractive( ); } }; - // Default async (fire-and-forget), sync if configured + // Default async (parallel with tool execution), sync if configured if (rewriteConfig?.async === false) { await doRewrite(); } else { - void doRewrite(); + pendingRewrites.push(doRewrite()); } } } @@ -653,6 +654,12 @@ export async function runNonInteractive( }); } + // Wait for all pending async rewrites before emitting result + if (pendingRewrites.length > 0) { + await Promise.allSettled(pendingRewrites); + pendingRewrites.length = 0; + } + const metrics = uiTelemetryService.getMetrics(); const usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output From 242c0dddf68d66d9d21417ef1f5ad18bf2cadf99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 10:33:53 +0800 Subject: [PATCH 09/25] refactor: remove sync rewrite mode, always use async (non-blocking) rewrite - Remove `async` field from MessageRewriteConfig - MessageRewriteMiddleware.flushTurn() always fires in background - nonInteractiveCli.ts main & cron paths always push to pendingRewrites - No user-facing latency from rewrite calls --- .../src/acp-integration/session/Session.ts | 4 +- .../rewrite/MessageRewriteMiddleware.ts | 15 +-- .../acp-integration/session/rewrite/types.ts | 4 - packages/cli/src/nonInteractiveCli.ts | 123 +++++++++--------- 4 files changed, 64 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 20810c3f896..d3437c24b08 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -587,9 +587,9 @@ export class Session implements SessionContext { } if (usageMetadata) { - // Flush rewrite buffer before emitting usage (marks turn boundary) + // Kick off rewrite in background (non-blocking) if (this.messageRewriter) { - await this.messageRewriter.flushTurn(ac.signal); + this.messageRewriter.flushTurn(ac.signal); } const durationMs = Date.now() - streamStartTime; await this.messageEmitter.emitUsageMetadata( diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index e306ac9db72..64e38ad0112 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -31,7 +31,6 @@ export class MessageRewriteMiddleware { private readonly turnBuffer: TurnBuffer; private readonly rewriter: LlmRewriter; private readonly target: MessageRewriteConfig['target']; - private readonly asyncMode: boolean; private turnIndex = 0; constructor( @@ -42,7 +41,6 @@ export class MessageRewriteMiddleware { this.turnBuffer = new TurnBuffer(); this.rewriter = new LlmRewriter(config, rewriteConfig); this.target = rewriteConfig.target; - this.asyncMode = rewriteConfig.async !== false; // default true } /** @@ -97,8 +95,7 @@ export class MessageRewriteMiddleware { /** * Flush the turn buffer: rewrite accumulated content and emit. * - * In async mode (default): non-blocking, rewrite runs parallel to tool execution. - * In sync mode: blocks until rewrite completes, ensures strict message ordering. + * Non-blocking: rewrite runs in background, parallel to tool execution. * * Called when: * - A tool_call is about to be emitted (turn boundary) @@ -112,7 +109,7 @@ export class MessageRewriteMiddleware { this.turnIndex++; const turnIdx = this.turnIndex; - const doRewrite = (async () => { + this.pendingRewrite = (async () => { try { const rewritten = await this.rewriter.rewrite(content, signal); if (!rewritten) { @@ -139,14 +136,6 @@ export class MessageRewriteMiddleware { ); } })(); - - if (this.asyncMode) { - // Non-blocking: rewrite runs parallel to tool execution - this.pendingRewrite = doRewrite; - } else { - // Blocking: wait for rewrite before proceeding (strict message order) - await doRewrite; - } } /** diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts index 4a03a75a229..330dfd7480b 100644 --- a/packages/cli/src/acp-integration/session/rewrite/types.ts +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -20,10 +20,6 @@ export interface MessageRewriteConfig { promptFile?: string; /** Model to use for rewriting (empty = use current model) */ model?: string; - /** Whether to run rewrite async (parallel with tool execution, default true). - * async=true: no added latency, but rewritten messages may arrive after tool calls - * async=false: rewrite blocks before tools, messages in strict order */ - async?: boolean; } /** diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 4273a5cce6d..e7a673ce86a 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -340,44 +340,40 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - apiStartTime; - // Rewrite turn content (async by default, parallel with tool execution) + // Rewrite turn content (async, parallel with tool execution) if (rewriter && turnBuffer) { const content = turnBuffer.flush(); if (content) { rewriteTurnIndex++; const turnIdx = rewriteTurnIndex; - const doRewrite = async () => { - try { - const rewritten = await rewriter.rewrite( - content, - abortController?.signal, - ); - if (rewritten) { - debugLogger.info( - `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, + pendingRewrites.push( + (async () => { + try { + const rewritten = await rewriter.rewrite( + content, + abortController?.signal, + ); + if (rewritten) { + debugLogger.info( + `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, + ); + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: rewritten, + _meta: { rewritten: true, turnIndex: turnIdx }, + } as unknown as Parameters< + JsonOutputAdapterInterface['processEvent'] + >[0]); + adapter.finalizeAssistantMessage(); + } + } catch (err) { + debugLogger.warn( + `Turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, ); - adapter.startAssistantMessage(); - adapter.processEvent({ - type: GeminiEventType.Content, - value: rewritten, - _meta: { rewritten: true, turnIndex: turnIdx }, - } as unknown as Parameters< - JsonOutputAdapterInterface['processEvent'] - >[0]); - adapter.finalizeAssistantMessage(); } - } catch (err) { - debugLogger.warn( - `Turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }; - // Default async (parallel with tool execution), sync if configured - if (rewriteConfig?.async === false) { - await doRewrite(); - } else { - pendingRewrites.push(doRewrite()); - } + })(), + ); } } @@ -545,42 +541,43 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - cronApiStartTime; - // Flush turn buffer and append rewritten message for cron path + // Flush turn buffer and rewrite for cron path (async) if (rewriter && turnBuffer) { const content = turnBuffer.flush(); if (content) { rewriteTurnIndex++; - try { - const rewriteSignal = AbortSignal.any([ - abortController.signal, - AbortSignal.timeout(30000), - ]); - const rewritten = await rewriter.rewrite( - content, - rewriteSignal, - ); - if (rewritten) { - debugLogger.info( - `Cron turn ${rewriteTurnIndex}: rewritten ${rewritten.length} chars`, - ); - adapter.startAssistantMessage(); - adapter.processEvent({ - type: GeminiEventType.Content, - value: rewritten, - _meta: { - rewritten: true, - turnIndex: rewriteTurnIndex, - }, - } as unknown as Parameters< - JsonOutputAdapterInterface['processEvent'] - >[0]); - adapter.finalizeAssistantMessage(); - } - } catch (err) { - debugLogger.warn( - `Cron turn ${rewriteTurnIndex}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } + const turnIdx = rewriteTurnIndex; + pendingRewrites.push( + (async () => { + try { + const rewritten = await rewriter.rewrite( + content, + abortController.signal, + ); + if (rewritten) { + debugLogger.info( + `Cron turn ${turnIdx}: rewritten ${rewritten.length} chars`, + ); + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: rewritten, + _meta: { + rewritten: true, + turnIndex: turnIdx, + }, + } as unknown as Parameters< + JsonOutputAdapterInterface['processEvent'] + >[0]); + adapter.finalizeAssistantMessage(); + } + } catch (err) { + debugLogger.warn( + `Cron turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + })(), + ); } } From 3e514b725afc49c15f1c73ac9bcf14881663b6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 10:37:47 +0800 Subject: [PATCH 10/25] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20trust=20check,=20timeout,=20history=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. loadRewriteConfig: skip workspace settings when !isTrusted, preventing untrusted repos from enabling rewriter with a custom prompt 2. MessageRewriteMiddleware.flushTurn: always enforce 30s timeout internally, even when caller provides no AbortSignal (interactive path) 3. Install rewriter AFTER history replay completes (Session.installRewriter), so historical messages are never rewritten on session load --- packages/cli/src/acp-integration/acpAgent.ts | 3 ++ .../src/acp-integration/session/Session.ts | 31 +++++++++++-------- .../rewrite/MessageRewriteMiddleware.ts | 8 ++++- .../acp-integration/session/rewrite/config.ts | 11 ++++--- .../cli/src/acp-integration/session/types.ts | 5 +-- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index fc1dceef1de..8c0a70e4cfa 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -514,6 +514,9 @@ class QwenAgent implements Agent { await session.replayHistory(conversation.messages); } + // Install rewriter AFTER history replay to avoid rewriting historical messages + session.installRewriter(); + return session; } diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d3437c24b08..68ddcbaa47c 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -128,8 +128,8 @@ export class Session implements SessionContext { private readonly planEmitter: PlanEmitter; private readonly messageEmitter: MessageEmitter; - // Message rewrite middleware (optional) - readonly messageRewriter?: MessageRewriteMiddleware; + // Message rewrite middleware (optional, installed after history replay) + messageRewriter?: MessageRewriteMiddleware; // Implement SessionContext interface readonly sessionId: string; @@ -144,17 +144,6 @@ export class Session implements SessionContext { this.sessionId = id; this.runtimeBaseDir = Storage.getRuntimeBaseDir(); - // Initialize message rewrite middleware if configured - const rewriteConfig = loadRewriteConfig(settings); - if (rewriteConfig?.enabled) { - debugLogger.info('Message rewrite middleware enabled'); - this.messageRewriter = new MessageRewriteMiddleware( - config, - rewriteConfig, - (update) => this.sendUpdate(update), - ); - } - // Initialize modular components with this session as context this.toolCallEmitter = new ToolCallEmitter(this); this.planEmitter = new PlanEmitter(this); @@ -170,6 +159,22 @@ export class Session implements SessionContext { return this.config; } + /** + * Install the message rewrite middleware if configured. + * Must be called AFTER history replay to avoid rewriting historical messages. + */ + installRewriter(): void { + const rewriteConfig = loadRewriteConfig(this.settings); + if (rewriteConfig?.enabled) { + debugLogger.info('Message rewrite middleware enabled'); + this.messageRewriter = new MessageRewriteMiddleware( + this.config, + rewriteConfig, + (update) => this.sendUpdate(update), + ); + } + } + /** * Replays conversation history to the client using modular components. * Delegates to HistoryReplayer for consistent event emission. diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index 64e38ad0112..2920aaef9b8 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -109,9 +109,15 @@ export class MessageRewriteMiddleware { this.turnIndex++; const turnIdx = this.turnIndex; + // Always enforce a 30s timeout, combined with caller's signal if provided + const timeoutSignal = AbortSignal.timeout(30_000); + const rewriteSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + this.pendingRewrite = (async () => { try { - const rewritten = await this.rewriter.rewrite(content, signal); + const rewritten = await this.rewriter.rewrite(content, rewriteSignal); if (!rewritten) { debugLogger.info(`Turn ${turnIdx}: no rewrite output`); return; diff --git a/packages/cli/src/acp-integration/session/rewrite/config.ts b/packages/cli/src/acp-integration/session/rewrite/config.ts index 05107e800e3..6a128309eef 100644 --- a/packages/cli/src/acp-integration/session/rewrite/config.ts +++ b/packages/cli/src/acp-integration/session/rewrite/config.ts @@ -9,7 +9,8 @@ import type { MessageRewriteConfig } from './types.js'; /** * Reads messageRewrite configuration from user/workspace originalSettings. - * Workspace settings take precedence over user settings. + * Workspace settings are only used when the workspace is trusted, + * preventing untrusted repos from enabling the rewriter with a custom prompt. */ export function loadRewriteConfig( settings: LoadedSettings, @@ -17,9 +18,11 @@ export function loadRewriteConfig( const userOriginal = settings.user?.originalSettings as | Record | undefined; - const workspaceOriginal = settings.workspace?.originalSettings as - | Record - | undefined; + const workspaceOriginal = settings.isTrusted + ? (settings.workspace?.originalSettings as + | Record + | undefined) + : undefined; return (workspaceOriginal?.['messageRewrite'] ?? userOriginal?.['messageRewrite']) as MessageRewriteConfig | undefined; } diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index 75cff5b8255..1ef7fb9e9c1 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -30,8 +30,9 @@ export interface SessionUpdateSender { export interface SessionContext extends SessionUpdateSender { readonly sessionId: string; readonly config: Config; - /** Optional message rewrite middleware for ACP message transformation */ - readonly messageRewriter?: MessageRewriteMiddleware; + /** Optional message rewrite middleware for ACP message transformation. + * Installed after history replay to avoid rewriting historical messages. */ + messageRewriter?: MessageRewriteMiddleware; } /** From 617b466e87e6ec1ad1fd3b3bdc0a2c83b4c3682d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 11:22:17 +0800 Subject: [PATCH 11/25] =?UTF-8?q?fix:=20address=20second=20round=20review?= =?UTF-8?q?=20=E2=80=94=20target=20filter,=20timeout,=20rewrite=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. nonInteractiveCli: apply rewriteConfig.target filter to accumulation (main path and cron path), matching MessageRewriteMiddleware behavior 2. nonInteractiveCli: add 30s AbortSignal.timeout to rewrite calls in both main and cron paths 3. MessageRewriteMiddleware: replace single pendingRewrite slot with pendingRewrites array + Promise.allSettled, ensuring all rewrites complete before session exits --- .../src/acp-integration/session/Session.ts | 2 +- .../rewrite/MessageRewriteMiddleware.ts | 68 ++++++++++--------- packages/cli/src/nonInteractiveCli.ts | 34 +++++++--- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 68ddcbaa47c..6b2afa3e059 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -444,7 +444,7 @@ export class Session implements SessionContext { } // Wait for any pending rewrite before returning if (this.messageRewriter) { - await this.messageRewriter.waitForPendingRewrite(); + await this.messageRewriter.waitForPendingRewrites(); } return { stopReason: 'end_turn' }; }, diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index 2920aaef9b8..503f0dd5bf7 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -89,8 +89,8 @@ export class MessageRewriteMiddleware { } } - /** Pending rewrite promise — resolved when rewrite completes and message is emitted */ - private pendingRewrite: Promise | null = null; + /** Pending rewrite promises — all must settle before session exits */ + private pendingRewrites: Array> = []; /** * Flush the turn buffer: rewrite accumulated content and emit. @@ -115,43 +115,45 @@ export class MessageRewriteMiddleware { ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; - this.pendingRewrite = (async () => { - try { - const rewritten = await this.rewriter.rewrite(content, rewriteSignal); - if (!rewritten) { - debugLogger.info(`Turn ${turnIdx}: no rewrite output`); - return; + this.pendingRewrites.push( + (async () => { + try { + const rewritten = await this.rewriter.rewrite(content, rewriteSignal); + if (!rewritten) { + debugLogger.info(`Turn ${turnIdx}: no rewrite output`); + return; + } + + debugLogger.info( + `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, + ); + + // Emit rewritten message with special _meta + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: rewritten }, + _meta: { + rewritten: true, + turnIndex: turnIdx, + }, + } as SessionUpdate); + } catch (error) { + debugLogger.warn( + `Turn ${turnIdx}: rewrite failed: ${error instanceof Error ? error.message : String(error)}`, + ); } - - debugLogger.info( - `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, - ); - - // Emit rewritten message with special _meta - await this.sendUpdate({ - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: rewritten }, - _meta: { - rewritten: true, - turnIndex: turnIdx, - }, - } as SessionUpdate); - } catch (error) { - debugLogger.warn( - `Turn ${turnIdx}: rewrite failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - })(); + })(), + ); } /** - * Wait for any pending rewrite to complete. + * Wait for all pending rewrites to complete. * Call this before session ends to ensure all rewrites are flushed. */ - async waitForPendingRewrite(): Promise { - if (this.pendingRewrite) { - await this.pendingRewrite; - this.pendingRewrite = null; + async waitForPendingRewrites(): Promise { + if (this.pendingRewrites.length > 0) { + await Promise.allSettled(this.pendingRewrites); + this.pendingRewrites = []; } } } diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index e7a673ce86a..cb9c32f5bc3 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -258,6 +258,7 @@ export async function runNonInteractive( const rewriter = rewriteConfig?.enabled ? new LlmRewriter(config, rewriteConfig) : null; + const rewriteTarget = rewriteConfig?.target ?? 'both'; const turnBuffer = rewriter ? new TurnBuffer() : null; let rewriteTurnIndex = 0; const pendingRewrites: Array> = []; @@ -302,14 +303,19 @@ export async function runNonInteractive( // Use adapter for all event processing adapter.processEvent(event); - // Accumulate for turn-end rewriting + // Accumulate for turn-end rewriting (respecting target filter) if (turnBuffer) { if ( event.type === GeminiEventType.Content && - typeof event.value === 'string' + typeof event.value === 'string' && + (rewriteTarget === 'message' || rewriteTarget === 'both') ) { turnBuffer.appendMessage(event.value); - } else if (event.type === GeminiEventType.Thought && event.value) { + } else if ( + event.type === GeminiEventType.Thought && + event.value && + (rewriteTarget === 'thought' || rewriteTarget === 'both') + ) { const thought = event.value; const thoughtText = thought.subject ? `${thought.subject}: ${thought.description}` @@ -349,9 +355,13 @@ export async function runNonInteractive( pendingRewrites.push( (async () => { try { + const rewriteSignal = AbortSignal.any([ + abortController.signal, + AbortSignal.timeout(30_000), + ]); const rewritten = await rewriter.rewrite( content, - abortController?.signal, + rewriteSignal, ); if (rewritten) { debugLogger.info( @@ -512,16 +522,20 @@ export async function runNonInteractive( } adapter.processEvent(event); - // Accumulate turn content for rewriting + // Accumulate turn content for rewriting (respecting target filter) if (turnBuffer) { if ( event.type === GeminiEventType.Content && - typeof event.value === 'string' + typeof event.value === 'string' && + (rewriteTarget === 'message' || + rewriteTarget === 'both') ) { turnBuffer.appendMessage(event.value); } else if ( event.type === GeminiEventType.Thought && - event.value + event.value && + (rewriteTarget === 'thought' || + rewriteTarget === 'both') ) { const thought = event.value; const thoughtText = thought.subject @@ -550,9 +564,13 @@ export async function runNonInteractive( pendingRewrites.push( (async () => { try { + const cronRewriteSignal = AbortSignal.any([ + abortController.signal, + AbortSignal.timeout(30_000), + ]); const rewritten = await rewriter.rewrite( content, - abortController.signal, + cronRewriteSignal, ); if (rewritten) { debugLogger.info( From a4cfb4c03bb6cb2c578d3c47f82a0fb71d217255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 11:33:23 +0800 Subject: [PATCH 12/25] test: add unit tests for TurnBuffer, loadRewriteConfig, MessageRewriteMiddleware - TurnBuffer: flush, reset, isEmpty, markToolCall, whitespace filtering (12 tests) - loadRewriteConfig: isTrusted gating, workspace/user precedence (5 tests) - MessageRewriteMiddleware: target filtering, tool_call boundary flush, pendingRewrites queue, rewrite metadata (9 tests) --- .../rewrite/MessageRewriteMiddleware.test.ts | 206 ++++++++++++++++++ .../session/rewrite/TurnBuffer.test.ts | 112 ++++++++++ .../session/rewrite/config.test.ts | 93 ++++++++ 3 files changed, 411 insertions(+) create mode 100644 packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts create mode 100644 packages/cli/src/acp-integration/session/rewrite/TurnBuffer.test.ts create mode 100644 packages/cli/src/acp-integration/session/rewrite/config.test.ts diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts new file mode 100644 index 00000000000..77f93314ee3 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import type { Config } from '@qwen-code/qwen-code-core'; + +// Mock core to avoid Vite https resolution issue +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +// Mock LlmRewriter to avoid real LLM calls +vi.mock('./LlmRewriter.js', () => ({ + LlmRewriter: vi.fn().mockImplementation(() => ({ + rewrite: vi.fn().mockResolvedValue('rewritten text'), + })), +})); + +// Import after mocks are set up +const { MessageRewriteMiddleware } = await import( + './MessageRewriteMiddleware.js' +); + +function createMiddleware( + target: 'message' | 'thought' | 'both' = 'both', + sendUpdate?: ReturnType, +) { + const mockSendUpdate = sendUpdate ?? vi.fn().mockResolvedValue(undefined); + const middleware = new MessageRewriteMiddleware( + {} as Config, + { enabled: true, target, prompt: 'test prompt' }, + mockSendUpdate, + ); + return { middleware, mockSendUpdate }; +} + +describe('MessageRewriteMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('interceptUpdate — pass-through', () => { + it('should pass through non-message updates unchanged', async () => { + const { middleware, mockSendUpdate } = createMiddleware(); + const update = { + sessionUpdate: 'tool_call_update', + content: { text: 'progress' }, + } as unknown as SessionUpdate; + + await middleware.interceptUpdate(update); + expect(mockSendUpdate).toHaveBeenCalledWith(update); + }); + + it('should always send original message/thought as-is', async () => { + const { middleware, mockSendUpdate } = createMiddleware(); + const msgUpdate = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + } as unknown as SessionUpdate; + + await middleware.interceptUpdate(msgUpdate); + expect(mockSendUpdate).toHaveBeenCalledWith(msgUpdate); + }); + }); + + describe('interceptUpdate — target filtering', () => { + it('should accumulate messages when target is "message"', async () => { + const { middleware, mockSendUpdate } = createMiddleware('message'); + + await middleware.interceptUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'msg' }, + } as unknown as SessionUpdate); + + await middleware.interceptUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thought' }, + } as unknown as SessionUpdate); + + // Flush and wait + await middleware.flushTurn(); + await middleware.waitForPendingRewrites(); + + // Original pass-through (2) + rewritten (1) + expect(mockSendUpdate).toHaveBeenCalledTimes(3); + }); + + it('should not accumulate thoughts when target is "message"', async () => { + const { middleware, mockSendUpdate } = createMiddleware('message'); + + // Only thought, no message — flush should produce nothing + await middleware.interceptUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thought only' }, + } as unknown as SessionUpdate); + + await middleware.flushTurn(); + await middleware.waitForPendingRewrites(); + + // Only the original pass-through, no rewrite + expect(mockSendUpdate).toHaveBeenCalledTimes(1); + }); + + it('should accumulate both when target is "both"', async () => { + const { middleware, mockSendUpdate } = createMiddleware('both'); + + await middleware.interceptUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'msg' }, + } as unknown as SessionUpdate); + + await middleware.interceptUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thought' }, + } as unknown as SessionUpdate); + + await middleware.flushTurn(); + await middleware.waitForPendingRewrites(); + + // 2 pass-throughs + 1 rewrite + expect(mockSendUpdate).toHaveBeenCalledTimes(3); + }); + }); + + describe('flushTurn — tool_call boundary', () => { + it('should flush before passing through tool_call', async () => { + const { middleware, mockSendUpdate } = createMiddleware(); + + await middleware.interceptUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'before tool' }, + } as unknown as SessionUpdate); + + await middleware.interceptUpdate({ + sessionUpdate: 'tool_call', + callId: '123', + } as unknown as SessionUpdate); + + await middleware.waitForPendingRewrites(); + + // pass-through msg + tool_call + rewrite + expect(mockSendUpdate).toHaveBeenCalledTimes(3); + }); + }); + + describe('waitForPendingRewrites', () => { + it('should wait for multiple pending rewrites', async () => { + const { middleware, mockSendUpdate } = createMiddleware(); + + // Simulate 3 turns + for (let i = 0; i < 3; i++) { + await middleware.interceptUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `turn ${i}` }, + } as unknown as SessionUpdate); + await middleware.flushTurn(); + } + + await middleware.waitForPendingRewrites(); + + // 3 pass-throughs + 3 rewrites + expect(mockSendUpdate).toHaveBeenCalledTimes(6); + }); + + it('should be safe to call when no rewrites are pending', async () => { + const { middleware } = createMiddleware(); + await expect( + middleware.waitForPendingRewrites(), + ).resolves.toBeUndefined(); + }); + }); + + describe('rewrite metadata', () => { + it('should emit rewritten message with _meta.rewritten=true', async () => { + const { middleware, mockSendUpdate } = createMiddleware(); + + await middleware.interceptUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'content' }, + } as unknown as SessionUpdate); + + await middleware.flushTurn(); + await middleware.waitForPendingRewrites(); + + const rewriteCall = mockSendUpdate.mock.calls.find( + (call: unknown[]) => + (call[0] as Record)['_meta'] !== undefined, + ); + expect(rewriteCall).toBeDefined(); + const meta = (rewriteCall![0] as Record)[ + '_meta' + ] as Record; + expect(meta['rewritten']).toBe(true); + expect(meta['turnIndex']).toBe(1); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/rewrite/TurnBuffer.test.ts b/packages/cli/src/acp-integration/session/rewrite/TurnBuffer.test.ts new file mode 100644 index 00000000000..08a5562976f --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/TurnBuffer.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { TurnBuffer } from './TurnBuffer.js'; + +describe('TurnBuffer', () => { + let buffer: TurnBuffer; + + beforeEach(() => { + buffer = new TurnBuffer(); + }); + + describe('isEmpty', () => { + it('should be empty initially', () => { + expect(buffer.isEmpty).toBe(true); + }); + + it('should not be empty after appending a message', () => { + buffer.appendMessage('hello'); + expect(buffer.isEmpty).toBe(false); + }); + + it('should not be empty after appending a thought', () => { + buffer.appendThought('thinking...'); + expect(buffer.isEmpty).toBe(false); + }); + + it('should be empty after flush', () => { + buffer.appendMessage('hello'); + buffer.flush(); + expect(buffer.isEmpty).toBe(true); + }); + }); + + describe('appendMessage / appendThought', () => { + it('should ignore empty strings', () => { + buffer.appendMessage(''); + buffer.appendThought(''); + expect(buffer.isEmpty).toBe(true); + }); + }); + + describe('markToolCall', () => { + it('should set hasToolCalls in flushed content', () => { + buffer.appendMessage('text'); + buffer.markToolCall(); + const content = buffer.flush(); + expect(content?.hasToolCalls).toBe(true); + }); + + it('should default hasToolCalls to false', () => { + buffer.appendMessage('text'); + const content = buffer.flush(); + expect(content?.hasToolCalls).toBe(false); + }); + }); + + describe('flush', () => { + it('should return null when buffer is empty', () => { + expect(buffer.flush()).toBeNull(); + }); + + it('should return null when only whitespace was appended', () => { + buffer.appendMessage(' '); + buffer.appendThought(' \n '); + expect(buffer.flush()).toBeNull(); + }); + + it('should return accumulated messages and thoughts', () => { + buffer.appendThought('thought 1'); + buffer.appendThought('thought 2'); + buffer.appendMessage('msg 1'); + buffer.appendMessage('msg 2'); + + const content = buffer.flush(); + expect(content).toEqual({ + thoughts: ['thought 1', 'thought 2'], + messages: ['msg 1', 'msg 2'], + hasToolCalls: false, + }); + }); + + it('should filter out whitespace-only entries', () => { + buffer.appendThought(' '); + buffer.appendThought('real thought'); + buffer.appendMessage(''); + buffer.appendMessage('real message'); + + const content = buffer.flush(); + expect(content?.thoughts).toEqual(['real thought']); + expect(content?.messages).toEqual(['real message']); + }); + + it('should reset buffer after flush', () => { + buffer.appendMessage('first'); + buffer.markToolCall(); + buffer.flush(); + + buffer.appendMessage('second'); + const content = buffer.flush(); + expect(content).toEqual({ + thoughts: [], + messages: ['second'], + hasToolCalls: false, + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/rewrite/config.test.ts b/packages/cli/src/acp-integration/session/rewrite/config.test.ts new file mode 100644 index 00000000000..8bf808b1ce3 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/config.test.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { loadRewriteConfig } from './config.js'; +import type { LoadedSettings, SettingsFile } from '../../../config/settings.js'; +import type { Settings } from '../../../config/settingsSchema.js'; + +function makeSettingsFile( + originalSettings: Record, +): SettingsFile { + return { + settings: {} as Settings, + originalSettings: originalSettings as unknown as Settings, + path: '/tmp/test-settings.json', + }; +} + +function makeSettings( + overrides: { + userRewrite?: Record; + workspaceRewrite?: Record; + isTrusted?: boolean; + } = {}, +): LoadedSettings { + return { + user: makeSettingsFile( + overrides.userRewrite ? { messageRewrite: overrides.userRewrite } : {}, + ), + workspace: makeSettingsFile( + overrides.workspaceRewrite + ? { messageRewrite: overrides.workspaceRewrite } + : {}, + ), + isTrusted: overrides.isTrusted ?? true, + merged: {} as Settings, + migratedInMemorScopes: new Set(), + migrationWarnings: [], + } as LoadedSettings; +} + +describe('loadRewriteConfig', () => { + it('should return undefined when no config is set', () => { + const settings = makeSettings(); + expect(loadRewriteConfig(settings)).toBeUndefined(); + }); + + it('should return user config when only user config is set', () => { + const settings = makeSettings({ + userRewrite: { enabled: true, target: 'both', prompt: 'user prompt' }, + }); + const config = loadRewriteConfig(settings); + expect(config).toEqual({ + enabled: true, + target: 'both', + prompt: 'user prompt', + }); + }); + + it('should return workspace config when trusted', () => { + const settings = makeSettings({ + userRewrite: { enabled: false, target: 'message' }, + workspaceRewrite: { enabled: true, target: 'both', prompt: 'ws prompt' }, + isTrusted: true, + }); + const config = loadRewriteConfig(settings); + expect(config?.enabled).toBe(true); + expect(config?.prompt).toBe('ws prompt'); + }); + + it('should ignore workspace config when untrusted', () => { + const settings = makeSettings({ + userRewrite: { enabled: false, target: 'message' }, + workspaceRewrite: { enabled: true, target: 'both', prompt: 'malicious' }, + isTrusted: false, + }); + const config = loadRewriteConfig(settings); + expect(config?.enabled).toBe(false); + expect(config?.prompt).toBeUndefined(); + }); + + it('should fall back to user config when workspace has no rewrite config', () => { + const settings = makeSettings({ + userRewrite: { enabled: true, target: 'thought' }, + isTrusted: true, + }); + const config = loadRewriteConfig(settings); + expect(config?.target).toBe('thought'); + }); +}); From 97bd82640018519de891f3fde60ed5d82ec61ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 11:56:52 +0800 Subject: [PATCH 13/25] fix: config.test.ts use unknown cast for LoadedSettings stub (fix tsc --build) --- .../session/rewrite/config.test.ts | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/config.test.ts b/packages/cli/src/acp-integration/session/rewrite/config.test.ts index 8bf808b1ce3..f9d1f7f022c 100644 --- a/packages/cli/src/acp-integration/session/rewrite/config.test.ts +++ b/packages/cli/src/acp-integration/session/rewrite/config.test.ts @@ -6,19 +6,12 @@ import { describe, it, expect } from 'vitest'; import { loadRewriteConfig } from './config.js'; -import type { LoadedSettings, SettingsFile } from '../../../config/settings.js'; -import type { Settings } from '../../../config/settingsSchema.js'; - -function makeSettingsFile( - originalSettings: Record, -): SettingsFile { - return { - settings: {} as Settings, - originalSettings: originalSettings as unknown as Settings, - path: '/tmp/test-settings.json', - }; -} +import type { LoadedSettings } from '../../../config/settings.js'; +/** + * Build a minimal LoadedSettings stub with only the fields + * that loadRewriteConfig actually reads (user/workspace originalSettings + isTrusted). + */ function makeSettings( overrides: { userRewrite?: Record; @@ -27,19 +20,18 @@ function makeSettings( } = {}, ): LoadedSettings { return { - user: makeSettingsFile( - overrides.userRewrite ? { messageRewrite: overrides.userRewrite } : {}, - ), - workspace: makeSettingsFile( - overrides.workspaceRewrite + user: { + originalSettings: overrides.userRewrite + ? { messageRewrite: overrides.userRewrite } + : {}, + }, + workspace: { + originalSettings: overrides.workspaceRewrite ? { messageRewrite: overrides.workspaceRewrite } : {}, - ), + }, isTrusted: overrides.isTrusted ?? true, - merged: {} as Settings, - migratedInMemorScopes: new Set(), - migrationWarnings: [], - } as LoadedSettings; + } as unknown as LoadedSettings; } describe('loadRewriteConfig', () => { From b8d7400d8e12ac4e7c25dffa9f18a4323ef3746d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 13:47:03 +0800 Subject: [PATCH 14/25] fix: filter LLM literal "empty string" responses in rewriter output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM sometimes outputs "(空字符串)" or similar text instead of actual empty string when instructed to "return empty string". Add regex patterns to catch common variants and treat them as null (skip rewrite output). --- .../session/rewrite/LlmRewriter.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index cd3a79235d7..386303884ff 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -136,23 +136,37 @@ export class LlmRewriter { .filter(Boolean) .join('') ?? ''; - // If LLM returns empty or very short, skip - if (!rewritten.trim() || rewritten.trim().length < 5) { - debugLogger.info(`[REWRITE OUTPUT] empty or too short, skipping`); + // If LLM returns empty or very short, skip. + // Also catch common LLM literal interpretations of "return empty string" + // (e.g. '(空字符串)', '空字符串', '""', "''", '(empty)', etc.) + const trimmed0 = rewritten.trim(); + const EMPTY_PATTERNS = [ + /^[((]?空字符串[))]?$/, + /^["'""'']{2}$/, + /^[((]?empty[))]?$/i, + /^[((]?empty string[))]?$/i, + /^[((]?无[))]?$/, + /^[((]?无内容[))]?$/, + /^[((]?无业务价值[))]?$/, + ]; + if ( + !trimmed0 || + trimmed0.length < 5 || + EMPTY_PATTERNS.some((p) => p.test(trimmed0)) + ) { + debugLogger.info(`[REWRITE OUTPUT] empty or no-value, skipping`); return null; } - const trimmed = rewritten.trim(); - debugLogger.info( - `[REWRITE OUTPUT] len=${trimmed.length}\n` + - `--- OUTPUT ---\n${trimmed}\n---`, + `[REWRITE OUTPUT] len=${trimmed0.length}\n` + + `--- OUTPUT ---\n${trimmed0}\n---`, ); // Update context for next turn - this.lastOutput = trimmed; + this.lastOutput = trimmed0; - return trimmed; + return trimmed0; } catch (error) { debugLogger.warn( `LLM rewrite failed, skipping: ${error instanceof Error ? error.message : String(error)}`, From 288927fd760e4182d88f9f0af1626fbb3b766458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 13:49:14 +0800 Subject: [PATCH 15/25] revert: remove LLM empty-string pattern defense, rely on prompt fix instead --- .../session/rewrite/LlmRewriter.ts | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index 386303884ff..cd3a79235d7 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -136,37 +136,23 @@ export class LlmRewriter { .filter(Boolean) .join('') ?? ''; - // If LLM returns empty or very short, skip. - // Also catch common LLM literal interpretations of "return empty string" - // (e.g. '(空字符串)', '空字符串', '""', "''", '(empty)', etc.) - const trimmed0 = rewritten.trim(); - const EMPTY_PATTERNS = [ - /^[((]?空字符串[))]?$/, - /^["'""'']{2}$/, - /^[((]?empty[))]?$/i, - /^[((]?empty string[))]?$/i, - /^[((]?无[))]?$/, - /^[((]?无内容[))]?$/, - /^[((]?无业务价值[))]?$/, - ]; - if ( - !trimmed0 || - trimmed0.length < 5 || - EMPTY_PATTERNS.some((p) => p.test(trimmed0)) - ) { - debugLogger.info(`[REWRITE OUTPUT] empty or no-value, skipping`); + // If LLM returns empty or very short, skip + if (!rewritten.trim() || rewritten.trim().length < 5) { + debugLogger.info(`[REWRITE OUTPUT] empty or too short, skipping`); return null; } + const trimmed = rewritten.trim(); + debugLogger.info( - `[REWRITE OUTPUT] len=${trimmed0.length}\n` + - `--- OUTPUT ---\n${trimmed0}\n---`, + `[REWRITE OUTPUT] len=${trimmed.length}\n` + + `--- OUTPUT ---\n${trimmed}\n---`, ); // Update context for next turn - this.lastOutput = trimmed0; + this.lastOutput = trimmed; - return trimmed0; + return trimmed; } catch (error) { debugLogger.warn( `LLM rewrite failed, skipping: ${error instanceof Error ? error.message : String(error)}`, From e05a373bbda52d11a4da7299c2ab12ad90464a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 14:51:31 +0800 Subject: [PATCH 16/25] 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. --- .../session/rewrite/LlmRewriter.ts | 5 +- packages/cli/src/nonInteractiveCli.ts | 72 +++++++++++-------- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index cd3a79235d7..ec5339c846a 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -41,10 +41,13 @@ export class LlmRewriter { /** Last successful rewrite output, used as context for next turn */ private lastOutput: string | null = null; + private readonly rewriteModel: string | undefined; + constructor( private readonly config: Config, rewriteConfig: MessageRewriteConfig, ) { + this.rewriteModel = rewriteConfig.model || undefined; // promptFile takes precedence over inline prompt if (rewriteConfig.promptFile) { const filePath = resolve(rewriteConfig.promptFile); @@ -105,7 +108,7 @@ export class LlmRewriter { return null; } - const model = this.config.getModel(); + const model = this.rewriteModel || this.config.getModel(); const result = await contentGenerator.generateContent( { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index cb9c32f5bc3..1cdde503f23 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -261,7 +261,33 @@ export async function runNonInteractive( const rewriteTarget = rewriteConfig?.target ?? 'both'; const turnBuffer = rewriter ? new TurnBuffer() : null; let rewriteTurnIndex = 0; - const pendingRewrites: Array> = []; + const pendingRewrites: Array< + Promise<{ turnIdx: number; text: string } | null> + > = []; + + /** + * Emit all settled rewrite results via the adapter. + * Must be called from the main control flow (not inside async promises) + * to avoid concurrent adapter state corruption. + */ + const emitSettledRewrites = async () => { + if (pendingRewrites.length === 0) return; + const results = await Promise.allSettled(pendingRewrites); + pendingRewrites.length = 0; + for (const r of results) { + if (r.status === 'fulfilled' && r.value) { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: r.value.text, + _meta: { rewritten: true, turnIndex: r.value.turnIdx }, + } as unknown as Parameters< + JsonOutputAdapterInterface['processEvent'] + >[0]); + adapter.finalizeAssistantMessage(); + } + } + }; if (rewriter) { debugLogger.info('Message rewrite enabled in non-interactive mode'); @@ -278,6 +304,9 @@ export async function runNonInteractive( handleMaxTurnsExceededError(config); } + // Emit any settled rewrites before starting the next turn + await emitSettledRewrites(); + const toolCallRequests: ToolCallRequestInfo[] = []; const apiStartTime = Date.now(); const responseStream = geminiClient.sendMessageStream( @@ -346,7 +375,9 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - apiStartTime; - // Rewrite turn content (async, parallel with tool execution) + // Rewrite turn content (async, parallel with tool execution). + // Only collects rewritten text — emission happens at safe boundaries + // via emitSettledRewrites() to avoid concurrent adapter state corruption. if (rewriter && turnBuffer) { const content = turnBuffer.flush(); if (content) { @@ -367,21 +398,14 @@ export async function runNonInteractive( debugLogger.info( `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, ); - adapter.startAssistantMessage(); - adapter.processEvent({ - type: GeminiEventType.Content, - value: rewritten, - _meta: { rewritten: true, turnIndex: turnIdx }, - } as unknown as Parameters< - JsonOutputAdapterInterface['processEvent'] - >[0]); - adapter.finalizeAssistantMessage(); + return { turnIdx, text: rewritten }; } } catch (err) { debugLogger.warn( `Turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, ); } + return null; })(), ); } @@ -555,7 +579,7 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - cronApiStartTime; - // Flush turn buffer and rewrite for cron path (async) + // Flush turn buffer and rewrite for cron path (async, collect only) if (rewriter && turnBuffer) { const content = turnBuffer.flush(); if (content) { @@ -576,29 +600,22 @@ export async function runNonInteractive( debugLogger.info( `Cron turn ${turnIdx}: rewritten ${rewritten.length} chars`, ); - adapter.startAssistantMessage(); - adapter.processEvent({ - type: GeminiEventType.Content, - value: rewritten, - _meta: { - rewritten: true, - turnIndex: turnIdx, - }, - } as unknown as Parameters< - JsonOutputAdapterInterface['processEvent'] - >[0]); - adapter.finalizeAssistantMessage(); + return { turnIdx, text: rewritten }; } } catch (err) { debugLogger.warn( `Cron turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, ); } + return null; })(), ); } } + // Emit settled rewrites before next cron turn + await emitSettledRewrites(); + if (cronToolCallRequests.length > 0) { const cronToolResponseParts: Part[] = []; @@ -669,11 +686,8 @@ export async function runNonInteractive( }); } - // Wait for all pending async rewrites before emitting result - if (pendingRewrites.length > 0) { - await Promise.allSettled(pendingRewrites); - pendingRewrites.length = 0; - } + // Emit all remaining rewrites before emitting result + await emitSettledRewrites(); const metrics = uiTelemetryService.getMetrics(); const usage = computeUsageFromMetrics(metrics); From ecd57e2d5a9433e8a6ee69583225970291276944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 14:53:49 +0800 Subject: [PATCH 17/25] docs: add messageRewrite configuration guide to settings.md --- docs/users/configuration/settings.md | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 6d753dfbb98..53a343f100f 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -488,6 +488,62 @@ Here is an example of a `settings.json` file with the nested structure, new as o } ``` +## Message Rewrite + +The message rewrite middleware allows you to automatically transform model output using a secondary LLM call. This is useful when the raw model output contains technical details (file paths, tool names, code snippets) that are not suitable for end users, and you want to present a cleaner, business-friendly view. + +Rewriting is **async by default** — it runs in the background parallel to tool execution, adding no latency to the session. Original messages are always sent as-is; rewritten versions are appended with a `_meta.rewritten: true` marker, allowing clients to choose which to display. + +### Configuration + +Add `messageRewrite` to your `settings.json` (user or project level): + +```json +{ + "messageRewrite": { + "enabled": true, + "target": "both", + "prompt": "Rewrite the agent output into concise user-friendly updates...", + "model": "qwen3-plus" + } +} +``` + +| Field | Type | Description | Default | +| ------------ | ------- | ----------------------------------------------------------------------------------------------- | ---------------- | +| `enabled` | boolean | Enable/disable the rewrite middleware. | `false` | +| `target` | string | Which content to accumulate for rewriting: `"message"`, `"thought"`, or `"both"`. | `"both"` | +| `prompt` | string | Inline system prompt for the rewriter LLM. | Built-in default | +| `promptFile` | string | Path to a file containing the rewrite prompt (relative to CWD). Takes precedence over `prompt`. | — | +| `model` | string | Model to use for rewriting. If empty, uses the same model as the main session. | Current model | + +### Using a prompt file + +For longer or project-specific prompts, use `promptFile`: + +```json +{ + "messageRewrite": { + "enabled": true, + "target": "both", + "promptFile": ".qwen/rewrite-prompt.txt" + } +} +``` + +### How it works + +1. During each model turn, the middleware accumulates `agent_thought_chunk` and/or `agent_message_chunk` content (based on `target`). +2. At turn boundaries (tool calls, end of response), the accumulated content is sent to the rewriter LLM with your system prompt. +3. The rewriter's output is emitted as a new `agent_message_chunk` with `_meta.rewritten: true`. +4. If the rewriter returns an empty string (e.g., for pure technical operations), no rewritten message is emitted. +5. A 30-second timeout protects against slow rewriter calls — on timeout, the rewrite is silently skipped. + +### Security + +- **Workspace trust**: In untrusted workspaces, project-level `messageRewrite` settings are ignored. Only user-level settings are applied. +- **History replay**: The rewriter is installed after session history replay, so historical messages are never re-rewritten. + ## Shell History The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder. From 412301dbba2401715db961af8c1c8b27ebcf1d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 15:00:01 +0800 Subject: [PATCH 18/25] Revert "docs: add messageRewrite configuration guide to settings.md" This reverts commit ecd57e2d5a9433e8a6ee69583225970291276944. --- docs/users/configuration/settings.md | 56 ---------------------------- 1 file changed, 56 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 53a343f100f..6d753dfbb98 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -488,62 +488,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o } ``` -## Message Rewrite - -The message rewrite middleware allows you to automatically transform model output using a secondary LLM call. This is useful when the raw model output contains technical details (file paths, tool names, code snippets) that are not suitable for end users, and you want to present a cleaner, business-friendly view. - -Rewriting is **async by default** — it runs in the background parallel to tool execution, adding no latency to the session. Original messages are always sent as-is; rewritten versions are appended with a `_meta.rewritten: true` marker, allowing clients to choose which to display. - -### Configuration - -Add `messageRewrite` to your `settings.json` (user or project level): - -```json -{ - "messageRewrite": { - "enabled": true, - "target": "both", - "prompt": "Rewrite the agent output into concise user-friendly updates...", - "model": "qwen3-plus" - } -} -``` - -| Field | Type | Description | Default | -| ------------ | ------- | ----------------------------------------------------------------------------------------------- | ---------------- | -| `enabled` | boolean | Enable/disable the rewrite middleware. | `false` | -| `target` | string | Which content to accumulate for rewriting: `"message"`, `"thought"`, or `"both"`. | `"both"` | -| `prompt` | string | Inline system prompt for the rewriter LLM. | Built-in default | -| `promptFile` | string | Path to a file containing the rewrite prompt (relative to CWD). Takes precedence over `prompt`. | — | -| `model` | string | Model to use for rewriting. If empty, uses the same model as the main session. | Current model | - -### Using a prompt file - -For longer or project-specific prompts, use `promptFile`: - -```json -{ - "messageRewrite": { - "enabled": true, - "target": "both", - "promptFile": ".qwen/rewrite-prompt.txt" - } -} -``` - -### How it works - -1. During each model turn, the middleware accumulates `agent_thought_chunk` and/or `agent_message_chunk` content (based on `target`). -2. At turn boundaries (tool calls, end of response), the accumulated content is sent to the rewriter LLM with your system prompt. -3. The rewriter's output is emitted as a new `agent_message_chunk` with `_meta.rewritten: true`. -4. If the rewriter returns an empty string (e.g., for pure technical operations), no rewritten message is emitted. -5. A 30-second timeout protects against slow rewriter calls — on timeout, the rewrite is silently skipped. - -### Security - -- **Workspace trust**: In untrusted workspaces, project-level `messageRewrite` settings are ignored. Only user-level settings are applied. -- **History replay**: The rewriter is installed after session history replay, so historical messages are never re-rewritten. - ## Shell History The CLI keeps a history of shell commands you run. To avoid conflicts between different projects, this history is stored in a project-specific directory within your user's home folder. From 418d7492a593b61ad2f6c60d1ac7a9508003145d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 15:37:52 +0800 Subject: [PATCH 19/25] feat: add contextTurns config for rewrite history context Allow configuring how many previous rewrite outputs are included as context when rewriting a new turn: - contextTurns: 1 (default) = last rewrite only - contextTurns: 0 = no context - contextTurns: N = last N rewrites - contextTurns: "all" = all previous rewrites --- .../session/rewrite/LlmRewriter.ts | 24 +++++++++++++------ .../acp-integration/session/rewrite/types.ts | 4 ++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts index ec5339c846a..01e21dea33e 100644 --- a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.ts @@ -38,8 +38,10 @@ Output only the rewritten text, or empty string if the input has no user-facing */ export class LlmRewriter { private readonly prompt: string; - /** Last successful rewrite output, used as context for next turn */ - private lastOutput: string | null = null; + /** Previous successful rewrite outputs, used as context for coherence */ + private outputHistory: string[] = []; + /** How many previous outputs to include: 0=none, N=last N, Infinity=all */ + private readonly contextTurns: number; private readonly rewriteModel: string | undefined; @@ -48,6 +50,10 @@ export class LlmRewriter { rewriteConfig: MessageRewriteConfig, ) { this.rewriteModel = rewriteConfig.model || undefined; + this.contextTurns = + rewriteConfig.contextTurns === 'all' + ? Infinity + : (rewriteConfig.contextTurns ?? 1); // promptFile takes precedence over inline prompt if (rewriteConfig.promptFile) { const filePath = resolve(rewriteConfig.promptFile); @@ -85,9 +91,13 @@ export class LlmRewriter { inputParts.push('[回复文本]\n' + turnContent.messages.join('\n')); } - // Prepend last rewrite output as context for coherence - if (this.lastOutput) { - inputParts.unshift('[上一轮改写结果]\n' + this.lastOutput); + // Prepend previous rewrite outputs as context for coherence + if (this.contextTurns > 0 && this.outputHistory.length > 0) { + const contextSlice = + this.contextTurns === Infinity + ? this.outputHistory + : this.outputHistory.slice(-this.contextTurns); + inputParts.unshift('[上一轮改写结果]\n' + contextSlice.join('\n---\n')); } const inputText = inputParts.join('\n\n'); @@ -97,7 +107,7 @@ export class LlmRewriter { if (inputText.length < 10) return null; debugLogger.info( - `[REWRITE INPUT] system_prompt_len=${this.prompt.length} input_len=${inputText.length} prev_output=${this.lastOutput ? this.lastOutput.length : 0}\n` + + `[REWRITE INPUT] system_prompt_len=${this.prompt.length} input_len=${inputText.length} context_turns=${this.outputHistory.length}\n` + `--- INPUT TEXT ---\n${inputText}\n---`, ); @@ -153,7 +163,7 @@ export class LlmRewriter { ); // Update context for next turn - this.lastOutput = trimmed; + this.outputHistory.push(trimmed); return trimmed; } catch (error) { diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts index 330dfd7480b..fa0f2fbf399 100644 --- a/packages/cli/src/acp-integration/session/rewrite/types.ts +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -20,6 +20,10 @@ export interface MessageRewriteConfig { promptFile?: string; /** Model to use for rewriting (empty = use current model) */ model?: string; + /** Number of previous rewrite outputs to include as context. + * 1 = last rewrite only (default), "all" = all previous rewrites, + * 0 = no context, N = last N rewrites. */ + contextTurns?: number | 'all'; } /** From 93c88aea3b56ceb6aed12a24cb41c2845db421b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 16:01:23 +0800 Subject: [PATCH 20/25] refactor: rename target 'both' to 'all' + add LlmRewriter unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename target value 'both' → 'all' for future extensibility (e.g. 'tool') - Add LlmRewriter tests: contextTurns (0/1/N/all), model override, filtering - Total: 35 tests across 4 test files --- .../session/rewrite/LlmRewriter.test.ts | 227 ++++++++++++++++++ .../rewrite/MessageRewriteMiddleware.test.ts | 4 +- .../rewrite/MessageRewriteMiddleware.ts | 4 +- .../session/rewrite/config.test.ts | 8 +- .../acp-integration/session/rewrite/types.ts | 2 +- packages/cli/src/nonInteractiveCli.ts | 10 +- 6 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts diff --git a/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts new file mode 100644 index 00000000000..df26f8b07f0 --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { TurnContent, MessageRewriteConfig } from './types.js'; + +// Mock core to avoid Vite https resolution issue +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +// Track generateContent calls +const mockGenerateContent = vi.fn().mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'rewritten output' }], + }, + }, + ], +}); + +const { LlmRewriter } = await import('./LlmRewriter.js'); + +function makeConfig(): Config { + return { + getContentGenerator: () => ({ + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + } as unknown as Config; +} + +function makeTurn(messages: string[], thoughts: string[] = []): TurnContent { + return { messages, thoughts, hasToolCalls: false }; +} + +describe('LlmRewriter', () => { + beforeEach(() => { + mockGenerateContent.mockClear(); + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'rewritten output' }] } }], + }); + }); + + describe('contextTurns', () => { + it('should include last rewrite output by default (contextTurns=1)', async () => { + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + } as MessageRewriteConfig); + + // First call — no context + await rewriter.rewrite(makeTurn(['first message'])); + const firstInput = + mockGenerateContent.mock.calls[0][0].contents[0].parts[0].text; + expect(firstInput).not.toContain('上一轮改写结果'); + + // Second call — should include first rewrite output + await rewriter.rewrite(makeTurn(['second message'])); + const secondInput = + mockGenerateContent.mock.calls[1][0].contents[0].parts[0].text; + expect(secondInput).toContain('上一轮改写结果'); + expect(secondInput).toContain('rewritten output'); + }); + + it('should include no context when contextTurns=0', async () => { + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + contextTurns: 0, + } as MessageRewriteConfig); + + await rewriter.rewrite(makeTurn(['first'])); + await rewriter.rewrite(makeTurn(['second'])); + + const secondInput = + mockGenerateContent.mock.calls[1][0].contents[0].parts[0].text; + expect(secondInput).not.toContain('上一轮改写结果'); + }); + + it('should include last N rewrites when contextTurns=N', async () => { + mockGenerateContent + .mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'rewrite-A' }] } }], + }) + .mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'rewrite-B' }] } }], + }) + .mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'rewrite-C' }] } }], + }) + .mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'rewrite-D' }] } }], + }); + + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + contextTurns: 2, + } as MessageRewriteConfig); + + await rewriter.rewrite(makeTurn(['msg1'])); + await rewriter.rewrite(makeTurn(['msg2'])); + await rewriter.rewrite(makeTurn(['msg3'])); + + // 4th call — should include rewrite-B and rewrite-C (last 2), not rewrite-A + await rewriter.rewrite(makeTurn(['msg4'])); + const input = + mockGenerateContent.mock.calls[3][0].contents[0].parts[0].text; + expect(input).not.toContain('rewrite-A'); + expect(input).toContain('rewrite-B'); + expect(input).toContain('rewrite-C'); + }); + + it('should include all rewrites when contextTurns="all"', async () => { + mockGenerateContent + .mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'rewrite-1' }] } }], + }) + .mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'rewrite-2' }] } }], + }) + .mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'rewrite-3' }] } }], + }); + + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + contextTurns: 'all', + } as MessageRewriteConfig); + + await rewriter.rewrite(makeTurn(['msg1'])); + await rewriter.rewrite(makeTurn(['msg2'])); + await rewriter.rewrite(makeTurn(['msg3'])); + + const input = + mockGenerateContent.mock.calls[2][0].contents[0].parts[0].text; + expect(input).toContain('rewrite-1'); + expect(input).toContain('rewrite-2'); + }); + }); + + describe('model override', () => { + it('should use rewriteConfig.model when set', async () => { + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + model: 'custom-rewrite-model', + } as MessageRewriteConfig); + + await rewriter.rewrite(makeTurn(['hello'])); + expect(mockGenerateContent.mock.calls[0][0].model).toBe( + 'custom-rewrite-model', + ); + }); + + it('should fall back to config.getModel() when model is empty', async () => { + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + } as MessageRewriteConfig); + + await rewriter.rewrite(makeTurn(['hello'])); + expect(mockGenerateContent.mock.calls[0][0].model).toBe('test-model'); + }); + }); + + describe('filtering', () => { + it('should return null for empty input', async () => { + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + } as MessageRewriteConfig); + + const result = await rewriter.rewrite(makeTurn([], [])); + expect(result).toBeNull(); + expect(mockGenerateContent).not.toHaveBeenCalled(); + }); + + it('should return null when LLM returns short text', async () => { + mockGenerateContent.mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'hi' }] } }], + }); + + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + } as MessageRewriteConfig); + + const result = await rewriter.rewrite(makeTurn(['some input text here'])); + expect(result).toBeNull(); + }); + + it('should not accumulate failed rewrites in history', async () => { + mockGenerateContent.mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: '' }] } }], + }); + mockGenerateContent.mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'second rewrite ok' }] } }], + }); + + const rewriter = new LlmRewriter(makeConfig(), { + enabled: true, + target: 'all', + } as MessageRewriteConfig); + + await rewriter.rewrite(makeTurn(['first'])); // returns null + await rewriter.rewrite(makeTurn(['second'])); + + // Second call should have no context (first rewrite returned null) + const input = + mockGenerateContent.mock.calls[1][0].contents[0].parts[0].text; + expect(input).not.toContain('上一轮改写结果'); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts index 77f93314ee3..b098df9b826 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts @@ -31,7 +31,7 @@ const { MessageRewriteMiddleware } = await import( ); function createMiddleware( - target: 'message' | 'thought' | 'both' = 'both', + target: 'message' | 'thought' | 'all' = 'all', sendUpdate?: ReturnType, ) { const mockSendUpdate = sendUpdate ?? vi.fn().mockResolvedValue(undefined); @@ -111,7 +111,7 @@ describe('MessageRewriteMiddleware', () => { }); it('should accumulate both when target is "both"', async () => { - const { middleware, mockSendUpdate } = createMiddleware('both'); + const { middleware, mockSendUpdate } = createMiddleware('all'); await middleware.interceptUpdate({ sessionUpdate: 'agent_message_chunk', diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index 503f0dd5bf7..5df87a86f2a 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -79,11 +79,11 @@ export class MessageRewriteMiddleware { // Accumulate for turn-end rewriting if (updateType === 'agent_thought_chunk') { - if (this.target === 'thought' || this.target === 'both') { + if (this.target === 'thought' || this.target === 'all') { this.turnBuffer.appendThought(text); } } else if (updateType === 'agent_message_chunk') { - if (this.target === 'message' || this.target === 'both') { + if (this.target === 'message' || this.target === 'all') { this.turnBuffer.appendMessage(text); } } diff --git a/packages/cli/src/acp-integration/session/rewrite/config.test.ts b/packages/cli/src/acp-integration/session/rewrite/config.test.ts index f9d1f7f022c..8a31ea5ecb1 100644 --- a/packages/cli/src/acp-integration/session/rewrite/config.test.ts +++ b/packages/cli/src/acp-integration/session/rewrite/config.test.ts @@ -42,12 +42,12 @@ describe('loadRewriteConfig', () => { it('should return user config when only user config is set', () => { const settings = makeSettings({ - userRewrite: { enabled: true, target: 'both', prompt: 'user prompt' }, + userRewrite: { enabled: true, target: 'all', prompt: 'user prompt' }, }); const config = loadRewriteConfig(settings); expect(config).toEqual({ enabled: true, - target: 'both', + target: 'all', prompt: 'user prompt', }); }); @@ -55,7 +55,7 @@ describe('loadRewriteConfig', () => { it('should return workspace config when trusted', () => { const settings = makeSettings({ userRewrite: { enabled: false, target: 'message' }, - workspaceRewrite: { enabled: true, target: 'both', prompt: 'ws prompt' }, + workspaceRewrite: { enabled: true, target: 'all', prompt: 'ws prompt' }, isTrusted: true, }); const config = loadRewriteConfig(settings); @@ -66,7 +66,7 @@ describe('loadRewriteConfig', () => { it('should ignore workspace config when untrusted', () => { const settings = makeSettings({ userRewrite: { enabled: false, target: 'message' }, - workspaceRewrite: { enabled: true, target: 'both', prompt: 'malicious' }, + workspaceRewrite: { enabled: true, target: 'all', prompt: 'malicious' }, isTrusted: false, }); const config = loadRewriteConfig(settings); diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts index fa0f2fbf399..afc720a6d68 100644 --- a/packages/cli/src/acp-integration/session/rewrite/types.ts +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -12,7 +12,7 @@ export interface MessageRewriteConfig { /** Whether message rewriting is enabled */ enabled: boolean; /** Which message types to rewrite */ - target: 'message' | 'thought' | 'both'; + target: 'message' | 'thought' | 'all'; /** LLM rewrite prompt (system prompt for the rewriter). Inline string. */ prompt?: string; /** Path to a file containing the rewrite prompt. Resolved relative to CWD. diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 1cdde503f23..a64f444d477 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -258,7 +258,7 @@ export async function runNonInteractive( const rewriter = rewriteConfig?.enabled ? new LlmRewriter(config, rewriteConfig) : null; - const rewriteTarget = rewriteConfig?.target ?? 'both'; + const rewriteTarget = rewriteConfig?.target ?? 'all'; const turnBuffer = rewriter ? new TurnBuffer() : null; let rewriteTurnIndex = 0; const pendingRewrites: Array< @@ -337,13 +337,13 @@ export async function runNonInteractive( if ( event.type === GeminiEventType.Content && typeof event.value === 'string' && - (rewriteTarget === 'message' || rewriteTarget === 'both') + (rewriteTarget === 'message' || rewriteTarget === 'all') ) { turnBuffer.appendMessage(event.value); } else if ( event.type === GeminiEventType.Thought && event.value && - (rewriteTarget === 'thought' || rewriteTarget === 'both') + (rewriteTarget === 'thought' || rewriteTarget === 'all') ) { const thought = event.value; const thoughtText = thought.subject @@ -552,14 +552,14 @@ export async function runNonInteractive( event.type === GeminiEventType.Content && typeof event.value === 'string' && (rewriteTarget === 'message' || - rewriteTarget === 'both') + rewriteTarget === 'all') ) { turnBuffer.appendMessage(event.value); } else if ( event.type === GeminiEventType.Thought && event.value && (rewriteTarget === 'thought' || - rewriteTarget === 'both') + rewriteTarget === 'all') ) { const thought = event.value; const thoughtText = thought.subject From 90e2d55ad7ce66d5d03ae4bf62c93fcb179327d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 16:20:38 +0800 Subject: [PATCH 21/25] refactor: remove message rewrite from non-interactive CLI mode Non-interactive mode (qwen -p "..." --output-format json) consumers are scripts/programs that don't need user-friendly rewrites. Additionally, the JSON output adapter doesn't support _meta fields, so rewritten text was silently mixed into normal assistant messages without any marker. Rewrite middleware is now ACP-only (Session path). --- packages/cli/src/nonInteractiveCli.ts | 170 -------------------------- 1 file changed, 170 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index a64f444d477..bcc0ad7b179 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -44,10 +44,6 @@ import { createAgentToolProgressHandler, computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; -import { TurnBuffer } from './acp-integration/session/rewrite/TurnBuffer.js'; -import { LlmRewriter } from './acp-integration/session/rewrite/LlmRewriter.js'; -import { loadRewriteConfig } from './acp-integration/session/rewrite/config.js'; - const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); /** @@ -253,46 +249,6 @@ export async function runNonInteractive( const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; - // Initialize message rewriter if configured - const rewriteConfig = loadRewriteConfig(settings); - const rewriter = rewriteConfig?.enabled - ? new LlmRewriter(config, rewriteConfig) - : null; - const rewriteTarget = rewriteConfig?.target ?? 'all'; - const turnBuffer = rewriter ? new TurnBuffer() : null; - let rewriteTurnIndex = 0; - const pendingRewrites: Array< - Promise<{ turnIdx: number; text: string } | null> - > = []; - - /** - * Emit all settled rewrite results via the adapter. - * Must be called from the main control flow (not inside async promises) - * to avoid concurrent adapter state corruption. - */ - const emitSettledRewrites = async () => { - if (pendingRewrites.length === 0) return; - const results = await Promise.allSettled(pendingRewrites); - pendingRewrites.length = 0; - for (const r of results) { - if (r.status === 'fulfilled' && r.value) { - adapter.startAssistantMessage(); - adapter.processEvent({ - type: GeminiEventType.Content, - value: r.value.text, - _meta: { rewritten: true, turnIndex: r.value.turnIdx }, - } as unknown as Parameters< - JsonOutputAdapterInterface['processEvent'] - >[0]); - adapter.finalizeAssistantMessage(); - } - } - }; - - if (rewriter) { - debugLogger.info('Message rewrite enabled in non-interactive mode'); - } - let isFirstTurn = true; let modelOverride: string | undefined; while (true) { @@ -304,9 +260,6 @@ export async function runNonInteractive( handleMaxTurnsExceededError(config); } - // Emit any settled rewrites before starting the next turn - await emitSettledRewrites(); - const toolCallRequests: ToolCallRequestInfo[] = []; const apiStartTime = Date.now(); const responseStream = geminiClient.sendMessageStream( @@ -332,30 +285,8 @@ export async function runNonInteractive( // Use adapter for all event processing adapter.processEvent(event); - // Accumulate for turn-end rewriting (respecting target filter) - if (turnBuffer) { - if ( - event.type === GeminiEventType.Content && - typeof event.value === 'string' && - (rewriteTarget === 'message' || rewriteTarget === 'all') - ) { - turnBuffer.appendMessage(event.value); - } else if ( - event.type === GeminiEventType.Thought && - event.value && - (rewriteTarget === 'thought' || rewriteTarget === 'all') - ) { - const thought = event.value; - const thoughtText = thought.subject - ? `${thought.subject}: ${thought.description}` - : thought.description; - if (thoughtText) turnBuffer.appendThought(thoughtText); - } - } - if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); - if (turnBuffer) turnBuffer.markToolCall(); } if ( outputFormat === OutputFormat.TEXT && @@ -375,42 +306,6 @@ export async function runNonInteractive( adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - apiStartTime; - // Rewrite turn content (async, parallel with tool execution). - // Only collects rewritten text — emission happens at safe boundaries - // via emitSettledRewrites() to avoid concurrent adapter state corruption. - if (rewriter && turnBuffer) { - const content = turnBuffer.flush(); - if (content) { - rewriteTurnIndex++; - const turnIdx = rewriteTurnIndex; - pendingRewrites.push( - (async () => { - try { - const rewriteSignal = AbortSignal.any([ - abortController.signal, - AbortSignal.timeout(30_000), - ]); - const rewritten = await rewriter.rewrite( - content, - rewriteSignal, - ); - if (rewritten) { - debugLogger.info( - `Turn ${turnIdx}: rewritten ${rewritten.length} chars`, - ); - return { turnIdx, text: rewritten }; - } - } catch (err) { - debugLogger.warn( - `Turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - return null; - })(), - ); - } - } - if (toolCallRequests.length > 0) { const toolResponseParts: Part[] = []; @@ -546,76 +441,14 @@ export async function runNonInteractive( } adapter.processEvent(event); - // Accumulate turn content for rewriting (respecting target filter) - if (turnBuffer) { - if ( - event.type === GeminiEventType.Content && - typeof event.value === 'string' && - (rewriteTarget === 'message' || - rewriteTarget === 'all') - ) { - turnBuffer.appendMessage(event.value); - } else if ( - event.type === GeminiEventType.Thought && - event.value && - (rewriteTarget === 'thought' || - rewriteTarget === 'all') - ) { - const thought = event.value; - const thoughtText = thought.subject - ? `${thought.subject}: ${thought.description}` - : thought.description; - if (thoughtText) - turnBuffer.appendThought(thoughtText); - } - } - if (event.type === GeminiEventType.ToolCallRequest) { cronToolCallRequests.push(event.value); - if (turnBuffer) turnBuffer.markToolCall(); } } adapter.finalizeAssistantMessage(); totalApiDurationMs += Date.now() - cronApiStartTime; - // Flush turn buffer and rewrite for cron path (async, collect only) - if (rewriter && turnBuffer) { - const content = turnBuffer.flush(); - if (content) { - rewriteTurnIndex++; - const turnIdx = rewriteTurnIndex; - pendingRewrites.push( - (async () => { - try { - const cronRewriteSignal = AbortSignal.any([ - abortController.signal, - AbortSignal.timeout(30_000), - ]); - const rewritten = await rewriter.rewrite( - content, - cronRewriteSignal, - ); - if (rewritten) { - debugLogger.info( - `Cron turn ${turnIdx}: rewritten ${rewritten.length} chars`, - ); - return { turnIdx, text: rewritten }; - } - } catch (err) { - debugLogger.warn( - `Cron turn ${turnIdx}: rewrite failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - return null; - })(), - ); - } - } - - // Emit settled rewrites before next cron turn - await emitSettledRewrites(); - if (cronToolCallRequests.length > 0) { const cronToolResponseParts: Part[] = []; @@ -686,9 +519,6 @@ export async function runNonInteractive( }); } - // Emit all remaining rewrites before emitting result - await emitSettledRewrites(); - const metrics = uiTelemetryService.getMetrics(); const usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output From 9fa7438311c4567cee6686b70ab6fa0d2eee26c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 14 Apr 2026 17:08:11 +0800 Subject: [PATCH 22/25] revert: restore package-lock.json and nonInteractiveCli.ts to main state --- package-lock.json | 1 + packages/cli/src/nonInteractiveCli.ts | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33f01cb65b9..1e211f6fc56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12850,6 +12850,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index bcc0ad7b179..bbd7fab7d83 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -36,6 +36,8 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; + +const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); import { normalizePartList, extractPartsFromUserMessage, @@ -44,7 +46,6 @@ import { createAgentToolProgressHandler, computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; -const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); /** * Emits a final message for slash command results. @@ -284,7 +285,6 @@ export async function runNonInteractive( } // Use adapter for all event processing adapter.processEvent(event); - if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); } @@ -440,7 +440,6 @@ export async function runNonInteractive( return; } adapter.processEvent(event); - if (event.type === GeminiEventType.ToolCallRequest) { cronToolCallRequests.push(event.value); } From 7480a782ec69c42a96748a9f617d6cfc2aa266f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Wed, 15 Apr 2026 12:28:52 +0800 Subject: [PATCH 23/25] docs: add README for message rewrite middleware Explain the feature purpose (business-oriented output customization), mark it as a temporary solution, and reference the hook-based alternative (#3266) for future discussion. --- .../acp-integration/session/rewrite/README.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/cli/src/acp-integration/session/rewrite/README.md diff --git a/packages/cli/src/acp-integration/session/rewrite/README.md b/packages/cli/src/acp-integration/session/rewrite/README.md new file mode 100644 index 00000000000..40bfff41e8a --- /dev/null +++ b/packages/cli/src/acp-integration/session/rewrite/README.md @@ -0,0 +1,40 @@ +# Message Rewrite Middleware + +ACP message rewrite middleware that transforms raw agent output (internal reasoning + reply text) into user-friendly, business-oriented formats via LLM rewriting. + +## Use Case + +When a coding agent is integrated into vertical business scenarios (data analysis, ops, report generation, etc.), the raw output often contains technical details (file paths, tool calls, internal reasoning) that end users don't care about. By configuring a rewrite prompt, the output can be transformed into business-friendly language. + +## How It Works + +1. Original messages are **passed through as-is** — no modification +2. At the end of each turn (before tool calls / at response end), accumulated thought + message chunks are sent to a separate LLM call for rewriting +3. Rewritten text is appended as a new `agent_message_chunk` with `_meta.rewritten: true` +4. The client decides which version to display based on `_meta.rewritten` + +## Configuration + +Add to `settings.json`: + +```json +{ + "messageRewrite": { + "enabled": true, + "target": "all", + "promptFile": ".qwen/rewrite-prompt.txt", + "model": "qwen3-plus", + "contextTurns": 1 + } +} +``` + +## This Is a Temporary Solution + +The current implementation lives as an ACP Session-layer middleware, coupling message interception with LLM call logic. We are also exploring a hook-based approach ([#3266](https://github.com/QwenLM/qwen-code/pull/3266) — PostTurn Hook), which would enable more decoupled turn-level post-processing and support use cases beyond rewriting. However, the hook-based approach still has some open issues, so we are using the ACP middleware approach for now. + +We welcome discussion on more elegant alternatives, including but not limited to: + +- Externalizing rewrite logic via PostTurn Hook, with the middleware only handling message routing +- Pluggable rewrite strategies (LLM / template / rule engine) +- Client-side rewriting (agent provides structured data, frontend decides how to present it) From f4876396287e075474c437f8cf85a7be6583723e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Wed, 15 Apr 2026 12:30:34 +0800 Subject: [PATCH 24/25] docs: move temporary-solution notice to top of README --- .../acp-integration/session/rewrite/README.md | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/README.md b/packages/cli/src/acp-integration/session/rewrite/README.md index 40bfff41e8a..5b9f292811f 100644 --- a/packages/cli/src/acp-integration/session/rewrite/README.md +++ b/packages/cli/src/acp-integration/session/rewrite/README.md @@ -1,6 +1,14 @@ # Message Rewrite Middleware -ACP message rewrite middleware that transforms raw agent output (internal reasoning + reply text) into user-friendly, business-oriented formats via LLM rewriting. +> **⚠️ Temporary Solution — subject to change or removal at any time.** +> +> This is a stopgap implementation. We are exploring a hook-based approach ([#3266](https://github.com/QwenLM/qwen-code/pull/3266) — PostTurn Hook) that would be more decoupled and extensible. The hook approach still has open issues, so we are using this ACP middleware for now. +> +> We welcome discussion on more elegant alternatives, including but not limited to: +> +> - Externalizing rewrite logic via PostTurn Hook, with the middleware only handling message routing +> - Pluggable rewrite strategies (LLM / template / rule engine) +> - Client-side rewriting (agent provides structured data, frontend decides how to present it) ## Use Case @@ -28,13 +36,3 @@ Add to `settings.json`: } } ``` - -## This Is a Temporary Solution - -The current implementation lives as an ACP Session-layer middleware, coupling message interception with LLM call logic. We are also exploring a hook-based approach ([#3266](https://github.com/QwenLM/qwen-code/pull/3266) — PostTurn Hook), which would enable more decoupled turn-level post-processing and support use cases beyond rewriting. However, the hook-based approach still has some open issues, so we are using the ACP middleware approach for now. - -We welcome discussion on more elegant alternatives, including but not limited to: - -- Externalizing rewrite logic via PostTurn Hook, with the middleware only handling message routing -- Pluggable rewrite strategies (LLM / template / rule engine) -- Client-side rewriting (agent provides structured data, frontend decides how to present it) From 6a64589207960a47a7547f421828add45e71558c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Wed, 15 Apr 2026 13:54:44 +0800 Subject: [PATCH 25/25] docs: simplify temporary-solution notice in rewrite README --- .../cli/src/acp-integration/session/rewrite/README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/cli/src/acp-integration/session/rewrite/README.md b/packages/cli/src/acp-integration/session/rewrite/README.md index 5b9f292811f..b5cb6eb6e67 100644 --- a/packages/cli/src/acp-integration/session/rewrite/README.md +++ b/packages/cli/src/acp-integration/session/rewrite/README.md @@ -2,13 +2,7 @@ > **⚠️ Temporary Solution — subject to change or removal at any time.** > -> This is a stopgap implementation. We are exploring a hook-based approach ([#3266](https://github.com/QwenLM/qwen-code/pull/3266) — PostTurn Hook) that would be more decoupled and extensible. The hook approach still has open issues, so we are using this ACP middleware for now. -> -> We welcome discussion on more elegant alternatives, including but not limited to: -> -> - Externalizing rewrite logic via PostTurn Hook, with the middleware only handling message routing -> - Pluggable rewrite strategies (LLM / template / rule engine) -> - Client-side rewriting (agent provides structured data, frontend decides how to present it) +> This is a stopgap implementation. We are considering a hook-based approach that would be more decoupled and extensible. Ideas and suggestions for a better design are very welcome. ## Use Case