Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/__tests__/acp-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
type AcpBackendClient,
type AcpBackendClientFactoryContext,
type AcpBackendCreateSessionInput,
type AcpBackendInitializeResult,

Check warning on line 11 in src/__tests__/acp-backend.test.ts

View workflow job for this annotation

GitHub Actions / lint

'AcpBackendInitializeResult' is defined but never used. Allowed unused vars must match /^_/u
type AcpBackendSessionService,
type AcpCreateSessionInput,
type AcpJsonRpcInboundRequest,
Expand Down Expand Up @@ -661,10 +661,11 @@
await backend.createSession({ ...scope, cwd });
const result = await backend.sendPrompt('session-1', 'hello', scope);

// Issue #4705: timeout means CC did not ack → delivered:false
expect(result.delivered).toBe(false);
// Timeout = slow ack on a live pipe, not non-delivery (request was written;
// claude-agent-acp processes it in order). Transcript is source of truth.
expect(result.delivered).toBe(true);
expect(result.attempts).toBe(1);
expect(result.error).toBe('prompt_ack_timeout');
expect(result.error).toBeUndefined();
});

it('sendPrompt surfaces -32601 Method not found errors (#3479, #4705)', async () => {
Expand Down
48 changes: 38 additions & 10 deletions src/__tests__/acp-sendprompt-timeout-4705.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
*
* Issue #4705: API /v1/sessions/:id/send accepts message but never forwards to CC runtime.
*
* Root cause: sendPrompt treats AcpJsonRpcTimeoutError as "delivered: true" when the
* JSON-RPC request to session/prompt times out. The timeout means CC did not acknowledge
* the prompt within 5s — the message may not have been delivered.
* Original fix (#4705): a session/prompt timeout → delivered:false (CC did not ack).
* Revised 2026-06-26: empirically the request IS written to a live JSON-RPC pipe and
* claude-agent-acp (single-threaded, in-order) processes it; the ack merely lags past
* the window on idle/cold-resumed sessions, and the reply always appears in the
* transcript. A dead pipe rejects with a transport error, never a timeout. So a
* timeout is now treated as a slow ack, not a non-delivery.
*
* Acceptance criteria:
* - Timeout on session/prompt → delivered: false with error 'prompt_ack_timeout'
* - Timeout on session/prompt → delivered: true (slow ack; transcript is source of truth)
* - Actual error (e.g. -32601 Method not found) → thrown as AcpBackendLifecycleError
* - Successful ack → delivered: true
*/
Expand Down Expand Up @@ -41,12 +44,12 @@ const scope: AcpSessionScope = {
const cwd = '/tmp/test-workspace';

describe('Issue #4705: sendPrompt timeout handling', () => {
it('returns delivered:false when session/prompt times out', async () => {
it('returns delivered:true when session/prompt times out (slow ack, not non-delivery)', async () => {
const service = new FakeSessionService();
const client = new FakeBackendClient();
client.setResult('initialize', {});
client.setResult('session/new', { sessionId: 'acp-agent-session-1' });
// session/prompt will timeout
// session/prompt will timeout — request was still written to a live pipe.
client.setTimeout('session/prompt', 50);

const backend = new AcpBackend({
Expand All @@ -59,11 +62,9 @@ describe('Issue #4705: sendPrompt timeout handling', () => {

const result = await backend.sendPrompt('session-1', 'hello', scope, cwd);

// BUG: currently returns delivered: true on timeout
// FIX: should return delivered: false with timeout error
expect(result.delivered).toBe(false);
expect(result.delivered).toBe(true);
expect(result.attempts).toBe(1);
expect(result.error).toBe('prompt_ack_timeout');
expect(result.error).toBeUndefined();
}, 10000);

it('returns delivered:true when session/prompt acks within timeout', async () => {
Expand Down Expand Up @@ -107,6 +108,33 @@ describe('Issue #4705: sendPrompt timeout handling', () => {
});
});

/**
* Direct sendPrompt: a session/prompt timeout on a live runtime surfaces as
* delivered:true (the request was written to a live JSON-RPC pipe; the ack
* merely lagged). This covers the continue-conversation case where an idle
* session acks slowly — the dashboard must not show a false error toast.
*/
describe('sendPrompt timeout on a live runtime', () => {
it('returns delivered:true when session/prompt times out', async () => {
const client = new FakeBackendClient();
client.setTimeout('session/prompt', 50);
const runtime = { client } as unknown as import('../services/acp/backend/types.js').AcpBackendRuntime;
const deps = {
sessionService: { getSession: async () => ({ acpAgentSessionId: 'acp-agent-session-1' }) },
inFlightPrompts: new Map<string, AbortController>(),
pendingHandshakes: new Map<string, unknown>(),
} as unknown as import('../services/acp/backend/prompts.js').PromptDeps;

const { sendPrompt } = await import('../services/acp/backend/prompts.js');

const result = await sendPrompt(deps, runtime, 'session-1', 'hello', scope);

expect(result.delivered).toBe(true);
expect(result.attempts).toBe(1);
expect(result.error).toBeUndefined();
}, 10000);
});

// Fake client that supports timeout and error simulation
class FakeBackendClient implements AcpBackendClient {
readonly requests: { method: string; params: AcpJsonValue | undefined }[] = [];
Expand Down
16 changes: 11 additions & 5 deletions src/services/acp/backend/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,24 @@ export async function sendPrompt(
return { delivered: false, attempts: 0, error: 'no_agent_session' };
}

// Issue #4705: Fix timeout handling — timeout means CC did not ack,
// so the prompt was NOT delivered. Return delivered:false with timeout error.
// Actual JSON-RPC errors (e.g. -32601 Method not found) are thrown to caller.
// Issue #4705: a session/prompt timeout originally meant "CC did not ack,
// treat as not delivered". In practice the request is written to a live
// JSON-RPC pipe and claude-agent-acp (single-threaded, in-order) DOES
// process it — the ack simply lags past the window on idle/cold-resumed
// sessions (verified: the reply appears in the transcript on every
// observed timeout). A dead pipe rejects with a transport error, never a
// timeout. So a timeout is a slow ack, not a non-delivery: surface
// delivered:true and let the transcript be the source of truth. Actual
// JSON-RPC errors (e.g. -32601 Method not found) are still thrown.
try {
await runtime.client.request('session/prompt', {
sessionId: acpSessionId,
prompt: [{ type: 'text', text }],
}, { timeoutMs: ACP_PROMPT_ACK_TIMEOUT_MS });
} catch (err) {
if (err instanceof Error && err.name === 'AcpJsonRpcTimeoutError') {
log.warn({ component: 'acp-backend', operation: 'promptAckTimeout', attributes: { sessionId } });
return { delivered: false, attempts: 1, error: 'prompt_ack_timeout' };
log.warn({ component: 'acp-backend', operation: 'promptAckTimeoutAccepted', attributes: { sessionId } });
return { delivered: true, attempts: 1 };
} else {
throw err;
}
Expand Down
Loading