feat(cli): make ACP message rewrite timeout configurable (#3475)

* feat(cli): make ACP message rewrite timeout configurable

The rewrite LLM call timeout was hardcoded to 30s. For business
scenarios where the final turn contains a large KPI table or
report body, that call can exceed 30s and get aborted silently —
losing the user-visible conclusion.

Adds optional `timeoutMs` to `MessageRewriteConfig` (default 30000)
so large/slow rewrites can be tuned per deployment.

Fixes #3474

* docs(cli): translate timeoutMs note to English, fix code block
This commit is contained in:
zhangxy-zju 2026-04-20 20:58:58 +08:00 committed by GitHub
parent bf561fa495
commit 4d1d430390
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 3 deletions

View file

@ -203,4 +203,102 @@ describe('MessageRewriteMiddleware', () => {
expect(meta['turnIndex']).toBe(1);
});
});
describe('timeoutMs config', () => {
it('should use configured timeoutMs for the rewrite abort signal', async () => {
vi.useFakeTimers();
try {
const capturedSignals: AbortSignal[] = [];
const { LlmRewriter } = await import('./LlmRewriter.js');
(
LlmRewriter as unknown as {
mockImplementation: (fn: unknown) => void;
}
).mockImplementation(() => ({
rewrite: vi.fn((_content: unknown, signal: AbortSignal) => {
capturedSignals.push(signal);
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new Error('aborted')),
);
});
}),
}));
const mockSendUpdate = vi.fn().mockResolvedValue(undefined);
const middleware = new MessageRewriteMiddleware(
{} as Config,
{
enabled: true,
target: 'all',
prompt: 'test prompt',
timeoutMs: 5_000,
},
mockSendUpdate,
);
await middleware.interceptUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'content' },
} as unknown as SessionUpdate);
await middleware.flushTurn();
expect(capturedSignals).toHaveLength(1);
expect(capturedSignals[0].aborted).toBe(false);
// Advance past the configured 5s timeout
await vi.advanceTimersByTimeAsync(5_100);
expect(capturedSignals[0].aborted).toBe(true);
await middleware.waitForPendingRewrites();
} finally {
vi.useRealTimers();
}
});
it('should default to 30s when timeoutMs is not provided', async () => {
vi.useFakeTimers();
try {
const capturedSignals: AbortSignal[] = [];
const { LlmRewriter } = await import('./LlmRewriter.js');
(
LlmRewriter as unknown as {
mockImplementation: (fn: unknown) => void;
}
).mockImplementation(() => ({
rewrite: vi.fn((_content: unknown, signal: AbortSignal) => {
capturedSignals.push(signal);
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new Error('aborted')),
);
});
}),
}));
const mockSendUpdate = vi.fn().mockResolvedValue(undefined);
const middleware = new MessageRewriteMiddleware(
{} as Config,
{ enabled: true, target: 'all', prompt: 'test prompt' },
mockSendUpdate,
);
await middleware.interceptUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'content' },
} as unknown as SessionUpdate);
await middleware.flushTurn();
expect(capturedSignals).toHaveLength(1);
await vi.advanceTimersByTimeAsync(29_000);
expect(capturedSignals[0].aborted).toBe(false);
await vi.advanceTimersByTimeAsync(1_500);
expect(capturedSignals[0].aborted).toBe(true);
await middleware.waitForPendingRewrites();
} finally {
vi.useRealTimers();
}
});
});
});

View file

@ -27,10 +27,13 @@ const debugLogger = createDebugLogger('MESSAGE_REWRITE');
* LlmRewriter rewrites the accumulated content
* 4. Rewritten text is emitted as agent_message_chunk with _meta.rewritten=true
*/
const DEFAULT_REWRITE_TIMEOUT_MS = 30_000;
export class MessageRewriteMiddleware {
private readonly turnBuffer: TurnBuffer;
private readonly rewriter: LlmRewriter;
private readonly target: MessageRewriteConfig['target'];
private readonly timeoutMs: number;
private turnIndex = 0;
constructor(
@ -41,6 +44,7 @@ export class MessageRewriteMiddleware {
this.turnBuffer = new TurnBuffer();
this.rewriter = new LlmRewriter(config, rewriteConfig);
this.target = rewriteConfig.target;
this.timeoutMs = rewriteConfig.timeoutMs ?? DEFAULT_REWRITE_TIMEOUT_MS;
}
/**
@ -109,8 +113,8 @@ 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);
// Always enforce a timeout, combined with caller's signal if provided
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
const rewriteSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;

View file

@ -26,7 +26,10 @@ Add to `settings.json`:
"target": "all",
"promptFile": ".qwen/rewrite-prompt.txt",
"model": "qwen3-plus",
"contextTurns": 1
"contextTurns": 1,
"timeoutMs": 60000
}
}
```
`timeoutMs` sets the per-rewrite LLM call timeout in milliseconds. Defaults to 30000.

View file

@ -24,6 +24,8 @@ export interface MessageRewriteConfig {
* 1 = last rewrite only (default), "all" = all previous rewrites,
* 0 = no context, N = last N rewrites. */
contextTurns?: number | 'all';
/** Per-rewrite LLM call timeout in milliseconds. Defaults to 30000 (30s). */
timeoutMs?: number;
}
/**