306 lines
9.2 KiB
Go
306 lines
9.2 KiB
Go
|
|
package converter
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestConvertLineAssistant(t *testing.T) {
|
||
|
|
line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}}`
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
|
||
|
|
if result.Skip {
|
||
|
|
t.Error("expected not Skip")
|
||
|
|
}
|
||
|
|
if result.Done {
|
||
|
|
t.Error("expected not Done")
|
||
|
|
}
|
||
|
|
if result.Error != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", result.Error)
|
||
|
|
}
|
||
|
|
if result.Chunk == nil {
|
||
|
|
t.Fatal("expected Chunk, got nil")
|
||
|
|
}
|
||
|
|
if result.Chunk.ID != "chat-123" {
|
||
|
|
t.Errorf("Chunk.ID = %q, want %q", result.Chunk.ID, "chat-123")
|
||
|
|
}
|
||
|
|
if result.Chunk.Object != "chat.completion.chunk" {
|
||
|
|
t.Errorf("Chunk.Object = %q, want %q", result.Chunk.Object, "chat.completion.chunk")
|
||
|
|
}
|
||
|
|
if len(result.Chunk.Choices) != 1 {
|
||
|
|
t.Fatalf("len(Choices) = %d, want 1", len(result.Chunk.Choices))
|
||
|
|
}
|
||
|
|
if *result.Chunk.Choices[0].Delta.Content != "Hello, world!" {
|
||
|
|
t.Errorf("Delta.Content = %q, want %q", *result.Chunk.Choices[0].Delta.Content, "Hello, world!")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvertLineSystem(t *testing.T) {
|
||
|
|
line := `{"type":"system","message":{"role":"system","content":"init"}}`
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
|
||
|
|
if !result.Skip {
|
||
|
|
t.Error("expected Skip for system line")
|
||
|
|
}
|
||
|
|
if result.Chunk != nil {
|
||
|
|
t.Error("expected nil Chunk for system line")
|
||
|
|
}
|
||
|
|
if result.Error != nil {
|
||
|
|
t.Errorf("unexpected error: %v", result.Error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvertLineUser(t *testing.T) {
|
||
|
|
line := `{"type":"user","message":{"role":"user","content":"hello"}}`
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
|
||
|
|
if !result.Skip {
|
||
|
|
t.Error("expected Skip for user line")
|
||
|
|
}
|
||
|
|
if result.Chunk != nil {
|
||
|
|
t.Error("expected nil Chunk for user line")
|
||
|
|
}
|
||
|
|
if result.Error != nil {
|
||
|
|
t.Errorf("unexpected error: %v", result.Error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvertLineResultSuccess(t *testing.T) {
|
||
|
|
line := `{"type":"result","subtype":"success","result":"done","usage":{"inputTokens":100,"outputTokens":50}}`
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
|
||
|
|
if !result.Done {
|
||
|
|
t.Error("expected Done")
|
||
|
|
}
|
||
|
|
if result.Skip {
|
||
|
|
t.Error("expected not Skip")
|
||
|
|
}
|
||
|
|
if result.Error != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", result.Error)
|
||
|
|
}
|
||
|
|
if result.Usage == nil {
|
||
|
|
t.Fatal("expected Usage, got nil")
|
||
|
|
}
|
||
|
|
if result.Usage.InputTokens != 100 {
|
||
|
|
t.Errorf("Usage.InputTokens = %d, want 100", result.Usage.InputTokens)
|
||
|
|
}
|
||
|
|
if result.Usage.OutputTokens != 50 {
|
||
|
|
t.Errorf("Usage.OutputTokens = %d, want 50", result.Usage.OutputTokens)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvertLineResultError(t *testing.T) {
|
||
|
|
line := `{"type":"result","is_error":true,"result":"something went wrong"}`
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
|
||
|
|
if result.Error == nil {
|
||
|
|
t.Fatal("expected error, got nil")
|
||
|
|
}
|
||
|
|
if !strings.Contains(result.Error.Error(), "something went wrong") {
|
||
|
|
t.Errorf("error = %q, want to contain %q", result.Error.Error(), "something went wrong")
|
||
|
|
}
|
||
|
|
if result.Done {
|
||
|
|
t.Error("expected not Done for error")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvertLineEmpty(t *testing.T) {
|
||
|
|
tests := []string{"", " ", "\n", " \n "}
|
||
|
|
for _, line := range tests {
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
if !result.Skip {
|
||
|
|
t.Errorf("expected Skip for empty/whitespace line %q", line)
|
||
|
|
}
|
||
|
|
if result.Error != nil {
|
||
|
|
t.Errorf("unexpected error for empty line: %v", result.Error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConvertLineInvalidJSON(t *testing.T) {
|
||
|
|
line := `{"type":"assistant", invalid json}`
|
||
|
|
result := ConvertLine(line, "chat-123")
|
||
|
|
|
||
|
|
if result.Error == nil {
|
||
|
|
t.Fatal("expected error for invalid JSON, got nil")
|
||
|
|
}
|
||
|
|
if !strings.Contains(result.Error.Error(), "unmarshal error") {
|
||
|
|
t.Errorf("error = %q, want to contain %q", result.Error.Error(), "unmarshal error")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestExtractContent(t *testing.T) {
|
||
|
|
t.Run("nil message", func(t *testing.T) {
|
||
|
|
result := ExtractContent(nil)
|
||
|
|
if result != "" {
|
||
|
|
t.Errorf("ExtractContent(nil) = %q, want empty", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("single content", func(t *testing.T) {
|
||
|
|
msg := &CursorMessage{
|
||
|
|
Role: "assistant",
|
||
|
|
Content: []CursorContent{
|
||
|
|
{Type: "text", Text: "Hello"},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
result := ExtractContent(msg)
|
||
|
|
if result != "Hello" {
|
||
|
|
t.Errorf("ExtractContent() = %q, want %q", result, "Hello")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("multiple content", func(t *testing.T) {
|
||
|
|
msg := &CursorMessage{
|
||
|
|
Role: "assistant",
|
||
|
|
Content: []CursorContent{
|
||
|
|
{Type: "text", Text: "Hello"},
|
||
|
|
{Type: "text", Text: ", "},
|
||
|
|
{Type: "text", Text: "world!"},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
result := ExtractContent(msg)
|
||
|
|
if result != "Hello, world!" {
|
||
|
|
t.Errorf("ExtractContent() = %q, want %q", result, "Hello, world!")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("empty content", func(t *testing.T) {
|
||
|
|
msg := &CursorMessage{
|
||
|
|
Role: "assistant",
|
||
|
|
Content: []CursorContent{},
|
||
|
|
}
|
||
|
|
result := ExtractContent(msg)
|
||
|
|
if result != "" {
|
||
|
|
t.Errorf("ExtractContent() = %q, want empty", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStreamParser_OnlyEmitsNewDeltaFromAccumulatedAssistantMessages(t *testing.T) {
|
||
|
|
parser := NewStreamParser("chat-123")
|
||
|
|
|
||
|
|
first := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hel"}]}}`)
|
||
|
|
if first.Error != nil {
|
||
|
|
t.Fatalf("unexpected error on first chunk: %v", first.Error)
|
||
|
|
}
|
||
|
|
if first.Chunk == nil || first.Chunk.Choices[0].Delta.Content == nil {
|
||
|
|
t.Fatal("expected first chunk content")
|
||
|
|
}
|
||
|
|
if got := *first.Chunk.Choices[0].Delta.Content; got != "Hel" {
|
||
|
|
t.Fatalf("first delta = %q, want %q", got, "Hel")
|
||
|
|
}
|
||
|
|
|
||
|
|
second := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
|
||
|
|
if second.Error != nil {
|
||
|
|
t.Fatalf("unexpected error on second chunk: %v", second.Error)
|
||
|
|
}
|
||
|
|
if second.Chunk == nil || second.Chunk.Choices[0].Delta.Content == nil {
|
||
|
|
t.Fatal("expected second chunk content")
|
||
|
|
}
|
||
|
|
if got := *second.Chunk.Choices[0].Delta.Content; got != "lo" {
|
||
|
|
t.Fatalf("second delta = %q, want %q", got, "lo")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStreamParser_SkipsFinalDuplicateAssistantMessage(t *testing.T) {
|
||
|
|
parser := NewStreamParser("chat-123")
|
||
|
|
|
||
|
|
first := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
|
||
|
|
if first.Skip || first.Error != nil || first.Chunk == nil {
|
||
|
|
t.Fatalf("expected first assistant chunk, got %+v", first)
|
||
|
|
}
|
||
|
|
|
||
|
|
duplicate := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]}}`)
|
||
|
|
if !duplicate.Skip {
|
||
|
|
t.Fatalf("expected duplicate assistant message to be skipped, got %+v", duplicate)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStreamParser_ResultIncludesUsage(t *testing.T) {
|
||
|
|
parser := NewStreamParser("chat-123")
|
||
|
|
|
||
|
|
result := parser.Parse(`{"type":"result","subtype":"success","usage":{"inputTokens":10,"outputTokens":4}}`)
|
||
|
|
if !result.Done {
|
||
|
|
t.Fatal("expected result.Done")
|
||
|
|
}
|
||
|
|
if result.Usage == nil {
|
||
|
|
t.Fatal("expected usage")
|
||
|
|
}
|
||
|
|
if result.Usage.InputTokens != 10 || result.Usage.OutputTokens != 4 {
|
||
|
|
t.Fatalf("unexpected usage: %+v", result.Usage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStreamParser_CanReconstructFinalContentFromIncrementalAssistantMessages(t *testing.T) {
|
||
|
|
parser := NewStreamParser("chat-123")
|
||
|
|
|
||
|
|
lines := []string{
|
||
|
|
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`,
|
||
|
|
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
|
||
|
|
`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好,世界"}]}}`,
|
||
|
|
}
|
||
|
|
|
||
|
|
var content strings.Builder
|
||
|
|
for i, line := range lines {
|
||
|
|
result := parser.Parse(line)
|
||
|
|
if result.Error != nil {
|
||
|
|
t.Fatalf("line %d unexpected error: %v", i, result.Error)
|
||
|
|
}
|
||
|
|
if result.Skip {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if result.Chunk == nil || result.Chunk.Choices[0].Delta.Content == nil {
|
||
|
|
t.Fatalf("line %d expected chunk content, got %+v", i, result)
|
||
|
|
}
|
||
|
|
content.WriteString(*result.Chunk.Choices[0].Delta.Content)
|
||
|
|
}
|
||
|
|
|
||
|
|
if got := content.String(); got != "你好,世界" {
|
||
|
|
t.Fatalf("reconstructed content = %q, want %q", got, "你好,世界")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStreamParser_RawTextFallbackSkipsExactDuplicates(t *testing.T) {
|
||
|
|
parser := NewStreamParser("chat-123")
|
||
|
|
|
||
|
|
first := parser.ParseRawText("plain chunk")
|
||
|
|
if first.Skip || first.Chunk == nil || first.Chunk.Choices[0].Delta.Content == nil {
|
||
|
|
t.Fatalf("expected raw text chunk, got %+v", first)
|
||
|
|
}
|
||
|
|
|
||
|
|
duplicate := parser.ParseRawText("plain chunk")
|
||
|
|
if !duplicate.Skip {
|
||
|
|
t.Fatalf("expected duplicate raw text to be skipped, got %+v", duplicate)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStreamParser_IncrementalFragmentsAccumulateAndSkipFinalDuplicate(t *testing.T) {
|
||
|
|
parser := NewStreamParser("chat-123")
|
||
|
|
|
||
|
|
fragments := []string{"你", "好,", "世", "界!"}
|
||
|
|
var got strings.Builder
|
||
|
|
for i, fr := range fragments {
|
||
|
|
line := fmt.Sprintf(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":%q}]}}`, fr)
|
||
|
|
res := parser.Parse(line)
|
||
|
|
if res.Skip || res.Error != nil || res.Chunk == nil {
|
||
|
|
t.Fatalf("fragment %d: expected delta chunk, got %+v", i, res)
|
||
|
|
}
|
||
|
|
got.WriteString(*res.Chunk.Choices[0].Delta.Content)
|
||
|
|
}
|
||
|
|
|
||
|
|
if got.String() != "你好,世界!" {
|
||
|
|
t.Fatalf("reconstructed = %q, want 你好,世界!", got.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
final := parser.Parse(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好,世界!"}]}}`)
|
||
|
|
if !final.Skip {
|
||
|
|
t.Fatalf("expected final duplicate cumulative message to be skipped, got %+v", final)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewStreamParser_DoesNotExistYet(t *testing.T) {
|
||
|
|
_ = fmt.Sprintf
|
||
|
|
}
|