feat: enable token-level streaming and fix duplicate message bubble

Backend:
- Add includePartialMessages: true to SDK query options
- Handle stream_event/content_block_delta for real-time text streaming
- Skip text/thinking blocks from complete assistant messages (already
  streamed via deltas) to avoid duplication
- Change default result summary to empty string

Flutter:
- Only show CompletedEvent summary when no assistant text was streamed
  (prevents duplicate message bubble)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 17:24:48 -08:00
parent 5f827b0961
commit 74be945e4a
2 changed files with 29 additions and 8 deletions

View File

@ -230,7 +230,12 @@ class ChatNotifier extends StateNotifier<ChatState> {
); );
case CompletedEvent(:final summary): case CompletedEvent(:final summary):
if (summary.isNotEmpty) { // Only show summary as a message if there were no assistant text messages
// (avoids duplicate bubble when the SDK already streamed the full response)
final hasAssistantText = state.messages.any(
(m) => m.role == MessageRole.assistant && m.type == MessageType.text && m.content.isNotEmpty,
);
if (summary.isNotEmpty && !hasAssistantText) {
_appendOrUpdateAssistantMessage(summary, MessageType.text); _appendOrUpdateAssistantMessage(summary, MessageType.text);
} }
state = state.copyWith(agentStatus: AgentStatus.idle); state = state.copyWith(agentStatus: AgentStatus.idle);

View File

@ -107,6 +107,7 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
maxBudgetUsd: params.maxBudgetUsd, maxBudgetUsd: params.maxBudgetUsd,
env, env,
abortController, abortController,
includePartialMessages: true,
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
permissionMode: 'bypassPermissions', permissionMode: 'bypassPermissions',
stderr: (data: string) => { stderr: (data: string) => {
@ -273,6 +274,7 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
prompt: message, prompt: message,
options: { options: {
resume: sdkSessionId, resume: sdkSessionId,
includePartialMessages: true,
env, env,
abortController, abortController,
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
@ -352,15 +354,28 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
private mapSdkMessage(message: any): EngineStreamEvent[] { private mapSdkMessage(message: any): EngineStreamEvent[] {
const events: EngineStreamEvent[] = []; const events: EngineStreamEvent[] = [];
if (message.type === 'assistant') { if (message.type === 'stream_event') {
// Token-level streaming from includePartialMessages: true
const streamEvent = message.event;
if (!streamEvent) return events;
if (streamEvent.type === 'content_block_delta') {
const delta = streamEvent.delta;
if (delta?.type === 'text_delta' && delta.text) {
events.push({ type: 'text', content: delta.text });
} else if (delta?.type === 'thinking_delta' && delta.thinking) {
events.push({ type: 'thinking', content: delta.thinking });
}
}
// Ignore content_block_start/stop/message_start/message_stop — they are structural
} else if (message.type === 'assistant') {
// Complete assistant message (also emitted after streaming)
// When streaming is enabled, skip full blocks to avoid duplicate text
// Only forward tool_use and tool_result which aren't streamed as deltas
const content = message.message?.content; const content = message.message?.content;
if (Array.isArray(content)) { if (Array.isArray(content)) {
for (const block of content) { for (const block of content) {
if (block.type === 'thinking') { if (block.type === 'tool_use') {
events.push({ type: 'thinking', content: block.thinking ?? '' });
} else if (block.type === 'text') {
events.push({ type: 'text', content: block.text ?? '' });
} else if (block.type === 'tool_use') {
events.push({ events.push({
type: 'tool_use', type: 'tool_use',
toolName: block.name ?? 'unknown', toolName: block.name ?? 'unknown',
@ -376,12 +391,13 @@ export class ClaudeAgentSdkEngine implements AgentEnginePort {
isError: block.is_error ?? false, isError: block.is_error ?? false,
}); });
} }
// text and thinking blocks are already streamed via stream_event deltas
} }
} }
} else if (message.type === 'result') { } else if (message.type === 'result') {
events.push({ events.push({
type: 'completed', type: 'completed',
summary: message.result ?? 'Task completed', summary: message.result ?? '',
tokensUsed: message.usage tokensUsed: message.usage
? (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0) ? (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0)
: undefined, : undefined,