-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(acp): LLM-based message rewrite middleware with custom prompts #3191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
8b16bfa
feat(acp): LLM-based message rewrite middleware
zhangxy-zju 554b7a1
fix: TypeScript 编译错误修复 + 优化默认改写 prompt(参考竞品风格)
zhangxy-zju fbb8899
fix: 从 user/workspace originalSettings 读取 messageRewrite 配置(绕过 schema…
zhangxy-zju 56706f6
feat: 非交互 CLI 模式也支持 message rewrite(eval 可用)
zhangxy-zju 937369c
fix: 禁用 rewriter LLM 的 thinking,过滤 thought 部分只取纯文本输出
zhangxy-zju 65a00ea
fix: cron 路径补齐 message rewrite flush + 代码质量优化
zhangxy-zju 823ac2d
feat: rewrite 支持 async/sync 模式(默认 async,不增加执行时间)
zhangxy-zju 42696c7
feat: rewrite prompt 通用化 + 上下文连贯 + promptFile + async 修复
zhangxy-zju 242c0dd
refactor: remove sync rewrite mode, always use async (non-blocking) r…
zhangxy-zju 3e514b7
fix: address review feedback — trust check, timeout, history replay
zhangxy-zju 617b466
fix: address second round review — target filter, timeout, rewrite queue
zhangxy-zju a4cfb4c
test: add unit tests for TurnBuffer, loadRewriteConfig, MessageRewrit…
zhangxy-zju 97bd826
fix: config.test.ts use unknown cast for LoadedSettings stub (fix tsc…
zhangxy-zju b8d7400
fix: filter LLM literal "empty string" responses in rewriter output
zhangxy-zju 288927f
revert: remove LLM empty-string pattern defense, rely on prompt fix i…
zhangxy-zju e05a373
fix: prevent async rewrite from corrupting adapter state + honor conf…
zhangxy-zju ecd57e2
docs: add messageRewrite configuration guide to settings.md
zhangxy-zju 412301d
Revert "docs: add messageRewrite configuration guide to settings.md"
zhangxy-zju 418d749
feat: add contextTurns config for rewrite history context
zhangxy-zju 93c88ae
refactor: rename target 'both' to 'all' + add LlmRewriter unit tests
zhangxy-zju 90e2d55
refactor: remove message rewrite from non-interactive CLI mode
zhangxy-zju 9fa7438
revert: restore package-lock.json and nonInteractiveCli.ts to main state
zhangxy-zju 7480a78
docs: add README for message rewrite middleware
zhangxy-zju f487639
docs: move temporary-solution notice to top of README
zhangxy-zju 6a64589
docs: simplify temporary-solution notice in rewrite README
zhangxy-zju File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
227 changes: 227 additions & 0 deletions
227
packages/cli/src/acp-integration/session/rewrite/LlmRewriter.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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('上一轮改写结果'); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.