package channel import ( "context" "errors" "strings" "sync" "testing" ) type streamValidationAdapter struct { channelType ChannelType outboundPolicy OutboundPolicy } func (a *streamValidationAdapter) Type() ChannelType { return a.channelType } func (a *streamValidationAdapter) Descriptor() Descriptor { return Descriptor{ Type: a.channelType, DisplayName: "stream-validation", Capabilities: ChannelCapabilities{ Text: true, Markdown: true, Attachments: true, Streaming: true, BlockStreaming: true, Buttons: true, Threads: true, }, OutboundPolicy: a.outboundPolicy, } } func newStreamValidationRegistry(t *testing.T) *Registry { t.Helper() registry := NewRegistry() if err := registry.Register(&streamValidationAdapter{channelType: ChannelType("test")}); err != nil { t.Fatalf("register adapter failed: %v", err) } return registry } func TestValidateStreamEventSupportedTypes(t *testing.T) { t.Parallel() registry := newStreamValidationRegistry(t) channelType := ChannelType("test") tests := []struct { name string event StreamEvent }{ {name: "status", event: StreamEvent{Type: StreamEventStatus, Status: StreamStatusStarted}}, {name: "delta", event: StreamEvent{Type: StreamEventDelta, Delta: "hello"}}, {name: "phase start", event: StreamEvent{Type: StreamEventPhaseStart, Phase: StreamPhaseText}}, {name: "phase end", event: StreamEvent{Type: StreamEventPhaseEnd, Phase: StreamPhaseText}}, {name: "tool start", event: StreamEvent{Type: StreamEventToolCallStart, ToolCall: &StreamToolCall{Name: "search"}}}, {name: "tool end", event: StreamEvent{Type: StreamEventToolCallEnd, ToolCall: &StreamToolCall{Name: "search"}}}, {name: "attachment", event: StreamEvent{Type: StreamEventAttachment, Attachments: []Attachment{{Type: AttachmentImage, URL: "https://example.com/img.png"}}}}, {name: "agent start", event: StreamEvent{Type: StreamEventAgentStart}}, {name: "agent end", event: StreamEvent{Type: StreamEventAgentEnd}}, {name: "processing started", event: StreamEvent{Type: StreamEventProcessingStarted}}, {name: "processing completed", event: StreamEvent{Type: StreamEventProcessingCompleted}}, {name: "processing failed", event: StreamEvent{Type: StreamEventProcessingFailed, Error: "failed"}}, {name: "final", event: StreamEvent{Type: StreamEventFinal, Final: &StreamFinalizePayload{Message: Message{Text: "done"}}}}, {name: "error", event: StreamEvent{Type: StreamEventError, Error: "boom"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() if err := validateStreamEvent(registry, channelType, tt.event); err != nil { t.Fatalf("expected nil error, got %v", err) } }) } } func TestValidateStreamEventInvalidPayload(t *testing.T) { t.Parallel() registry := newStreamValidationRegistry(t) channelType := ChannelType("test") tests := []struct { name string event StreamEvent }{ {name: "missing status", event: StreamEvent{Type: StreamEventStatus}}, {name: "missing tool call payload", event: StreamEvent{Type: StreamEventToolCallStart}}, {name: "empty attachment payload", event: StreamEvent{Type: StreamEventAttachment}}, {name: "processing failed missing error", event: StreamEvent{Type: StreamEventProcessingFailed}}, {name: "missing final payload", event: StreamEvent{Type: StreamEventFinal}}, {name: "missing error payload", event: StreamEvent{Type: StreamEventError}}, {name: "unsupported type", event: StreamEvent{Type: StreamEventType("unknown")}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() if err := validateStreamEvent(registry, channelType, tt.event); err == nil { t.Fatalf("expected error for %s", tt.name) } }) } } // recordingStream captures events pushed to it. type recordingStream struct { mu sync.Mutex events []StreamEvent } func (r *recordingStream) Push(_ context.Context, event StreamEvent) error { r.mu.Lock() defer r.mu.Unlock() r.events = append(r.events, event) return nil } func (*recordingStream) Close(_ context.Context) error { return nil } func (r *recordingStream) Events() []StreamEvent { r.mu.Lock() defer r.mu.Unlock() out := make([]StreamEvent, len(r.events)) copy(out, r.events) return out } type failingFinalStream struct { recordingStream } func (f *failingFinalStream) Push(ctx context.Context, event StreamEvent) error { if event.Type == StreamEventFinal { return context.DeadlineExceeded } return f.recordingStream.Push(ctx, event) } func newChunkingTestStream(t *testing.T, chunkLimit int) (*managerOutboundStream, *recordingStream, *[]OutboundMessage) { t.Helper() registry := NewRegistry() adapter := &streamValidationAdapter{ channelType: ChannelType("test"), outboundPolicy: OutboundPolicy{TextChunkLimit: chunkLimit}, } if err := registry.Register(adapter); err != nil { t.Fatalf("register adapter failed: %v", err) } manager := &Manager{registry: registry} rec := &recordingStream{} var sent []OutboundMessage var mu sync.Mutex stream := &managerOutboundStream{ manager: manager, stream: rec, channelType: ChannelType("test"), send: func(_ context.Context, msg OutboundMessage) error { mu.Lock() defer mu.Unlock() sent = append(sent, msg) return nil }, } return stream, rec, &sent } func TestPushFinalWithChunking_ShortText(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 2000) event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{Text: "short text", Format: MessageFormatPlain}, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 stream event, got %d", len(events)) } if events[0].Final.Message.Text != "short text" { t.Fatalf("expected original text, got %q", events[0].Final.Message.Text) } if len(*sent) != 0 { t.Fatalf("expected no overflow sends, got %d", len(*sent)) } } func TestPushFinalWithChunking_LongText(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 100) lines := make([]string, 0, 10) for i := range 10 { line := strings.Repeat("x", 20) if i > 0 { line = strings.Repeat("y", 20) } lines = append(lines, line) } longText := strings.Join(lines, "\n") event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{Text: longText, Format: MessageFormatPlain}, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 stream event (first chunk), got %d", len(events)) } firstChunk := events[0].Final.Message.Text if runeLen(firstChunk) > 100 { t.Fatalf("first chunk exceeds limit: %d runes", runeLen(firstChunk)) } if len(*sent) == 0 { t.Fatal("expected overflow sends, got none") } for i, msg := range *sent { if runeLen(msg.Message.Text) > 100 { t.Fatalf("overflow chunk %d exceeds limit: %d runes", i, runeLen(msg.Message.Text)) } } var reconstructed strings.Builder reconstructed.WriteString(firstChunk) for _, msg := range *sent { reconstructed.WriteString("\n") reconstructed.WriteString(msg.Message.Text) } if strings.TrimSpace(reconstructed.String()) != strings.TrimSpace(longText) { t.Fatal("reconstructed text does not match original") } } func TestPushFinalWithChunking_AttachmentsSeparated(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 50) longText := strings.Repeat("a", 30) + "\n" + strings.Repeat("b", 30) attachments := []Attachment{{Type: AttachmentImage, URL: "https://example.com/img.png"}} event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{ Text: longText, Format: MessageFormatPlain, Attachments: attachments, }, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 stream event, got %d", len(events)) } if len(events[0].Final.Message.Attachments) != 0 { t.Fatal("first chunk should not contain attachments") } hasAttachment := false for _, msg := range *sent { if len(msg.Message.Attachments) > 0 { hasAttachment = true if msg.Message.Attachments[0].URL != "https://example.com/img.png" { t.Fatal("attachment URL mismatch") } } } if !hasAttachment { t.Fatal("expected attachments in overflow sends") } } func TestBuildOutboundMessages_InlineTextWithMediaMovesTextToCaption(t *testing.T) { t.Parallel() msgs, err := buildOutboundMessages(OutboundMessage{ Target: "chat-1", Message: Message{ Text: "test.jpg from QQ", Attachments: []Attachment{{ Type: AttachmentImage, URL: "https://example.com/test.jpg", }}, }, }, OutboundPolicy{ TextChunkLimit: 100, MediaOrder: OutboundOrderTextFirst, InlineTextWithMedia: true, }) if err != nil { t.Fatalf("buildOutboundMessages failed: %v", err) } if len(msgs) != 1 { t.Fatalf("expected 1 outbound message, got %d", len(msgs)) } if got := strings.TrimSpace(msgs[0].Message.Text); got != "" { t.Fatalf("expected inline caption to suppress standalone text, got %q", got) } if len(msgs[0].Message.Attachments) != 1 { t.Fatalf("expected 1 attachment, got %d", len(msgs[0].Message.Attachments)) } if got := msgs[0].Message.Attachments[0].Caption; got != "test.jpg from QQ" { t.Fatalf("unexpected attachment caption: %q", got) } } func TestPushFinalWithChunking_NonFinalPassthrough(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 100) event := StreamEvent{ Type: StreamEventStatus, Status: StreamStatusStarted, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != StreamEventStatus { t.Fatalf("expected status event, got %s", events[0].Type) } if len(*sent) != 0 { t.Fatalf("expected no overflow sends, got %d", len(*sent)) } } func TestPushFinalWithChunking_MarkdownFormat(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 100) para1 := strings.Repeat("a", 60) para2 := strings.Repeat("b", 60) longText := para1 + "\n\n" + para2 event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{Text: longText, Format: MessageFormatMarkdown}, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 stream event, got %d", len(events)) } if events[0].Final.Message.Format != MessageFormatMarkdown { t.Fatal("first chunk should preserve markdown format") } if len(*sent) != 1 { t.Fatalf("expected 1 overflow send, got %d", len(*sent)) } if (*sent)[0].Message.Format != MessageFormatMarkdown { t.Fatal("overflow chunk should preserve markdown format") } } func TestPushFinalWithChunking_ActionsOnLastChunk(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 50) longText := strings.Repeat("a", 30) + "\n" + strings.Repeat("b", 30) actions := []Action{{Type: "button", Label: "Click me", URL: "https://example.com"}} event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{ Text: longText, Format: MessageFormatPlain, Actions: actions, }, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 stream event, got %d", len(events)) } if len(events[0].Final.Message.Actions) != 0 { t.Fatal("first chunk should NOT have actions when there are overflow chunks") } if len(*sent) == 0 { t.Fatal("expected overflow sends") } lastSent := (*sent)[len(*sent)-1] if len(lastSent.Message.Actions) != 1 || lastSent.Message.Actions[0].Label != "Click me" { t.Fatal("actions should be on the last overflow message") } } func TestPushFinalWithChunking_ActionsOnAttachmentMsg(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 50) longText := strings.Repeat("a", 30) + "\n" + strings.Repeat("b", 30) actions := []Action{{Type: "button", Label: "OK"}} attachments := []Attachment{{Type: AttachmentImage, URL: "https://example.com/img.png"}} event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{ Text: longText, Format: MessageFormatPlain, Actions: actions, Attachments: attachments, }, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events[0].Final.Message.Actions) != 0 { t.Fatal("first chunk should NOT have actions") } var attachmentMsg *OutboundMessage for i := range *sent { if len((*sent)[i].Message.Attachments) > 0 { attachmentMsg = &(*sent)[i] } } if attachmentMsg == nil { t.Fatal("expected attachment message in overflow") return } if len(attachmentMsg.Message.Actions) != 1 || attachmentMsg.Message.Actions[0].Label != "OK" { t.Fatal("actions should be on the attachment (last) message") } for _, msg := range *sent { if len(msg.Message.Attachments) == 0 && len(msg.Message.Actions) > 0 { t.Fatal("text-only overflow chunks should NOT have actions when attachment message exists") } } } func TestPushFinalWithChunking_ThreadPropagated(t *testing.T) { t.Parallel() stream, rec, sent := newChunkingTestStream(t, 50) longText := strings.Repeat("a", 30) + "\n" + strings.Repeat("b", 30) thread := &ThreadRef{ID: "thread-123"} event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{ Text: longText, Format: MessageFormatPlain, Thread: thread, }, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } events := rec.Events() if len(events) != 1 { t.Fatalf("expected 1 stream event, got %d", len(events)) } if len(*sent) == 0 { t.Fatal("expected overflow sends") } for i, msg := range *sent { if msg.Message.Thread == nil || msg.Message.Thread.ID != "thread-123" { t.Fatalf("overflow chunk %d should have thread propagated", i) } } } func TestPushFinalWithChunking_FirstChunkPushFailureFallback(t *testing.T) { t.Parallel() registry := NewRegistry() adapter := &streamValidationAdapter{ channelType: ChannelType("test"), outboundPolicy: OutboundPolicy{TextChunkLimit: 100}, } if err := registry.Register(adapter); err != nil { t.Fatalf("register adapter failed: %v", err) } manager := &Manager{registry: registry} rec := &failingFinalStream{} var sent []OutboundMessage stream := &managerOutboundStream{ manager: manager, stream: rec, channelType: ChannelType("test"), send: func(_ context.Context, msg OutboundMessage) error { sent = append(sent, msg) return nil }, } longText := strings.Repeat("a", 80) + "\n" + strings.Repeat("b", 80) + "\n" + strings.Repeat("c", 80) event := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{Text: longText, Format: MessageFormatPlain}, }, } if err := stream.Push(context.Background(), event); err != nil { t.Fatalf("Push failed: %v", err) } if len(rec.Events()) != 0 { t.Fatalf("expected no stream events after first chunk push failure, got %d", len(rec.Events())) } if len(sent) < 2 { t.Fatalf("expected fallback sends for all chunks, got %d", len(sent)) } var reconstructed strings.Builder for i, msg := range sent { if i > 0 { reconstructed.WriteString("\n") } reconstructed.WriteString(msg.Message.Text) } if strings.TrimSpace(reconstructed.String()) != strings.TrimSpace(longText) { t.Fatal("fallback reconstructed text does not match original") } } // reopenableStream wraps a list of recordingStreams; each reopen returns the next. type reopenableStream struct { streams []*recordingStream idx int } func (r *reopenableStream) current() *recordingStream { if r.idx >= len(r.streams) { return nil } return r.streams[r.idx] } func (r *reopenableStream) reopen(_ context.Context) (OutboundStream, error) { r.idx++ if r.idx >= len(r.streams) { return nil, errors.New("no more streams") } return r.streams[r.idx], nil } func newDeltaSplitTestStream(t *testing.T, chunkLimit int, streamCount int) (*managerOutboundStream, *reopenableStream, *[]OutboundMessage) { t.Helper() registry := NewRegistry() adapter := &streamValidationAdapter{ channelType: ChannelType("test"), outboundPolicy: OutboundPolicy{TextChunkLimit: chunkLimit}, } if err := registry.Register(adapter); err != nil { t.Fatalf("register adapter failed: %v", err) } manager := &Manager{registry: registry} streams := make([]*recordingStream, streamCount) for i := range streams { streams[i] = &recordingStream{} } reo := &reopenableStream{streams: streams} var sent []OutboundMessage var mu sync.Mutex stream := &managerOutboundStream{ manager: manager, stream: streams[0], channelType: ChannelType("test"), send: func(_ context.Context, msg OutboundMessage) error { mu.Lock() defer mu.Unlock() sent = append(sent, msg) return nil }, reopen: reo.reopen, } return stream, reo, &sent } func TestPushDelta_NoSplitUnderLimit(t *testing.T) { t.Parallel() stream, reo, _ := newDeltaSplitTestStream(t, 100, 2) for i := range 5 { _ = i if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("a", 10), }); err != nil { t.Fatalf("Push failed: %v", err) } } events := reo.streams[0].Events() if len(events) != 5 { t.Fatalf("expected 5 delta events on stream 0, got %d", len(events)) } if stream.splitCount != 0 { t.Fatal("expected no splits") } } func TestPushDelta_SplitsAtLimit(t *testing.T) { t.Parallel() stream, reo, _ := newDeltaSplitTestStream(t, 100, 3) for range 12 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("x", 10), }); err != nil { t.Fatalf("Push failed: %v", err) } } if stream.splitCount != 1 { t.Fatalf("expected 1 split, got %d", stream.splitCount) } s0 := reo.streams[0].Events() hasFinal := false deltaCount := 0 for _, e := range s0 { if e.Type == StreamEventFinal { hasFinal = true } if e.Type == StreamEventDelta { deltaCount++ } } if !hasFinal { t.Fatal("stream 0 should have received a Final event for finalization") } if deltaCount != 10 { t.Fatalf("stream 0 should have 10 deltas (100 runes), got %d", deltaCount) } s1 := reo.streams[1].Events() hasStarted := false deltaCount1 := 0 for _, e := range s1 { if e.Type == StreamEventStatus && e.Status == StreamStatusStarted { hasStarted = true } if e.Type == StreamEventDelta { deltaCount1++ } } if !hasStarted { t.Fatal("continuation stream should receive Status(Started) before deltas") } if deltaCount1 != 2 { t.Fatalf("stream 1 should have 2 deltas (overflow), got %d", deltaCount1) } } func TestPushDelta_MultipleSplits(t *testing.T) { t.Parallel() stream, reo, _ := newDeltaSplitTestStream(t, 50, 5) for range 15 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("y", 10), }); err != nil { t.Fatalf("Push failed: %v", err) } } if stream.splitCount != 2 { t.Fatalf("expected 2 splits for 150 runes / 50 limit, got %d", stream.splitCount) } for i := 0; i <= 2; i++ { events := reo.streams[i].Events() if len(events) == 0 { t.Fatalf("stream %d has no events", i) } } } func TestPushDelta_FinalAfterSplitUsesBuffer(t *testing.T) { t.Parallel() stream, reo, sent := newDeltaSplitTestStream(t, 50, 3) for range 8 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("z", 10), }); err != nil { t.Fatalf("Push failed: %v", err) } } if stream.splitCount == 0 { t.Fatal("expected at least 1 split") } finalEvent := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{ Text: strings.Repeat("z", 80), Format: MessageFormatPlain, }, }, } if err := stream.Push(context.Background(), finalEvent); err != nil { t.Fatalf("Final Push failed: %v", err) } lastStream := reo.current() events := lastStream.Events() hasFinal := false for _, e := range events { if e.Type == StreamEventFinal { hasFinal = true if !e.Final.Message.IsEmpty() { t.Fatal("after split, Final should have empty message (adapter uses buffer)") } } } if !hasFinal { t.Fatal("last stream should have received a Final event") } _ = sent } func TestPushDelta_FinalWithAttachmentsAfterSplit(t *testing.T) { t.Parallel() stream, _, sent := newDeltaSplitTestStream(t, 50, 3) for range 8 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("w", 10), }); err != nil { t.Fatalf("Push failed: %v", err) } } attachments := []Attachment{{Type: AttachmentImage, URL: "https://example.com/img.png"}} actions := []Action{{Type: "button", Label: "OK"}} finalEvent := StreamEvent{ Type: StreamEventFinal, Final: &StreamFinalizePayload{ Message: Message{ Text: strings.Repeat("w", 80), Format: MessageFormatPlain, Attachments: attachments, Actions: actions, }, }, } if err := stream.Push(context.Background(), finalEvent); err != nil { t.Fatalf("Final Push failed: %v", err) } if len(*sent) != 1 { t.Fatalf("expected 1 attachment send, got %d", len(*sent)) } if len((*sent)[0].Message.Attachments) != 1 { t.Fatal("expected attachments forwarded via send") } if len((*sent)[0].Message.Actions) != 1 || (*sent)[0].Message.Actions[0].Label != "OK" { t.Fatal("expected actions forwarded with attachments") } } func TestPushDelta_NoSplitWhenNoLimit(t *testing.T) { t.Parallel() stream, reo, _ := newDeltaSplitTestStream(t, 0, 2) for range 20 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("a", 100), }); err != nil { t.Fatalf("Push failed: %v", err) } } if stream.splitCount != 0 { t.Fatal("expected no splits when TextChunkLimit is 0") } events := reo.streams[0].Events() if len(events) != 20 { t.Fatalf("expected all 20 deltas on stream 0, got %d", len(events)) } } func TestPushDelta_ReasoningPhaseNotCounted(t *testing.T) { t.Parallel() stream, _, _ := newDeltaSplitTestStream(t, 50, 2) for range 10 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("r", 100), Phase: StreamPhaseReasoning, }); err != nil { t.Fatalf("Push failed: %v", err) } } if stream.splitCount != 0 { t.Fatal("reasoning deltas should not trigger splits") } if stream.deltaRunes != 0 { t.Fatal("reasoning deltas should not be counted") } } func TestPushDelta_SplitsAtNaturalBreak(t *testing.T) { t.Parallel() // limit=100, softLimit = 100 - 100/4 = 75 stream, reo, _ := newDeltaSplitTestStream(t, 100, 3) // Push 70 runes — under soft limit. if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("a", 70), }); err != nil { t.Fatalf("Push failed: %v", err) } if stream.splitCount != 0 { t.Fatal("should not split under soft limit") } // Push 10 runes ending with a sentence period → 80 runes total, // above soft (75) but under hard (100). Text ends with "." → natural break. if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("b", 9) + ".", }); err != nil { t.Fatalf("Push failed: %v", err) } if stream.splitCount != 1 { t.Fatalf("expected split at natural break point, got %d splits", stream.splitCount) } if stream.deltaRunes != 0 { t.Fatalf("deltaRunes should reset after natural-break split, got %d", stream.deltaRunes) } // Verify stream 0 got both deltas and a Final. s0 := reo.streams[0].Events() deltaCount := 0 hasFinal := false for _, e := range s0 { if e.Type == StreamEventDelta { deltaCount++ } if e.Type == StreamEventFinal { hasFinal = true } } if deltaCount != 2 { t.Fatalf("stream 0: expected 2 deltas, got %d", deltaCount) } if !hasFinal { t.Fatal("stream 0 should have a Final event") } // Push more content to the continuation stream. if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: "continuation text", }); err != nil { t.Fatalf("Push failed: %v", err) } s1 := reo.streams[1].Events() hasStarted := false for _, e := range s1 { if e.Type == StreamEventStatus && e.Status == StreamStatusStarted { hasStarted = true } } if !hasStarted { t.Fatal("continuation stream should receive Status(Started)") } } func TestPushDelta_NoEarlySplitWithoutBreakPoint(t *testing.T) { t.Parallel() // limit=100, softLimit=75 stream, _, _ := newDeltaSplitTestStream(t, 100, 3) // Push 90 runes of plain text (no break point) — above soft, under hard. for range 9 { if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("x", 10), }); err != nil { t.Fatalf("Push failed: %v", err) } } if stream.splitCount != 0 { t.Fatalf("should NOT split in soft zone without a natural break, got %d", stream.splitCount) } if stream.deltaRunes != 90 { t.Fatalf("expected 90 accumulated runes, got %d", stream.deltaRunes) } // Push 20 more runes → 110 total → exceeds hard limit → force split. if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("x", 20), }); err != nil { t.Fatalf("Push failed: %v", err) } if stream.splitCount != 1 { t.Fatalf("expected force-split at hard limit, got %d splits", stream.splitCount) } } func TestPushDelta_NewlineTriggersBreak(t *testing.T) { t.Parallel() stream, _, _ := newDeltaSplitTestStream(t, 100, 3) // 76 runes — just past soft limit (75). if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("w", 76), }); err != nil { t.Fatalf("Push failed: %v", err) } if stream.splitCount != 0 { t.Fatal("no break yet") } // Newline → natural break. if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: "\n", }); err != nil { t.Fatalf("Push failed: %v", err) } if stream.splitCount != 1 { t.Fatalf("expected split on newline in soft zone, got %d", stream.splitCount) } } func TestPushDelta_ChinesePunctuationBreak(t *testing.T) { t.Parallel() stream, _, _ := newDeltaSplitTestStream(t, 100, 3) if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: strings.Repeat("字", 76), }); err != nil { t.Fatalf("Push failed: %v", err) } // Chinese period → natural break. if err := stream.Push(context.Background(), StreamEvent{ Type: StreamEventDelta, Delta: "。", }); err != nil { t.Fatalf("Push failed: %v", err) } if stream.splitCount != 1 { t.Fatalf("expected split on Chinese period, got %d", stream.splitCount) } } func TestIsNaturalBreakPoint(t *testing.T) { t.Parallel() tests := []struct { name string text string expect bool }{ {"empty", "", false}, {"plain word", "hello", false}, {"period", "end.", true}, {"period with trailing space", "end. ", true}, {"question mark", "why?", true}, {"exclamation", "wow!", true}, {"Chinese period", "结束。", true}, {"Chinese question", "什么?", true}, {"Chinese exclamation", "好!", true}, {"ellipsis", "wait…", true}, {"newline", "line\n", true}, {"double newline", "paragraph\n\n", true}, {"semicolon", "clause;", true}, {"Chinese semicolon", "分号;", true}, {"comma", "hello,", false}, {"mid-word", "incompl", false}, {"version number", "v2.0", false}, {"code dot", "obj.method", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := isNaturalBreakPoint(tc.text) if got != tc.expect { t.Errorf("isNaturalBreakPoint(%q) = %v, want %v", tc.text, got, tc.expect) } }) } } func TestNormalizeOutboundMessage_MarkdownDetected(t *testing.T) { t.Parallel() msg := normalizeOutboundMessage(Message{Text: "Hello **world**"}) if msg.Format != MessageFormatMarkdown { t.Errorf("expected %q, got %q", MessageFormatMarkdown, msg.Format) } } func TestNormalizeOutboundMessage_PlainText(t *testing.T) { t.Parallel() msg := normalizeOutboundMessage(Message{Text: "Hello world"}) if msg.Format != MessageFormatPlain { t.Errorf("expected %q, got %q", MessageFormatPlain, msg.Format) } } func TestNormalizeOutboundMessage_ExplicitFormatPreserved(t *testing.T) { t.Parallel() msg := normalizeOutboundMessage(Message{Text: "Hello **world**", Format: MessageFormatPlain}) if msg.Format != MessageFormatPlain { t.Errorf("expected explicit format %q preserved, got %q", MessageFormatPlain, msg.Format) } } func TestNormalizeOutboundMessage_RichParts(t *testing.T) { t.Parallel() msg := normalizeOutboundMessage(Message{Parts: []MessagePart{{Type: "text", Text: "hello"}}}) if msg.Format != MessageFormatRich { t.Errorf("expected %q, got %q", MessageFormatRich, msg.Format) } }