Files
hapi/cli/src/agent/messageConverter.test.ts
T
Haoqing WangandGitHub 6bddc9d044 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.
2026-07-26 02:46:29 +08:00

116 lines
3.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { convertAgentMessage } from './messageConverter';
describe('convertAgentMessage', () => {
it('keeps tool-call status when converting ACP tool events', () => {
const converted = convertAgentMessage({
type: 'tool_call',
id: 'call-1',
name: 'Bash',
input: { cmd: 'echo test' },
status: 'completed'
});
expect(converted).toEqual({
type: 'tool-call',
callId: 'call-1',
name: 'Bash',
input: { cmd: 'echo test' },
status: 'completed'
});
});
it('preserves ACP native presentation metadata', () => {
const converted = convertAgentMessage({
type: 'tool_call',
id: 'call-native',
name: 'Bash',
input: { command: 'free -h' },
status: 'in_progress',
title: 'Shell: free -h',
kind: 'execute'
});
expect(converted).toMatchObject({
nativeTitle: 'Shell: free -h',
nativeKind: 'execute'
});
});
it('marks failed tool results as error', () => {
const converted = convertAgentMessage({
type: 'tool_result',
id: 'call-2',
output: { message: 'boom' },
status: 'failed'
});
expect(converted).toEqual({
type: 'tool-call-result',
callId: 'call-2',
output: { message: 'boom' },
is_error: true
});
});
it('preserves stable reasoning id when provided', () => {
const converted = convertAgentMessage({
type: 'reasoning',
text: 'thinking',
id: 'reasoning-stream-1'
});
expect(converted).toEqual({
type: 'reasoning',
message: 'thinking',
id: 'reasoning-stream-1'
});
});
it('converts agent errors into error wire payloads', () => {
const converted = convertAgentMessage({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});
expect(converted).toEqual({
type: 'error',
message: 'Cursor Agent failed: authentication required'
});
});
it('converts usage messages into token_count payloads', () => {
const converted = convertAgentMessage({
type: 'usage',
inputTokens: 8_119,
outputTokens: 2,
cacheReadTokens: 5_760,
thoughtTokens: 11,
totalTokens: 13_892,
contextTokens: 13_879,
contextWindow: 65_536
});
expect(converted).toEqual({
type: 'token_count',
info: {
total: {
inputTokens: 8119,
outputTokens: 2,
cachedInputTokens: 5760,
thoughtTokens: 11,
totalTokens: 13892
},
contextTokens: 13879,
modelContextWindow: 65536
}
});
});
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();
});
});