fix(cli): stop internal agent events from leaking into chat as raw JSON (#1165)

* fix(cli): drop unknown SDK message types instead of passing them through

The SDK-to-log converter's switch had a fail-open default that stamped any
unrecognized SDK message with transcript base fields (parentUuid/sessionId/
userType) and forwarded it. Claude Code emits a tool_progress heartbeat every
30s for long-running tools, so a single slow Bash call flooded the chat: the
web normalizer matches no known shape for those records and falls back to
rendering the raw envelope as message text.

Gate the switch on an explicit allowlist instead, bailing before the uuid is
allocated so a dropped event cannot advance sidechain/parent tracking -- the
heartbeats share one parent_tool_use_id and were overwriting the pointer a
subagent's next real message parents to. This matches the local launcher,
which already enforces the same allowlist via RawJSONLinesSchema.safeParse.

The default branch stays as a fail-closed guard so adding a type to the
allowlist without a matching case drops the message rather than leaking it.

* fix(cli): re-check reassembled text for internal event JSON at flush boundary

isInternalEventJson was only applied per incoming chunk. In delta mode
(OpenCode) every chunk is a fragment, so none of them parses as JSON on its
own and the filter never fires; the pieces accumulate and flushText emits the
reassembled envelope verbatim. The dedupe path has the same hole whenever two
chunks share no overlap.

Check again in flushText, which is the first point the complete text exists,
and tolerate surrounding whitespace so an envelope preceded by a newline is
not waved through by the leading-'{' fast path.

Genuine assistant output that happens to be JSON is unaffected: the matcher
still requires the specific { type: 'output', data: { parentUuid, sessionId,
userType } } envelope shape.

* fix(cli): fail closed on unrecognized agent message in converter

convertAgentMessage's exhaustiveness default returned the message object
itself at runtime. The never binding makes the branch unreachable today, but
every caller forwards a non-null result straight into the chat stream, so the
failure mode if it were ever reached is a raw object on screen. Keep the
compile-time check, return null at runtime.

* test(cli): cover command_lifecycle, a second unknown type seen leaking

Observed in the same session after tool_progress. The allowlist already
covered it with no code change, which is the argument for gating on known
types rather than adding a case per offender.
This commit is contained in:
Haoqing Wang
2026-07-26 02:46:29 +08:00
committed by GitHub
parent 8eac26726b
commit 6bddc9d044
7 changed files with 264 additions and 11 deletions
@@ -1067,6 +1067,84 @@ describe('AcpMessageHandler', () => {
expect((messages[0] as { text: string }).text).toMatch(/^Claude AI usage limit warning\|/);
});
it('drops a metadata envelope split across delta chunks', () => {
// In delta mode every chunk is a fragment, so no individual chunk ever
// parses as JSON and the per-chunk filter never fires. Only the flush
// boundary sees the reassembled envelope.
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler(
(message) => messages.push(message),
{ textChunkMode: 'delta' }
);
const metadataJson = JSON.stringify({
type: 'output',
data: {
parentUuid: null,
isSidechain: true,
userType: 'external',
sessionId: '5605239b-3ca8-4cf4-bf06-a234f7984f2f',
type: 'tool_progress',
tool_name: 'Bash',
elapsed_time_seconds: 30,
heartbeat: true,
},
});
for (let i = 0; i < metadataJson.length; i += 17) {
handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: { type: 'text', text: metadataJson.slice(i, i + 17) }
});
}
handler.flushText();
expect(messages).toEqual([]);
});
it('drops a metadata envelope that arrives with leading whitespace', () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
const metadataJson = JSON.stringify({
type: 'output',
data: {
parentUuid: null,
sessionId: 'session-789',
userType: 'external',
},
});
handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: { type: 'text', text: `\n ${metadataJson}` }
});
handler.flushText();
expect(messages).toEqual([]);
});
it('still emits genuine assistant text that happens to be JSON', () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler(
(message) => messages.push(message),
{ textChunkMode: 'delta' }
);
const answer = '{"name":"hapi","version":"0.23.4"}';
for (const chunk of [answer.slice(0, 10), answer.slice(10)]) {
handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: { type: 'text', text: chunk }
});
}
handler.flushText();
expect(messages).toEqual([{ type: 'text', text: answer }]);
});
it('forwards agent_thought_chunk as a reasoning message after flush', () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
@@ -419,6 +419,14 @@ export class AcpMessageHandler {
* buffer. Callers must treat this as a text-segment boundary: it is
* invoked internally before tool_call / plan events and externally at
* turn boundaries by AcpSdkBackend.
*
* The internal-event check in `handleUpdate` only sees one chunk at a
* time, so it cannot recognise an envelope that arrived in pieces — in
* `delta` mode (OpenCode) every chunk is a fragment and none of them
* parses as JSON on its own. This flush boundary is the first place the
* reassembled text exists, so it is the only place a split envelope can
* be caught. Re-checking here is what makes the filter complete rather
* than merely likely to fire.
*/
flushText(): void {
if (!this.bufferedText) {
@@ -426,6 +434,9 @@ export class AcpMessageHandler {
}
const text = this.bufferedText;
this.bufferedText = '';
if (isInternalEventJson(text)) {
return;
}
this.onMessage({ type: 'text', text });
}
+6 -4
View File
@@ -8,15 +8,17 @@
* We match on the specific structure rather than a broad type allowlist to
* avoid accidentally suppressing legitimate assistant JSON.
*
* Only called for text that starts with '{', so the fast-path for normal
* prose has zero overhead.
* Surrounding whitespace is tolerated: the envelope is also checked at the
* text-flush boundary, where it may have been reassembled from chunks that
* carried a leading newline or indentation.
*/
export function isInternalEventJson(text: string): boolean {
if (text[0] !== '{') return false;
const trimmed = text.trim();
if (trimmed[0] !== '{') return false;
let parsed: unknown;
try {
parsed = JSON.parse(text);
parsed = JSON.parse(trimmed);
} catch {
return false;
}
+6
View File
@@ -106,4 +106,10 @@ describe('convertAgentMessage', () => {
}
});
});
it('returns null instead of echoing an unrecognized message shape', () => {
// Unreachable through the type system, but callers forward any non-null
// result straight into the chat stream — so the runtime contract has to
// be fail-closed.
expect(convertAgentMessage({ type: 'not_a_real_type' } as never)).toBeNull();
});
});
+8 -1
View File
@@ -87,8 +87,15 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null
case 'turn_complete':
return null;
default: {
// Unreachable while every AgentMessage variant is handled above —
// the `never` binding is what enforces that at compile time. The
// runtime return is deliberately `null` rather than the message
// itself: callers forward a non-null result straight into the chat
// stream, so echoing an unrecognized shape here would put a raw
// object on screen instead of failing closed.
const _exhaustive: never = message;
return _exhaustive;
void _exhaustive;
return null;
}
}
}