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 b098df9b8..82c129905 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.test.ts @@ -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(); + } + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts index 5df87a86f..d698c79a8 100644 --- a/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts +++ b/packages/cli/src/acp-integration/session/rewrite/MessageRewriteMiddleware.ts @@ -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; diff --git a/packages/cli/src/acp-integration/session/rewrite/README.md b/packages/cli/src/acp-integration/session/rewrite/README.md index b5cb6eb6e..ad40314be 100644 --- a/packages/cli/src/acp-integration/session/rewrite/README.md +++ b/packages/cli/src/acp-integration/session/rewrite/README.md @@ -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. diff --git a/packages/cli/src/acp-integration/session/rewrite/types.ts b/packages/cli/src/acp-integration/session/rewrite/types.ts index afc720a6d..081c8ff4b 100644 --- a/packages/cli/src/acp-integration/session/rewrite/types.ts +++ b/packages/cli/src/acp-integration/session/rewrite/types.ts @@ -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; } /**