Enhance chat buffer and streaming message handling

- Updated the `ChatBuffer` to support streaming messages, allowing for content to be appended and finalized with `SendStream` and `End` methods.
- Modified the `AddAssistantMessage` method to include a message ID, improving message tracking and retrieval.
- Implemented new methods for appending content to streaming messages and completing them, ensuring accurate message storage and event handling.
- Revised tests to validate the new streaming functionality and ensure proper integration with existing message handling processes.
- Updated `CHAT_STORAGE_DESIGN.md` to reflect changes in message storage and indexing, including unique constraints for message IDs within requests.
This commit is contained in:
Max 2025-12-09 18:08:12 +08:00
parent a8a1103b6b
commit 55c8635c2a
12 changed files with 1480 additions and 187 deletions

View file

@ -580,8 +580,9 @@ func TestFlushBuffer(t *testing.T) {
require.NoError(t, err)
// Add some messages to buffer
require.NotNil(t, ctx.Buffer, "Buffer should be initialized")
ctx.Buffer.AddUserInput("Test question", "")
ctx.Buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
ctx.Buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
// Add a step
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)

View file

@ -365,6 +365,7 @@ func (s *streamState) handleMessageEnd(data []byte) int {
}
s.ctx.Buffer.AddAssistantMessage(
s.currentGroupID, // Use the message ID
msgType,
props,
blockID,

View file

@ -47,6 +47,18 @@ interface Context {
### Send Messages
The Context provides several methods for sending messages to the client:
| Method | Description | Auto `message_end` |
| ----------------------------------- | --------------------------- | ------------------ |
| `Send(message, blockId?)` | Send a complete message | ✅ Yes |
| `SendStream(message, blockId?)` | Start a streaming message | ❌ No |
| `Append(messageId, content, path?)` | Append content to a message | N/A |
| `Replace(messageId, message)` | Replace message content | N/A |
| `Merge(messageId, data, path?)` | Merge data into message | N/A |
| `Set(messageId, data, path)` | Set a field in message | N/A |
| `End(messageId, finalContent?)` | Finalize streaming message | ✅ Yes |
#### `ctx.Send(message, blockId?): string`
Sends a message to the client and automatically flushes the output.
@ -191,6 +203,139 @@ function Next(ctx, payload) {
- Output is automatically flushed after sending
- Throws exception on failure
- Delta operations (Replace, Append, Merge, Set) automatically inherit block_id and thread_id from the original message
- **For streaming output**, use `ctx.SendStream()` instead (see below)
#### `ctx.SendStream(message, blockId?): string`
Sends a streaming message that can be appended to later. Unlike `Send()`, this does NOT automatically send `message_end` event. Use `ctx.Append()` to add content, then `ctx.End()` to finalize.
**Parameters:**
- `message`: Message object or string
- `blockId`: String (optional) - Block ID to send this message in
**Returns:**
- `string`: The message ID (for use with `Append` and `End`)
**Examples:**
```javascript
// Start a streaming message
const msgId = ctx.SendStream({
type: "text",
props: { content: "# Title\n\n" },
});
// Append content in chunks (simulating streaming)
ctx.Append(msgId, "First paragraph. ");
ctx.Append(msgId, "Second sentence. ");
ctx.Append(msgId, "Third sentence.\n\n");
// Finalize the message (sends message_end event)
ctx.End(msgId);
```
**String Shorthand:**
```javascript
// SendStream with string shorthand
const msgId = ctx.SendStream("Starting analysis...");
ctx.Append(msgId, " processing...");
ctx.Append(msgId, " done!");
ctx.End(msgId);
// Final content: "Starting analysis... processing... done!"
```
**With Block ID:**
```javascript
const blockId = ctx.BlockID();
const msgId = ctx.SendStream("Step 1: ", blockId);
ctx.Append(msgId, "Analyzing data...");
ctx.End(msgId);
```
**Notes:**
- Returns the message ID immediately for use with `Append` and `End`
- Sends `message_start` event but NOT `message_end` (unlike `Send`)
- Must call `ctx.End(msgId)` to finalize the message
- Content appended via `ctx.Append()` is accumulated for storage
- Ideal for streaming text output where you control the timing
#### `ctx.End(messageId, finalContent?): string`
Finalizes a streaming message started with `SendStream()`. Sends `message_end` event with the complete accumulated content.
**Parameters:**
- `messageId`: String - The message ID returned by `SendStream()`
- `finalContent`: String (optional) - Final content to append before ending
**Returns:**
- `string`: The message ID
**Examples:**
```javascript
// Basic usage
const msgId = ctx.SendStream("Hello");
ctx.Append(msgId, " World");
ctx.End(msgId);
// Final: "Hello World"
// End with final content
const msgId2 = ctx.SendStream("Processing");
ctx.Append(msgId2, "...");
ctx.End(msgId2, " Complete!");
// Final: "Processing... Complete!"
```
**Notes:**
- Must be called after `SendStream()` to send `message_end` event
- Optional `finalContent` is appended before sending `message_end`
- The complete accumulated content is included in `message_end.extra.content`
- Throws exception if `messageId` is not a string
**Send vs SendStream Comparison:**
| Feature | `Send()` | `SendStream()` |
| --------------------- | ----------------- | ------------------- |
| `message_start` event | ✅ Auto | ✅ Auto |
| `message_end` event | ✅ Auto | ❌ Manual (`End()`) |
| Use case | Complete messages | Streaming output |
| Content accumulation | N/A | Via `Append()` |
| Storage | Immediate | On `End()` |
**Streaming Workflow Example:**
```javascript
function Create(ctx, messages) {
// Start streaming output
const msgId = ctx.SendStream({
type: "text",
props: { content: "# Analysis Report\n\n" },
});
// Simulate streaming chunks
ctx.Append(msgId, "## Section 1\n");
ctx.Append(msgId, "Processing data...\n\n");
// Do some work
const result = analyzeData();
ctx.Append(msgId, "## Section 2\n");
ctx.Append(msgId, `Found ${result.count} items.\n\n`);
// Finalize with conclusion
ctx.End(msgId, "## Conclusion\nAnalysis complete.");
return { messages };
}
```
#### `ctx.Replace(messageId, message): string`

View file

@ -50,6 +50,7 @@ type BufferedMessage struct {
Sequence int `json:"sequence"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
IsStreaming bool `json:"-"` // Internal flag: true if message is still streaming (not saved until End)
}
// BufferedStep represents an execution step waiting to be saved (for Resume)
@ -160,13 +161,14 @@ func (b *ChatBuffer) AddUserInput(content interface{}, name string) {
// AddAssistantMessage adds an assistant message to the buffer
// This is called by ctx.Send() to buffer messages for batch saving
func (b *ChatBuffer) AddAssistantMessage(msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
func (b *ChatBuffer) AddAssistantMessage(messageID, msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
// Skip event type messages (transient, not stored)
if msgType == "event" {
return
}
b.AddMessage(&BufferedMessage{
MessageID: messageID, // Use the same MessageID as sent to client
Role: "assistant",
Type: msgType,
Props: props,
@ -178,6 +180,93 @@ func (b *ChatBuffer) AddAssistantMessage(msgType string, props map[string]interf
})
}
// AddStreamingMessage adds a streaming message to the buffer
// Streaming messages are not saved until CompleteStreamingMessage is called
// This is called by ctx.SendStream() to start a streaming message
func (b *ChatBuffer) AddStreamingMessage(messageID, msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
// Skip event type messages (transient, not stored)
if msgType == "event" {
return
}
// Deep copy props to avoid mutation issues
propsCopy := make(map[string]interface{})
for k, v := range props {
propsCopy[k] = v
}
b.AddMessage(&BufferedMessage{
MessageID: messageID, // Use provided message ID
Role: "assistant",
Type: msgType,
Props: propsCopy,
BlockID: blockID,
ThreadID: threadID,
AssistantID: assistantID,
Connector: b.connector,
Metadata: metadata,
IsStreaming: true, // Mark as streaming
})
}
// AppendMessageContent appends content to a streaming message
// This is called by ctx.Append() to accumulate content
func (b *ChatBuffer) AppendMessageContent(messageID string, content string) bool {
b.mu.Lock()
defer b.mu.Unlock()
// Find the message by ID
for _, msg := range b.messages {
if msg.MessageID == messageID && msg.IsStreaming {
// Append to existing content
if msg.Props == nil {
msg.Props = make(map[string]interface{})
}
if existing, ok := msg.Props["content"].(string); ok {
msg.Props["content"] = existing + content
} else {
msg.Props["content"] = content
}
return true
}
}
return false
}
// CompleteStreamingMessage marks a streaming message as complete
// This is called by ctx.End() to finalize the message
// Returns the complete content for the message_end event
func (b *ChatBuffer) CompleteStreamingMessage(messageID string) (string, bool) {
b.mu.Lock()
defer b.mu.Unlock()
// Find the message by ID
for _, msg := range b.messages {
if msg.MessageID == messageID && msg.IsStreaming {
msg.IsStreaming = false
// Return the accumulated content
if content, ok := msg.Props["content"].(string); ok {
return content, true
}
return "", true
}
}
return "", false
}
// GetStreamingMessage returns a streaming message by ID
func (b *ChatBuffer) GetStreamingMessage(messageID string) *BufferedMessage {
b.mu.Lock()
defer b.mu.Unlock()
for _, msg := range b.messages {
if msg.MessageID == messageID && msg.IsStreaming {
return msg
}
}
return nil
}
// GetMessages returns all buffered messages
func (b *ChatBuffer) GetMessages() []*BufferedMessage {
b.mu.Lock()

View file

@ -165,6 +165,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
t.Run("AddTextMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "")
buffer.AddAssistantMessage(
"M1",
"text",
map[string]interface{}{"content": "Hello, how can I help?"},
"block-1",
@ -175,6 +176,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
messages := buffer.GetMessages()
require.Len(t, messages, 1)
assert.Equal(t, "M1", messages[0].MessageID)
assert.Equal(t, "assistant", messages[0].Role)
assert.Equal(t, "text", messages[0].Type)
assert.Equal(t, "block-1", messages[0].BlockID)
@ -186,6 +188,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
t.Run("SkipEventMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-2", "req-2", "assistant-2", "")
buffer.AddAssistantMessage(
"E1",
"event",
map[string]interface{}{"event": "message_start"},
"", "", "", nil,
@ -198,6 +201,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
t.Run("AddRetrievalMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-3", "req-3", "assistant-3", "")
buffer.AddAssistantMessage(
"M2",
"retrieval",
map[string]interface{}{
"sources": []map[string]interface{}{
@ -216,6 +220,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
t.Run("AddToolCallMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-4", "req-4", "assistant-4", "")
buffer.AddAssistantMessage(
"M3",
"tool_call",
map[string]interface{}{
"name": "get_weather",
@ -233,6 +238,7 @@ func TestBufferAddAssistantMessage(t *testing.T) {
t.Run("AddCustomTypeMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-5", "req-5", "assistant-5", "")
buffer.AddAssistantMessage(
"M4",
"custom_chart",
map[string]interface{}{
"chart_type": "bar",
@ -277,7 +283,7 @@ func TestBufferGetMessageCount(t *testing.T) {
buffer.AddUserInput("Message 1", "")
assert.Equal(t, 1, buffer.GetMessageCount())
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Reply"}, "", "", "", nil)
buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Reply"}, "", "", "", nil)
assert.Equal(t, 2, buffer.GetMessageCount())
}
@ -650,6 +656,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
// Add assistant message - should inherit connector from buffer
buffer.AddAssistantMessage(
"M1",
"text",
map[string]interface{}{"content": "Hello"},
"block-1", "thread-1", "assistant-1", nil,
@ -665,6 +672,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
// First message with openai
buffer.AddAssistantMessage(
"M1",
"text",
map[string]interface{}{"content": "Using OpenAI"},
"", "", "assistant-1", nil,
@ -675,6 +683,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
// Second message with anthropic
buffer.AddAssistantMessage(
"M2",
"text",
map[string]interface{}{"content": "Now using Claude"},
"", "", "assistant-1", nil,
@ -707,6 +716,7 @@ func TestBufferConnectorInMessages(t *testing.T) {
for i, conn := range connectors {
buffer.SetConnector(conn)
buffer.AddAssistantMessage(
fmt.Sprintf("M%d", i+1),
"text",
map[string]interface{}{"content": fmt.Sprintf("Message %d", i+1)},
"", "", "assistant-1", nil,
@ -908,8 +918,8 @@ func TestBufferEdgeCases(t *testing.T) {
"custom_type_1", "custom_type_2",
}
for _, msgType := range messageTypes {
buffer.AddAssistantMessage(msgType, map[string]interface{}{"type": msgType}, "", "", "", nil)
for i, msgType := range messageTypes {
buffer.AddAssistantMessage(fmt.Sprintf("M%d", i+1), msgType, map[string]interface{}{"type": msgType}, "", "", "", nil)
}
assert.Equal(t, len(messageTypes), buffer.GetMessageCount())
@ -948,12 +958,12 @@ func TestBufferCompleteWorkflow(t *testing.T) {
// 2. Create hook
buffer.BeginStep(context.StepTypeHookCreate, nil, nil)
buffer.AddAssistantMessage("thinking", map[string]interface{}{"content": "Processing your request..."}, "block-1", "", "assistant-main", nil)
buffer.AddAssistantMessage("M1", "thinking", map[string]interface{}{"content": "Processing your request..."}, "block-1", "", "assistant-main", nil)
buffer.CompleteStep(nil)
// 3. LLM call with tool
buffer.BeginStep(context.StepTypeLLM, map[string]interface{}{"model": "gpt-4"}, nil)
buffer.AddAssistantMessage("tool_call", map[string]interface{}{
buffer.AddAssistantMessage("M2", "tool_call", map[string]interface{}{
"name": "get_weather",
"arguments": `{"location":"San Francisco"}`,
}, "block-2", "", "assistant-main", nil)
@ -961,14 +971,14 @@ func TestBufferCompleteWorkflow(t *testing.T) {
// 4. Tool execution
buffer.BeginStep(context.StepTypeTool, map[string]interface{}{"tool": "get_weather"}, nil)
buffer.AddAssistantMessage("tool_result", map[string]interface{}{
buffer.AddAssistantMessage("M3", "tool_result", map[string]interface{}{
"result": "72°F, Sunny",
}, "block-2", "", "assistant-main", nil)
buffer.CompleteStep(map[string]interface{}{"result": "72°F, Sunny"})
// 5. Final LLM response
buffer.BeginStep(context.StepTypeLLM, nil, nil)
buffer.AddAssistantMessage("text", map[string]interface{}{
buffer.AddAssistantMessage("M4", "text", map[string]interface{}{
"content": "The weather in San Francisco is currently 72°F and sunny.",
}, "block-3", "", "assistant-main", nil)
buffer.CompleteStep(nil)
@ -998,7 +1008,7 @@ func TestBufferCompleteWorkflow(t *testing.T) {
// 2. LLM starts generating
buffer.BeginStep(context.StepTypeLLM, map[string]interface{}{"model": "gpt-4"}, nil)
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Once upon a time..."}, "block-1", "", "assistant-main", nil)
buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Once upon a time..."}, "block-1", "", "assistant-main", nil)
// User interrupts here!
// Get steps for resume
@ -1028,13 +1038,13 @@ func TestBufferCompleteWorkflow(t *testing.T) {
buffer.BeginStep(context.StepTypeDelegate, map[string]interface{}{"delegate_to": "assistant-child"}, childStack)
// Child assistant messages
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Child assistant responding"}, "block-child", "", "assistant-child", nil)
buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Child assistant responding"}, "block-child", "", "assistant-child", nil)
buffer.CompleteStep(map[string]interface{}{"delegate_result": "success"})
// Return to main assistant
buffer.SetAssistantID("assistant-main")
buffer.BeginStep(context.StepTypeLLM, nil, mainStack)
buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Main assistant continuing"}, "block-main", "", "assistant-main", nil)
buffer.AddAssistantMessage("M2", "text", map[string]interface{}{"content": "Main assistant continuing"}, "block-main", "", "assistant-main", nil)
buffer.CompleteStep(nil)
// Verify
@ -1064,6 +1074,7 @@ func TestBufferCompleteWorkflow(t *testing.T) {
defer wg.Done()
threadID := fmt.Sprintf("thread-%d", idx)
buffer.AddAssistantMessage(
fmt.Sprintf("M%d", idx),
"text",
map[string]interface{}{"content": fmt.Sprintf("Response from thread %d", idx)},
"block-concurrent",
@ -1113,9 +1124,9 @@ func TestBufferMessageSequence(t *testing.T) {
buffer := context.NewChatBuffer("chat-mixed", "req-mixed", "assistant-mixed", "")
buffer.AddUserInput("Hello", "")
buffer.AddAssistantMessage("text", nil, "", "", "", nil)
buffer.AddAssistantMessage("M1", "text", nil, "", "", "", nil)
buffer.AddUserInput("Follow up", "")
buffer.AddAssistantMessage("tool_call", nil, "", "", "", nil)
buffer.AddAssistantMessage("M2", "tool_call", nil, "", "", "", nil)
messages := buffer.GetMessages()
assert.Len(t, messages, 4)
@ -1169,3 +1180,238 @@ func TestBufferMultipleRequests(t *testing.T) {
assert.Equal(t, "req-2", msg2.RequestID)
})
}
// =============================================================================
// Streaming Message Tests
// =============================================================================
func TestBufferStreamingMessage(t *testing.T) {
t.Run("AddStreamingMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
buffer.AddStreamingMessage(
"msg-stream-1",
"text",
map[string]interface{}{"content": "# Title\n\n"},
"block-1",
"thread-1",
"assistant-1",
nil,
)
assert.Equal(t, 1, buffer.GetMessageCount())
// Verify streaming message is added
msg := buffer.GetStreamingMessage("msg-stream-1")
assert.NotNil(t, msg)
assert.Equal(t, "msg-stream-1", msg.MessageID)
assert.Equal(t, "text", msg.Type)
assert.Equal(t, "# Title\n\n", msg.Props["content"])
assert.True(t, msg.IsStreaming)
})
t.Run("AppendMessageContent", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Add streaming message
buffer.AddStreamingMessage(
"msg-stream-2",
"text",
map[string]interface{}{"content": "Initial "},
"", "", "", nil,
)
// Append content
ok := buffer.AppendMessageContent("msg-stream-2", "Line 1\n")
assert.True(t, ok)
ok = buffer.AppendMessageContent("msg-stream-2", "Line 2\n")
assert.True(t, ok)
// Verify accumulated content
msg := buffer.GetStreamingMessage("msg-stream-2")
assert.NotNil(t, msg)
assert.Equal(t, "Initial Line 1\nLine 2\n", msg.Props["content"])
})
t.Run("AppendToNonExistentMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Try to append to non-existent message
ok := buffer.AppendMessageContent("non-existent", "content")
assert.False(t, ok)
})
t.Run("AppendToCompletedMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Add and complete streaming message
buffer.AddStreamingMessage(
"msg-stream-3",
"text",
map[string]interface{}{"content": "Initial"},
"", "", "", nil,
)
buffer.CompleteStreamingMessage("msg-stream-3")
// Try to append to completed message (should fail)
ok := buffer.AppendMessageContent("msg-stream-3", " more")
assert.False(t, ok)
})
t.Run("CompleteStreamingMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Add streaming message
buffer.AddStreamingMessage(
"msg-stream-4",
"text",
map[string]interface{}{"content": "Hello "},
"", "", "", nil,
)
// Append content
buffer.AppendMessageContent("msg-stream-4", "World!")
// Complete the message
content, ok := buffer.CompleteStreamingMessage("msg-stream-4")
assert.True(t, ok)
assert.Equal(t, "Hello World!", content)
// Message should no longer be streaming
msg := buffer.GetStreamingMessage("msg-stream-4")
assert.Nil(t, msg)
// But should still exist in messages
messages := buffer.GetMessages()
assert.Equal(t, 1, len(messages))
assert.False(t, messages[0].IsStreaming)
})
t.Run("CompleteNonExistentMessage", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
content, ok := buffer.CompleteStreamingMessage("non-existent")
assert.False(t, ok)
assert.Empty(t, content)
})
t.Run("StreamingMessageWorkflow", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "deepseek")
// Simulate a typical streaming workflow:
// 1. SendStream sends initial content
buffer.AddStreamingMessage(
"msg-workflow",
"text",
map[string]interface{}{"content": "# Available Tests\n\n"},
"block-main",
"",
"assistant-1",
nil,
)
// 2. Multiple Append calls add content
buffer.AppendMessageContent("msg-workflow", "Send one of these keywords:\n\n")
buffer.AppendMessageContent("msg-workflow", "- **basic** - Basic tests\n")
buffer.AppendMessageContent("msg-workflow", "- **advanced** - Advanced tests\n")
// 3. End completes the message
finalContent, ok := buffer.CompleteStreamingMessage("msg-workflow")
assert.True(t, ok)
expectedContent := "# Available Tests\n\nSend one of these keywords:\n\n- **basic** - Basic tests\n- **advanced** - Advanced tests\n"
assert.Equal(t, expectedContent, finalContent)
// Verify final message state
messages := buffer.GetMessages()
assert.Equal(t, 1, len(messages))
assert.Equal(t, "msg-workflow", messages[0].MessageID)
assert.Equal(t, "deepseek", messages[0].Connector) // Connector should be set
assert.False(t, messages[0].IsStreaming)
})
t.Run("MixedStreamingAndRegularMessages", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Add user input (regular)
buffer.AddUserInput("Hello", "user1")
// Add streaming assistant message
buffer.AddStreamingMessage(
"msg-stream",
"text",
map[string]interface{}{"content": "Hi "},
"", "", "", nil,
)
buffer.AppendMessageContent("msg-stream", "there!")
buffer.CompleteStreamingMessage("msg-stream")
// Add regular assistant message
buffer.AddAssistantMessage("M3", "text", map[string]interface{}{"content": "How can I help?"}, "", "", "", nil)
// Verify all messages
messages := buffer.GetMessages()
assert.Equal(t, 3, len(messages))
// Check sequence
assert.Equal(t, 1, messages[0].Sequence)
assert.Equal(t, 2, messages[1].Sequence)
assert.Equal(t, 3, messages[2].Sequence)
// Check content
assert.Equal(t, "user", messages[0].Role)
assert.Equal(t, "Hi there!", messages[1].Props["content"])
assert.Equal(t, "How can I help?", messages[2].Props["content"])
})
t.Run("StreamingMessageWithEmptyInitialContent", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Add streaming message with nil props
buffer.AddStreamingMessage(
"msg-empty",
"text",
nil,
"", "", "", nil,
)
// Append content
buffer.AppendMessageContent("msg-empty", "Content")
// Complete
content, ok := buffer.CompleteStreamingMessage("msg-empty")
assert.True(t, ok)
assert.Equal(t, "Content", content)
})
t.Run("ConcurrentStreamingOperations", func(t *testing.T) {
buffer := context.NewChatBuffer("chat-1", "req-1", "assistant-1", "openai")
// Add streaming message
buffer.AddStreamingMessage(
"msg-concurrent",
"text",
map[string]interface{}{"content": ""},
"", "", "", nil,
)
// Concurrent appends with fixed-length content
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
buffer.AppendMessageContent("msg-concurrent", "x")
}()
}
wg.Wait()
// Complete
content, ok := buffer.CompleteStreamingMessage("msg-concurrent")
assert.True(t, ok)
// Content should have 100 'x' characters
assert.Equal(t, 100, len(content))
})
}

View file

@ -430,12 +430,12 @@ func (ctx *Context) BufferUserInput(messages []Message) {
// BufferAssistantMessage adds an assistant message to the buffer
// Called by ctx.Send() to buffer messages for batch saving
func (ctx *Context) BufferAssistantMessage(msgType string, props map[string]interface{}, blockID, threadID string, metadata map[string]interface{}) {
func (ctx *Context) BufferAssistantMessage(messageID, msgType string, props map[string]interface{}, blockID, threadID string, metadata map[string]interface{}) {
if ctx.Buffer == nil {
return
}
ctx.Buffer.AddAssistantMessage(msgType, props, blockID, threadID, ctx.AssistantID, metadata)
ctx.Buffer.AddAssistantMessage(messageID, msgType, props, blockID, threadID, ctx.AssistantID, metadata)
}
// BeginStep starts tracking a new execution step

View file

@ -44,10 +44,12 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
// Set methods
jsObject.Set("Send", ctx.sendMethod(v8ctx.Isolate()))
jsObject.Set("SendStream", ctx.sendStreamMethod(v8ctx.Isolate()))
jsObject.Set("Replace", ctx.replaceMethod(v8ctx.Isolate()))
jsObject.Set("Append", ctx.appendMethod(v8ctx.Isolate()))
jsObject.Set("Merge", ctx.mergeMethod(v8ctx.Isolate()))
jsObject.Set("Set", ctx.setMethod(v8ctx.Isolate()))
jsObject.Set("End", ctx.endMethod(v8ctx.Isolate()))
// Set ID generator methods
jsObject.Set("MessageID", ctx.messageIDMethod(v8ctx.Isolate()))
@ -266,6 +268,103 @@ func (ctx *Context) sendMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
})
}
// sendStreamMethod implements ctx.SendStream(message)
// Usage: const msgId = ctx.SendStream({ type: "text", props: { content: "Initial content" } })
// Starts a streaming message that can be appended to with ctx.Append()
// Must be finalized with ctx.End(msgId) or ctx.End(msgId, "final content")
// Unlike Send(), this does NOT automatically send message_end event
// Returns: message_id (string)
func (ctx *Context) sendStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if len(args) < 1 {
return bridge.JsException(v8ctx, "SendStream requires a message argument")
}
// Parse message argument
msg, err := parseMessage(v8ctx, args[0])
if err != nil {
return bridge.JsException(v8ctx, "invalid message: "+err.Error())
}
// Get optional blockId argument (second argument)
if len(args) >= 2 && args[1].IsString() && msg.BlockID == "" {
msg.BlockID = args[1].String()
}
// Call ctx.SendStream
messageID, err := ctx.SendStream(msg)
if err != nil {
return bridge.JsException(v8ctx, "SendStream failed: "+err.Error())
}
// Automatically flush after sending
if err := ctx.Flush(); err != nil {
return bridge.JsException(v8ctx, "Flush failed: "+err.Error())
}
// Return the message ID
returnID, err := v8go.NewValue(iso, messageID)
if err != nil {
return bridge.JsException(v8ctx, "Failed to create return value: "+err.Error())
}
return returnID
})
}
// endMethod implements ctx.End(messageId, finalContent?)
// Usage: ctx.End(msgId) or ctx.End(msgId, "final content to append")
// Finalizes a streaming message started with SendStream()
// Sends message_end event with the complete accumulated content
// Returns: message_id (string)
func (ctx *Context) endMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if len(args) < 1 {
return bridge.JsException(v8ctx, "End requires a messageId argument")
}
// Get message ID (first argument)
if !args[0].IsString() {
return bridge.JsException(v8ctx, "messageId must be a string")
}
messageID := args[0].String()
// Get optional final content (second argument)
var finalContent string
if len(args) >= 2 && args[1].IsString() {
finalContent = args[1].String()
}
// Call ctx.End
var err error
if finalContent != "" {
err = ctx.End(messageID, finalContent)
} else {
err = ctx.End(messageID)
}
if err != nil {
return bridge.JsException(v8ctx, "End failed: "+err.Error())
}
// Automatically flush after sending
if err := ctx.Flush(); err != nil {
return bridge.JsException(v8ctx, "Flush failed: "+err.Error())
}
// Return the message ID
returnID, err := v8go.NewValue(iso, messageID)
if err != nil {
return bridge.JsException(v8ctx, "Failed to create return value: "+err.Error())
}
return returnID
})
}
// replaceMethod implements ctx.Replace(messageId, message)
// Usage: ctx.Replace(messageId, { type: "text", props: { content: "Updated content" } })
// Replaces the entire message content with the specified message_id

View file

@ -826,3 +826,518 @@ func TestJsValueEndBlock(t *testing.T) {
output := mockWriter.buffer.String()
assert.Contains(t, output, "block_end", "Output should contain block_end event")
}
// TestJsValueSendStream tests the SendStream method on Context
func TestJsValueSendStream(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Setup mock writer
mockWriter := newMockResponseWriter()
// Use New() to properly initialize messageMetadata
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
// Test SendStream method
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// Start a streaming message
const msgId = ctx.SendStream({
type: "text",
props: { content: "Initial content" }
});
// Verify msgId is returned
if (typeof msgId !== 'string' || msgId === '') {
throw new Error('SendStream should return a message ID');
}
return { success: true, msgId: msgId };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
if !result["success"].(bool) {
t.Logf("Error: %v", result["error"])
}
assert.Equal(t, true, result["success"], "SendStream should work correctly")
// Verify message_start was sent but NOT message_end
output := mockWriter.buffer.String()
assert.Contains(t, output, "message_start", "Output should contain message_start event")
assert.NotContains(t, output, "message_end", "Output should NOT contain message_end event (streaming)")
}
// TestJsValueSendStreamWithBlockID tests SendStream with block_id parameter
func TestJsValueSendStreamWithBlockID(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// Generate block ID
const blockId = ctx.BlockID();
// Start streaming with block_id
const msgId = ctx.SendStream({
type: "text",
props: { content: "Streaming with block" },
block_id: blockId
});
return { success: true, msgId: msgId, blockId: blockId };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
assert.Equal(t, true, result["success"], "SendStream with blockId should succeed")
// Verify block_start was also sent
output := mockWriter.buffer.String()
assert.Contains(t, output, "block_start", "Output should contain block_start event")
}
// TestJsValueEnd tests the End method on Context
func TestJsValueEnd(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// Start a streaming message
const msgId = ctx.SendStream({
type: "text",
props: { content: "Hello" }
});
// End the message
ctx.End(msgId);
return { success: true, msgId: msgId };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
if !result["success"].(bool) {
t.Logf("Error: %v", result["error"])
}
assert.Equal(t, true, result["success"], "End should work correctly")
// Verify message_end was sent
output := mockWriter.buffer.String()
assert.Contains(t, output, "message_end", "Output should contain message_end event after End()")
}
// TestJsValueEndWithFinalContent tests End with final content parameter
func TestJsValueEndWithFinalContent(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// Start a streaming message
const msgId = ctx.SendStream({
type: "text",
props: { content: "Start" }
});
// End with final content
ctx.End(msgId, " End");
return { success: true, msgId: msgId };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
if !result["success"].(bool) {
t.Logf("Error: %v", result["error"])
}
assert.Equal(t, true, result["success"], "End with final content should work correctly")
// Verify message_end was sent
output := mockWriter.buffer.String()
assert.Contains(t, output, "message_end", "Output should contain message_end event")
}
// TestJsValueStreamingWorkflow tests the complete streaming workflow: SendStream -> Append -> End
func TestJsValueStreamingWorkflow(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// Start a streaming message
const msgId = ctx.SendStream({
type: "text",
props: { content: "# Title\n\n" }
});
// Append content in chunks (simulating streaming)
ctx.Append(msgId, "First paragraph. ");
ctx.Append(msgId, "Second sentence. ");
ctx.Append(msgId, "Third sentence.\n\n");
ctx.Append(msgId, "Second paragraph.");
// Finalize the message
ctx.End(msgId);
return { success: true, msgId: msgId };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
if !result["success"].(bool) {
t.Logf("Error: %v", result["error"])
}
assert.Equal(t, true, result["success"], "Streaming workflow should work correctly")
// Verify the complete workflow events
output := mockWriter.buffer.String()
assert.Contains(t, output, "message_start", "Output should contain message_start")
assert.Contains(t, output, "message_end", "Output should contain message_end")
assert.Contains(t, output, "# Title", "Output should contain initial content")
assert.Contains(t, output, "First paragraph", "Output should contain appended content")
}
// TestJsValueSendStreamStringShorthand tests SendStream with string shorthand
func TestJsValueSendStreamStringShorthand(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// SendStream with string shorthand
const msgId = ctx.SendStream("Hello streaming");
if (typeof msgId !== 'string' || msgId === '') {
throw new Error('SendStream should return a message ID');
}
ctx.End(msgId);
return { success: true, msgId: msgId };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
assert.Equal(t, true, result["success"], "SendStream with string shorthand should succeed")
}
// TestJsValueEndErrorHandling tests error handling in End method
func TestJsValueEndErrorHandling(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
// Test End without arguments
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
ctx.End();
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
assert.Equal(t, false, result["success"], "End without arguments should fail")
assert.Contains(t, result["error"], "messageId", "Error should mention missing messageId")
}
// TestJsValueEndWithInvalidMessageID tests End with invalid messageId type
func TestJsValueEndWithInvalidMessageID(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
// Test End with non-string messageId
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
ctx.End(123);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
assert.Equal(t, false, result["success"], "End with non-string messageId should fail")
assert.Contains(t, result["error"], "string", "Error should mention messageId must be string")
}
// TestJsValueSendStreamErrorHandling tests error handling in SendStream method
func TestJsValueSendStreamErrorHandling(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
// Test SendStream without arguments
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
ctx.SendStream();
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
assert.Equal(t, false, result["success"], "SendStream without arguments should fail")
assert.Contains(t, result["error"], "SendStream requires a message argument", "Error should mention missing message")
}
// TestJsValueMultipleStreams tests handling multiple concurrent streaming messages
func TestJsValueMultipleStreams(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
try {
// Start multiple streaming messages
const msg1 = ctx.SendStream({ type: "text", props: { content: "Stream 1: " } });
const msg2 = ctx.SendStream({ type: "text", props: { content: "Stream 2: " } });
// Interleave appends
ctx.Append(msg1, "A");
ctx.Append(msg2, "X");
ctx.Append(msg1, "B");
ctx.Append(msg2, "Y");
ctx.Append(msg1, "C");
ctx.Append(msg2, "Z");
// End both streams
ctx.End(msg1);
ctx.End(msg2);
return { success: true, msg1: msg1, msg2: msg2 };
} catch (error) {
return { success: false, error: error.message };
}
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
result, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", res)
}
if !result["success"].(bool) {
t.Logf("Error: %v", result["error"])
}
assert.Equal(t, true, result["success"], "Multiple streams should work correctly")
assert.NotEqual(t, result["msg1"], result["msg2"], "Message IDs should be different")
}
// TestJsValueSendVsSendStream tests the difference between Send and SendStream
func TestJsValueSendVsSendStream(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Test Send - should auto-send message_end
t.Run("Send auto-ends", func(t *testing.T) {
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
ctx.Send("Complete message");
return true;
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
output := mockWriter.buffer.String()
assert.Contains(t, output, "message_start", "Send should emit message_start")
assert.Contains(t, output, "message_end", "Send should auto-emit message_end")
})
// Test SendStream - should NOT auto-send message_end
t.Run("SendStream requires explicit End", func(t *testing.T) {
mockWriter := newMockResponseWriter()
cxt := New(context.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Accept = AcceptWebCUI
cxt.Locale = "en"
cxt.Writer = mockWriter
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
const msgId = ctx.SendStream("Streaming message");
// Intentionally NOT calling ctx.End(msgId)
return msgId;
}`, cxt)
if err != nil {
t.Fatalf("Call failed: %v", err)
}
output := mockWriter.buffer.String()
assert.Contains(t, output, "message_start", "SendStream should emit message_start")
assert.NotContains(t, output, "message_end", "SendStream should NOT auto-emit message_end")
})
}

View file

@ -55,6 +55,13 @@ func (ctx *Context) Send(msg *message.Message) error {
// Increment chunk count for this message
metadata.ChunkCount++
// Update Buffer content for streaming messages (for storage)
if ctx.Buffer != nil && msg.Props != nil {
if content, ok := msg.Props["content"].(string); ok {
ctx.Buffer.AppendMessageContent(msg.MessageID, content)
}
}
}
}
@ -156,6 +163,7 @@ func (ctx *Context) Send(msg *message.Message) error {
assistantID = ctx.Stack.AssistantID
}
ctx.Buffer.AddAssistantMessage(
msg.MessageID, // Use the same MessageID as sent to client
msg.Type,
msg.Props,
msg.BlockID,
@ -207,6 +215,189 @@ func (ctx *Context) Send(msg *message.Message) error {
return nil
}
// SendStream sends a streaming message that can be appended to later
// Unlike Send(), this does NOT automatically send message_end event
// Use ctx.Append() to add content, then ctx.End() to finalize
// Returns the message ID for use with Append/End
func (ctx *Context) SendStream(msg *message.Message) (string, error) {
out, err := ctx.getOutput()
if err != nil {
return "", err
}
// Skip lifecycle events for event-type messages
isEventMessage := msg.Type == message.TypeEvent
if isEventMessage {
// Event messages should use Send(), not SendStream()
return "", ctx.Send(msg)
}
// === Auto-generate ChunkID ===
if msg.ChunkID == "" {
if ctx.IDGenerator != nil {
msg.ChunkID = ctx.IDGenerator.GenerateChunkID()
} else {
msg.ChunkID = message.GenerateNanoID()
}
}
// === Auto-set ThreadID for non-root Stack ===
if msg.ThreadID == "" && ctx.Stack != nil && !ctx.Stack.IsRoot() {
msg.ThreadID = ctx.Stack.ID
}
// === Handle BlockID and block_start event ===
if msg.BlockID != "" && ctx.messageMetadata != nil {
if ctx.messageMetadata.getBlock(msg.BlockID) == nil {
blockStartData := message.EventBlockStartData{
BlockID: msg.BlockID,
Type: "mixed",
Timestamp: time.Now().UnixMilli(),
}
blockStartEvent := output.NewEventMessage(message.EventBlockStart, "Block started", blockStartData)
if err := ctx.sendRaw(blockStartEvent); err != nil {
return "", err
}
ctx.messageMetadata.setBlock(msg.BlockID, &BlockMetadata{
BlockID: msg.BlockID,
Type: "mixed",
StartTime: time.Now(),
MessageCount: 0,
})
}
ctx.messageMetadata.updateBlock(msg.BlockID, func(block *BlockMetadata) {
block.MessageCount++
})
}
// === Generate MessageID if not provided ===
if msg.MessageID == "" {
if ctx.IDGenerator != nil {
msg.MessageID = ctx.IDGenerator.GenerateMessageID()
} else {
msg.MessageID = message.GenerateNanoID()
}
}
// === Send message_start event ===
messageStartData := message.EventMessageStartData{
MessageID: msg.MessageID,
Type: msg.Type,
Timestamp: time.Now().UnixMilli(),
ThreadID: msg.ThreadID,
}
messageStartEvent := output.NewEventMessage(message.EventMessageStart, "Message started", messageStartData)
if err := ctx.sendRaw(messageStartEvent); err != nil {
return "", err
}
// === Record message metadata ===
if ctx.messageMetadata != nil {
ctx.messageMetadata.setMessage(msg.MessageID, &MessageMetadata{
MessageID: msg.MessageID,
BlockID: msg.BlockID,
ThreadID: msg.ThreadID,
Type: msg.Type,
StartTime: time.Now(),
ChunkCount: 1,
})
}
// === Actually send the message ===
if err := out.Send(msg); err != nil {
return "", err
}
// === Buffer streaming message (will be completed by End()) ===
if ctx.Buffer != nil && !ctx.shouldSkipHistory() {
assistantID := ""
if ctx.Stack != nil {
assistantID = ctx.Stack.AssistantID
}
ctx.Buffer.AddStreamingMessage(
msg.MessageID,
msg.Type,
msg.Props,
msg.BlockID,
msg.ThreadID,
assistantID,
nil,
)
}
// NOTE: No message_end event here - will be sent by End()
return msg.MessageID, nil
}
// End finalizes a streaming message started with SendStream
// Optionally appends final content before sending message_end event
// This also saves the complete message to the buffer for storage
func (ctx *Context) End(messageID string, finalContent ...string) error {
if messageID == "" {
return nil
}
// Append final content if provided
if len(finalContent) > 0 && finalContent[0] != "" {
// Create a delta message for the final content
deltaMsg := &message.Message{
MessageID: messageID,
Type: message.TypeText,
Delta: true,
DeltaAction: message.DeltaAppend,
Props: map[string]interface{}{
"content": finalContent[0],
},
}
if err := ctx.Send(deltaMsg); err != nil {
return err
}
}
// Get complete content from buffer
var completeContent string
if ctx.Buffer != nil {
completeContent, _ = ctx.Buffer.CompleteStreamingMessage(messageID)
}
// Get metadata for duration calculation
var durationMs int64
var threadID string
var chunkCount int
var msgType string = message.TypeText
if ctx.messageMetadata != nil {
if metadata := ctx.messageMetadata.getMessage(messageID); metadata != nil {
durationMs = time.Since(metadata.StartTime).Milliseconds()
threadID = metadata.ThreadID
chunkCount = metadata.ChunkCount
msgType = metadata.Type
}
}
// Build message_end event data
endData := message.EventMessageEndData{
MessageID: messageID,
Type: msgType,
Timestamp: time.Now().UnixMilli(),
ThreadID: threadID,
DurationMs: durationMs,
ChunkCount: chunkCount,
Status: "completed",
}
// Add complete content to extra
if completeContent != "" {
endData.Extra = map[string]interface{}{
"content": completeContent,
}
}
// Send message_end event
messageEndEvent := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData)
return ctx.sendRaw(messageEndEvent)
}
// EndMessage sends a message_end event for a completed message
// Note: For non-delta messages, message_end is automatically sent by Send()
// This method is primarily for delta streaming scenarios:

View file

@ -141,34 +141,35 @@ Stores user-visible messages (both user input and assistant responses).
**Table Name:** `agent_message`
| Column | Type | Nullable | Index | Description |
| -------------- | ----------- | -------- | ------ | ----------------------------------------- |
| `id` | ID | No | PK | Auto-increment primary key |
| `message_id` | string(64) | No | Unique | Unique message identifier |
| `chat_id` | string(64) | No | Yes | Parent chat ID |
| `request_id` | string(64) | Yes | Yes | Request ID for grouping |
| `role` | enum | No | Yes | Role: `user`, `assistant` |
| `type` | string(50) | No | - | Message type (text, image, loading, etc.) |
| `props` | json | No | - | Message properties (content, url, etc.) |
| `block_id` | string(64) | Yes | Yes | Block grouping ID |
| `thread_id` | string(64) | Yes | Yes | Thread grouping ID |
| `assistant_id` | string(200) | Yes | Yes | Assistant ID (join to get name/avatar) |
| `connector` | string(200) | Yes | Yes | Connector ID used for this message |
| `sequence` | integer | No | - | Message order within chat (in composite) |
| `metadata` | json | Yes | - | Additional metadata |
| `created_at` | timestamp | No | Yes | Creation timestamp |
| `updated_at` | timestamp | No | - | Last update timestamp |
| Column | Type | Nullable | Index | Description |
| -------------- | ----------- | -------- | ----- | ------------------------------------------ |
| `id` | ID | No | PK | Auto-increment primary key |
| `message_id` | string(64) | No | - | Message identifier (unique within request) |
| `chat_id` | string(64) | No | Yes | Parent chat ID |
| `request_id` | string(64) | Yes | Yes | Request ID for grouping |
| `role` | enum | No | Yes | Role: `user`, `assistant` |
| `type` | string(50) | No | - | Message type (text, image, loading, etc.) |
| `props` | json | No | - | Message properties (content, url, etc.) |
| `block_id` | string(64) | Yes | Yes | Block grouping ID |
| `thread_id` | string(64) | Yes | Yes | Thread grouping ID |
| `assistant_id` | string(200) | Yes | Yes | Assistant ID (join to get name/avatar) |
| `connector` | string(200) | Yes | Yes | Connector ID used for this message |
| `sequence` | integer | No | - | Message order within chat (in composite) |
| `metadata` | json | Yes | - | Additional metadata |
| `created_at` | timestamp | No | Yes | Creation timestamp |
| `updated_at` | timestamp | No | - | Last update timestamp |
**Indexes:**
| Name | Columns | Type |
| ------------------- | --------------------- | ----- |
| `idx_msg_chat_seq` | `chat_id`, `sequence` | index |
| `idx_msg_request` | `request_id` | index |
| `idx_msg_role` | `role` | index |
| `idx_msg_block` | `block_id` | index |
| `idx_msg_thread` | `thread_id` | index |
| `idx_msg_assistant` | `assistant_id` | index |
| Name | Columns | Type |
| ------------------------- | -------------------------- | ------ |
| `idx_msg_chat_seq` | `chat_id`, `sequence` | index |
| `idx_msg_request_message` | `request_id`, `message_id` | unique |
| `idx_msg_request` | `request_id` | index |
| `idx_msg_role` | `role` | index |
| `idx_msg_block` | `block_id` | index |
| `idx_msg_thread` | `thread_id` | index |
| `idx_msg_assistant` | `assistant_id` | index |
**Message Types:**

File diff suppressed because it is too large Load diff

View file

@ -18,10 +18,9 @@
"name": "message_id",
"type": "string",
"label": "Message ID",
"comment": "Unique message identifier",
"comment": "Message identifier (unique within request)",
"length": 64,
"nullable": false,
"unique": true
"nullable": false
},
{
"name": "chat_id",
@ -136,6 +135,12 @@
"columns": ["chat_id", "sequence"],
"type": "index",
"comment": "Index for message ordering within chat"
},
{
"name": "idx_msg_request_message",
"columns": ["request_id", "message_id"],
"type": "unique",
"comment": "Unique constraint for message_id within request"
}
],
"option": { "timestamps": true, "soft_deletes": true }