From 460c45af3020cdf1f78494164dbc9ffddb350352 Mon Sep 17 00:00:00 2001 From: Tim Visher <194828183+timvisher-dd@users.noreply.github.com> Date: Thu, 21 May 2026 09:15:13 -0400 Subject: [PATCH] fix: no-op ping keep-alive events in streamEventToAcpNotifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic Messages streaming API emits `ping` keep-alive events during long generations, but the SDK's `BetaRawMessageStreamEvent` union doesn't list them. They reach `streamEventToAcpNotifications` at runtime and fall through to `unreachable`, which since PR #173 no longer crashes but writes "Unexpected case: {\"type\":\"ping\"}" to stderr — visible as Notices spam in ACP clients (e.g. agent-shell). Add `case "ping" as never:` to the existing no-content branch. The `as never` cast is required because the SDK union is closed at the type level. Test asserts both that the return is `[]` and that the logger's `error` channel is never touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/acp-agent.ts | 6 +++++- src/tests/acp-agent.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 6466e750..abb587d1 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -3207,7 +3207,11 @@ export function streamEventToAcpNotifications( taskState: options?.taskState, }, ); - // No content + // No content. `ping` is a Messages-API keep-alive event that the SDK's + // `BetaRawMessageStreamEvent` union doesn't include even though the + // wire format emits it; the `as never` cast lets us no-op it here + // instead of letting it fall through to `unreachable`. + case "ping" as never: case "message_start": case "message_delta": case "message_stop": diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index b01dbe77..85d79ca0 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -32,6 +32,7 @@ import { ClaudeAcpAgent, claudeCliPath, describeAlwaysAllow, + streamEventToAcpNotifications, type SDKMessageFilter, } from "../acp-agent.js"; import { Pushable } from "../utils.js"; @@ -3872,3 +3873,38 @@ describe("post-error recovery", () => { expect(session.pendingMessages.size).toBe(0); }); }); + +describe("streamEventToAcpNotifications", () => { + it("treats `ping` keep-alive events as no-ops without logging to stderr", () => { + const errors: unknown[][] = []; + const logger = { + log: () => {}, + error: (...args: unknown[]) => { + errors.push(args); + }, + }; + const pingMessage = { + type: "stream_event", + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + // The SDK's typed `BetaRawMessageStreamEvent` union doesn't include + // `ping`, but the API emits it on the wire and the SDK passes it + // through. Cast through `unknown` to feed the realistic runtime shape. + event: { type: "ping" } as unknown, + } as Parameters[0]; + + const result = streamEventToAcpNotifications( + pingMessage, + "test-session", + {}, + { sessionUpdate: async () => {} } as unknown as Parameters< + typeof streamEventToAcpNotifications + >[3], + logger, + ); + + expect(result).toEqual([]); + expect(errors).toEqual([]); + }); +});