mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
bca13a13fa
* feat(web): introduce a brand new web ui * refactor(ui): align chat sidebar and UI components with Figma design - Restyle chat page sidebar: header with icon/title, search input, section labels, and "new session" footer button - Simplify bot-sidebar and session-sidebar to card-based layout matching Figma session card design (58px height, 26px avatar, status dots) - Update master-detail-sidebar-layout with bg-sidebar and 53px header - Unify border-radius across UI components to rounded-lg (8px): Card, Toggle, Alert, Popover, Item; Dialog uses rounded-xl (12px) * refactor(ui): move shared theme and design tokens from web to ui package CSS variables, @theme inline mappings, @custom-variant, and base layer styles now live in @memohai/ui/style.css. The web app imports them via @import "@memohai/ui/style.css", keeping only the Tailwind entry point and web-specific imports (markstream-vue, @source). * refactor(ui): apply flat design system from Figma spec Overhaul @memohai/ui component styles to match the new "high-contrast, flat" design language defined in the Figma design spec (DESIGN.md). Theme: - --primary-foreground: pure white -> #fafafa - --ring: purple -> foreground color (focus rings no longer use brand purple) Atoms (zero shadow, monochrome): - Button: default bg-primary -> bg-foreground; add explicit "primary" variant for Send CTA - Badge: rounded-full -> rounded-sm; default bg-primary -> bg-foreground; add warning/outline/size variants - Alert: rounded-lg -> rounded-[10px]; remove shadow-sm; destructive drops bg-red-50 - Card: add shadow-lg, rounded-lg -> rounded-xl, py-6 -> p-6 - Input/Textarea: remove shadow, text-sm -> text-[16px], focus ring non-purple - Checkbox: checked bg-primary -> bg-foreground - Switch: checked bg-primary -> bg-foreground - RadioGroup: indicator fill-primary -> fill-foreground - Slider: range/thumb border-primary -> border-foreground Floating panels (shadow-md): - DropdownMenu/Combobox/Select/ContextMenu Content: shadow-lg -> shadow-md - Sheet: shadow-2xl -> shadow-lg - MenuItem destructive focus: bg-red-50 -> bg-accent Other: - Pagination active: bg-foreground text-background (black, not purple) - Item variants: bg-transparent -> bg-background/bg-accent - Tabs active: shadow-sm -> border-border - Toggle: remove shadow-xs, unify hover to accent - SelectTrigger/NativeSelect: remove shadow, unify focus ring Docs: - Add packages/ui/DESIGN.md with full design system spec - Simplify apps/web/AGENTS.md, remove duplicated design info, reference DESIGN.md * refactor(chat-ui): restructure chat page components and styles (#288) * refactor(chat-ui): restructure chat page components and styles * feat(chat): add collapsible sidebar for both sides * feat(ui): add PinInput and BadgeCount components, align styles with Figma spec New components: - PinInput (OTP input): PinInput, PinInputGroup, PinInputSlot, PinInputSeparator based on reka-ui PinInput primitives with flat border-stitching design - BadgeCount: circular numeric counter with default/destructive/secondary variants Style updates to match Figma design: - Sonner: border-radius from 1rem to var(--radius-lg) (10px) - Table: add border border-border rounded-sm to container - TagsInput: remove shadow-xs, rounded-md -> rounded-lg, ring-[3px] -> ring-2 Updated DESIGN.md with all new component specifications. * chore: move up css to ui package * refactor: change npm package from @memoh to @memohai * Feat/chat layout (#295) * refactor(chat-ui): restructure chat page components and styles * feat(chat): add collapsible sidebar for both sides * fix: update chat page icon * style: refine UI components appearance * style: refine UI components appearance * chore(ci): update lock file * refactor: new layout * chore: adjust style * fix: tauri ui size * chore: remove bot session metadata * refactor: text size and muted color * fix: indirect height of bot-details pages * feat: add 5 icons * refactor: polish chat flow and settings navigation labels Persist chat selection across pages, simplify provider/settings sidebars, and refine chat/session UX so navigation and composer behavior feel consistent without extra session/provider jumps. * docs(web): refresh AGENTS frontend architecture guide Expand and align the web AGENTS documentation with the current route structure, component inventory, chat transport flow, and store responsibilities so implementation guidance matches the codebase. --------- Co-authored-by: Quincy <69751197+dqygit@users.noreply.github.com>
1074 lines
29 KiB
Go
1074 lines
29 KiB
Go
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)
|
|
}
|
|
}
|