fix(cli): surface real Cursor ACP session/load errors (#1198)

* fix(cli): surface real Cursor ACP session/load errors

Stop mislabeling every ACP session/load failure as a legacy
stream-json protocol problem. Prefer Cursor's Cannot use this model
stderr (including Available models when present), attach drained
stderr on process close, and keep structured formatAcpLoadError logs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): accumulate ACP stderr across split chunks

Address Codex Major on #1198: child_process stderr data events are not
message boundaries. Concatenate raw chunks in a rolling window, extract
Cannot use this model from the window on close, and prefer that over a
partial onStderrError hint when classifying resume failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): pin ACP model-rejection stderr when catalog overflows

Once Cannot use this model appears, keep the buffer from that match
head so a long Available models list cannot roll the rejection out of
the rolling window (Codex follow-up on #1198).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): wait for model id before ACP model-rejection emit

Only emit Cannot use this model via onStderrError once a non-space
token follows the colon, so a split before the id cannot suppress the
completed rolling-window message (Codex Minor on #1198).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): block ACP writes between process exit and close

Keep the post-exit stdin write guard while deferring markClosed until
stdio close so stderr can still enrich the failure (Codex Minor on #1198).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-29 10:05:41 +08:00
committed by GitHub
co-authored by Cursor
parent 4c203f17cb
commit d8aa8736cd
4 changed files with 494 additions and 20 deletions
+99 -10
View File
@@ -135,7 +135,7 @@ vi.mock('@/ui/logger', () => ({
logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn() }
}));
import { cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher';
import { classifyCursorAcpLoadError, cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher';
import { createCursorAcpBackend } from './utils/cursorAcpBackend';
import { CursorSession } from './session';
import { ApiSessionClient } from '@/api/apiSession';
@@ -218,13 +218,28 @@ describe('cursorAcpRemoteLauncher', () => {
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Cursor ACP mode is required for new Cursor remote sessions/
);
expect(client.sendAgentMessage).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('agent acp not found')
});
expect(legacyLauncher).not.toHaveBeenCalled();
expect(harness.newSessionCalled).toBe(false);
expect(client.sendAgentMessage).toHaveBeenCalled();
});
it('surfaces Cursor model rejection during initialize instead of the generic ACP-required message', async () => {
harness.initializeError = new Error(
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto'
);
const session = makeSession(null);
const error = await cursorAcpRemoteLauncher(session).then(
() => null,
(err: unknown) => err
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/^Failed to start Cursor ACP session: Cannot use this model: grok-4\.5\[fast=true\]/
);
expect((error as Error).message).toMatch(/Available models: auto/);
expect((error as Error).message).not.toMatch(/Cursor ACP mode is required/);
expect((error as Error).message).not.toMatch(/Legacy stream-json/);
expect(legacyLauncher).not.toHaveBeenCalled();
});
it('registers cursorSessionId before session/load completes', async () => {
@@ -252,15 +267,38 @@ describe('cursorAcpRemoteLauncher', () => {
harness.loadSessionError = new Error('session not found');
const session = makeSession('old-stream-json-id');
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Legacy stream-json sessions cannot be loaded via ACP/
const error = await cursorAcpRemoteLauncher(session).then(
() => null,
(err: unknown) => err
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/Failed to resume Cursor ACP session: session not found/);
expect((error as Error).message).not.toMatch(/Legacy stream-json/);
expect(harness.loadSessionCalled).toBe(true);
expect(harness.newSessionCalled).toBe(false);
expect(legacyLauncher).not.toHaveBeenCalled();
});
it('surfaces Cursor model rejection from session/load instead of claiming legacy protocol', async () => {
harness.loadSessionError = new Error(
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto, cursor-grok-4.5-high-fast'
);
const session = makeSession('acp-thread-1');
const error = await cursorAcpRemoteLauncher(session).then(
() => null,
(err: unknown) => err
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/Cannot use this model: grok-4\.5\[fast=true\]/);
expect((error as Error).message).toMatch(/Available models: auto, cursor-grok-4\.5-high-fast/);
expect((error as Error).message).not.toMatch(/Legacy stream-json/);
expect(harness.newSessionCalled).toBe(false);
expect(legacyLauncher).not.toHaveBeenCalled();
});
it('throws when resume id is set but session/load is unsupported', async () => {
harness.supportsLoadSession = false;
const session = makeSession('some-session-id');
@@ -297,12 +335,63 @@ describe('cursorAcpRemoteLauncher', () => {
const session = makeSession('old-stream-json-id');
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Legacy stream-json sessions cannot be loaded via ACP/
/Failed to resume Cursor ACP session: session not found/
);
expect(session.client.emitSessionReady).not.toHaveBeenCalled();
});
describe('classifyCursorAcpLoadError', () => {
it('prefers Cannot use this model text from the underlying error', () => {
const message = classifyCursorAcpLoadError(
new Error('ACP process exited (code=1, signal=null). stderr: Cannot use this model: grok-4.5[fast=true]. Available models: auto, composer-2.5')
);
expect(message).toContain('Cannot use this model: grok-4.5[fast=true]');
expect(message).toContain('Available models: auto, composer-2.5');
expect(message).not.toMatch(/Legacy stream-json/);
});
it('uses recentStderr hint when exit error omits the model line', () => {
const message = classifyCursorAcpLoadError(
new Error('ACP process exited (code=1, signal=null)'),
{ recentStderr: 'Cannot use this model: stale-id. Available models: auto' }
);
expect(message).toContain('Cannot use this model: stale-id');
expect(message).toContain('Available models: auto');
expect(message).not.toMatch(/Legacy stream-json/);
});
it('prefers accumulated close stderr over a partial recentStderr hint', () => {
const message = classifyCursorAcpLoadError(
new Error(
'ACP process exited (code=1, signal=null). stderr: Cannot use this model: full-id. Available models: auto, composer-2.5'
),
{ recentStderr: 'Cannot use this mo' }
);
expect(message).toContain('Cannot use this model: full-id');
expect(message).toContain('Available models: auto, composer-2.5');
expect(message).not.toContain('Cannot use this mo:');
});
it('propagates generic load failures without inventing a legacy diagnosis', () => {
const message = classifyCursorAcpLoadError(new Error('Session "abc" not found'));
expect(message).toBe('Failed to resume Cursor ACP session: Session "abc" not found');
expect(message).not.toMatch(/Legacy stream-json/);
});
it('uses start action prefix for spawn-time model rejection', () => {
const message = classifyCursorAcpLoadError(
new Error('ACP process exited (code=1, signal=null)'),
{
recentStderr: 'Cannot use this model: stale-id. Available models: auto',
action: 'start'
}
);
expect(message).toMatch(/^Failed to start Cursor ACP session: Cannot use this model: stale-id/);
expect(message).not.toMatch(/Failed to resume/);
});
});
// tiann/hapi#913: fresh ACP sessions previously persisted `cursorSessionId`
// via fire-and-forget `updateMetadata`. A SIGTERM within ~1s of the first
// turn (hub-restart cascade) could strand the session because the ACK