Skip to content
Open
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
19 changes: 17 additions & 2 deletions src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,19 @@ export default defineCommand({

const think = new ThinkingIndicator(statusOut, config.noColor);

// The stream is only complete once the server sends a terminator
// (`message_stop` or `[DONE]`). A connection that closes before that
// leaves `textContent` silently truncated, so track it explicitly.
let streamCompleted = false;

for await (const event of parseSSE(res)) {
if (event.data === '[DONE]') break;
if (event.data === '[DONE]') { streamCompleted = true; break; }
try {
const parsed = JSON.parse(event.data) as StreamEvent;

if (parsed.type === 'content_block_start') {
if (parsed.type === 'message_stop') {
streamCompleted = true;
} else if (parsed.type === 'content_block_start') {
if (parsed.content_block.type === 'thinking') {
inThinking = true;
think.start();
Expand All @@ -300,6 +307,14 @@ export default defineCommand({
}
if (inThinking) think.stop();

if (!streamCompleted) {
if (!isJsonOutput) resultOut?.write('\n');
throw new CLIError(
'Stream disconnected before response completed.',
ExitCode.NETWORK,
);
}

if (format === 'json') {
console.log(formatOutput({ content: textContent }, format));
} else {
Expand Down
59 changes: 59 additions & 0 deletions test/commands/text/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,63 @@ describe('text chat command', () => {
console.log = originalLog;
}
});

it('should fail when the SSE stream ends without a terminator instead of printing truncated output', async () => {
// Deltas only: no `message_stop` event and no `data: [DONE]` line, i.e. the
// connection dropped part-way through the response.
const truncatedBody =
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } })}\n\n` +
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ' wor' } })}\n\n`;

server = createMockServer({
routes: {
'/anthropic/v1/messages': () => new Response(truncatedBody, {
headers: { 'Content-Type': 'text/event-stream' },
}),
},
});

const { default: chatCommand } = await import('../../../src/commands/text/chat');

const config: Config = {
apiKey: 'test-key',
region: 'global' as const,
baseUrl: server.url,
output: 'json',
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: false,
nonInteractive: true,
async: false,
};

const originalLog = console.log;
let output = '';
console.log = (msg: string) => { output += `${msg}\n`; };

try {
await expect(
chatCommand.execute(config, {
message: ['Hello'],
stream: true,
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: false,
help: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('Stream disconnected before response completed.');

// The truncated text must not be reported as a complete result.
expect(output).toBe('');
} finally {
console.log = originalLog;
}
});
});