tpmjs/STREAMING_EMPTY_RESPONSE.md
Ajax Davis 0658aea425 feat: implement playground app with AI SDK v6 tool execution
- Create new Next.js app at apps/playground for testing TPMJS tools
- Implement AI SDK v6 patterns with DefaultChatTransport and UIMessage format
- Create template tool package at packages/tools/hello with hello-world and hello-name tools
- Use tool() and jsonSchema() helpers to avoid Zod 4 conversion issues with OpenAI
- Add static tool loading system with switch statement (Next.js/webpack compatible)
- Implement chat interface with tool call visualization showing inputs/outputs
- Support multi-step tool execution with stepCountIs(5)
- Stream responses with toUIMessageStreamResponse() for full tool support
- Add sidebar showing available tools (static list)
- Use parts-based message rendering for text and tool calls
- Integrate firecrawl-aisdk tools (scrape, crawl, search)
- Add theme toggle in header (defaults to light mode)
- Fix responsive layout with max-width for message bubbles
- Use biome-ignore comments for legitimate any types in tool loading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 02:51:02 +10:00

363 lines
10 KiB
Markdown

# AI SDK v6 Streaming Empty Response Issue
## Problem
Using AI SDK v6 Beta with OpenAI and `streamText()`, the API route returns a 200 OK response, but the streamed response body is **completely empty** when tools are involved.
- **Normal chat** (without tool calls): Works fine, streams text back
- **Tool calls** (when user asks "say hello world"): Returns empty response body, no error messages
## Environment
- **AI SDK Version**: `ai@6.0.0-beta.124`
- **OpenAI Provider**: `@ai-sdk/openai@3.0.0-beta.74`
- **OpenAI Library**: `openai@^6.9.1`
- **Next.js Version**: `next@^16.0.4` (App Router)
- **Runtime**: Node.js (`runtime = 'nodejs'`)
- **Model**: `gpt-4o-mini`
## API Route Implementation
Located at: `apps/playground/src/app/api/chat/route.ts`
```typescript
import { createOpenAI } from '@ai-sdk/openai';
import { streamText, type CoreMessage, tool, jsonSchema } from 'ai';
import { type NextRequest } from 'next/server';
import { z } from 'zod';
import { env } from '~/env';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
// Initialize OpenAI provider
const openai = createOpenAI({
apiKey: env.OPENAI_API_KEY,
});
// Request schema
const RequestSchema = z.object({
messages: z.array(
z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
})
),
});
// Simple inline test tool to verify streaming works
const testHelloTool = tool({
description: 'Returns a simple hello world greeting',
inputSchema: jsonSchema<{ includeTimestamp?: boolean }>({
type: 'object',
properties: {
includeTimestamp: {
type: 'boolean',
description: 'Whether to include a timestamp',
},
},
additionalProperties: false,
}),
async execute({ includeTimestamp = true }) {
const response: any = { message: 'Hello, World!' };
if (includeTimestamp) {
response.timestamp = new Date().toISOString();
}
return response;
},
});
/**
* POST /api/chat
* Chat with AI agent that can execute TPMJS tools
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { messages } = RequestSchema.parse(body);
// Use simple inline tool for testing
const tools = {
testHello: testHelloTool,
};
// Create system prompt listing available tools
const toolsList = Object.keys(tools)
.map((name) => `- ${name}: ${tools[name]?.description}`)
.join('\n');
const systemMessage: CoreMessage = {
role: 'system',
content: `You are a helpful AI assistant that can use TPMJS tools to help users.
Available tools:
${toolsList}
Call tools as needed to answer user questions. When a user asks to say hello world or for a greeting, use the testHello tool.`,
};
// Stream the response
const result = streamText({
model: openai('gpt-4o-mini'),
messages: [systemMessage, ...messages],
tools,
});
// Return the stream as SSE
return result.toTextStreamResponse();
} catch (error) {
console.error('Chat API error:', error);
if (error instanceof z.ZodError) {
return new Response(
JSON.stringify({
success: false,
error: 'Invalid request format',
details: error.issues,
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
return new Response(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
```
## Client-Side Hook
Located at: `apps/playground/src/hooks/useChat.ts`
```typescript
'use client';
import { useCallback, useState } from 'react';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
}
export function useChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim()) return;
// Add user message immediately
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
setError(null);
try {
// Create assistant message placeholder
const assistantMessageId = crypto.randomUUID();
const assistantMessage: ChatMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
// Send request to API
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [...messages, userMessage].map((m) => ({
role: m.role,
content: m.content,
})),
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Read the streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('Response body is null');
}
let accumulatedContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode the chunk
const chunk = decoder.decode(value, { stream: true });
accumulatedContent += chunk;
// Update the assistant message with accumulated content
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId
? { ...m, content: accumulatedContent }
: m
)
);
}
} catch (err) {
console.error('Error sending message:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
} finally {
setIsLoading(false);
}
}, [messages]);
const clearChat = useCallback(() => {
setMessages([]);
setError(null);
}, []);
return {
messages,
isLoading,
error,
sendMessage,
clearChat,
};
}
```
## Observed Behavior
### Working Case (Normal Chat)
- User types: "hi"
- API response: 200 OK
- Response body: Streams text chunks successfully
- UI shows: "Hi! How can I help you today?"
### Broken Case (Tool Call)
- User types: "say hello world"
- API response: 200 OK ✅
- Response body: **EMPTY** ❌ (no chunks, no data, nothing)
- UI shows: Empty message bubble
- Console: No errors logged
## HTTP Response Details
```
Request Method: POST
Status Code: 200 OK
URL: http://localhost:3001/api/chat
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
```
The response headers look correct for a streaming response, but the body is completely empty.
## What We've Tried
1. ✅ Fixed OpenAI schema validation error (was `type: "None"`, now uses proper JSON Schema)
2. ✅ Using `tool()` and `jsonSchema()` from AI SDK
3. ✅ Simplified to a single inline test tool
4. ✅ Tool executes without errors (no schema validation issues)
5. ✅ Normal chat works fine (proves streaming infrastructure is correct)
## Questions
1. **Is `toTextStreamResponse()` the correct method for streaming with tools in AI SDK v6?**
- Should we use a different method like `toDataStreamResponse()` for tool calls?
2. **Are we constructing the messages array correctly?**
- We're sending `{ role: 'user' | 'assistant' | 'system', content: string }[]`
- Do we need to include tool call messages or tool result messages?
3. **Does AI SDK v6 require a specific message format for tool calls?**
- Should we be including `toolInvocations` or `tool_calls` in the message history?
- Are we missing required fields in the `CoreMessage` type?
4. **Is the client-side streaming reader correct?**
- We're reading chunks with `response.body.getReader()`
- Should we be parsing SSE events differently for tool calls?
5. **Does `streamText()` with tools require `maxSteps` parameter?**
- Do we need to set `maxSteps: 5` to allow multi-step reasoning?
6. **Are we handling the conversation history correctly?**
- We're sending all previous messages on each request
- Should we be including assistant messages with tool call results?
## AI SDK v6 Documentation References
We're following these patterns from the official docs:
- [streamText() API](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text)
- [tool() API](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool)
- [Tool Calling Guide](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)
But we might be missing something specific about:
- How to handle streaming when tools are executed
- What response format tool calls produce
- How to parse the stream when tools are involved
## Suspected Issue
**The message format might be wrong.** We're sending:
```typescript
const systemMessage: CoreMessage = {
role: 'system',
content: `You are a helpful AI assistant...`,
};
const result = streamText({
model: openai('gpt-4o-mini'),
messages: [systemMessage, ...messages],
tools,
});
```
But `CoreMessage` might need additional fields when tools are involved, or we might need to handle tool call results differently in the conversation history.
## What We Need
1. Correct message format for `streamText()` with tools
2. How to properly stream responses that include tool calls
3. Whether we need different client-side parsing for tool call streams
4. Example of a working Next.js API route using AI SDK v6 with `streamText()` and tools
## Repo Context
- Monorepo using Turborepo + pnpm workspaces
- Next.js 16 App Router with Turbopack
- TypeScript strict mode
- All UI components from internal `@tpmjs/ui` package
- Tools are imported from workspace package `@tpmjs/hello`