2026-03-30 14:09:15 +00:00
|
|
|
|
package parser
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func makeAssistantLine(text string) string {
|
|
|
|
|
|
obj := map[string]interface{}{
|
|
|
|
|
|
"type": "assistant",
|
|
|
|
|
|
"message": map[string]interface{}{
|
|
|
|
|
|
"content": []map[string]interface{}{
|
|
|
|
|
|
{"type": "text", "text": text},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
b, _ := json.Marshal(obj)
|
|
|
|
|
|
return string(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func makeResultLine() string {
|
|
|
|
|
|
b, _ := json.Marshal(map[string]string{"type": "result", "subtype": "success"})
|
|
|
|
|
|
return string(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
func TestStreamParserFragmentMode(t *testing.T) {
|
|
|
|
|
|
// cursor --stream-partial-output 模式:每個訊息是獨立 token fragment
|
2026-03-30 14:09:15 +00:00
|
|
|
|
var texts []string
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
2026-04-01 00:53:34 +00:00
|
|
|
|
func() {},
|
2026-03-30 14:09:15 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(makeAssistantLine("你"))
|
|
|
|
|
|
p.Parse(makeAssistantLine("好!有"))
|
|
|
|
|
|
p.Parse(makeAssistantLine("什"))
|
|
|
|
|
|
p.Parse(makeAssistantLine("麼"))
|
2026-03-30 14:09:15 +00:00
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
if len(texts) != 4 {
|
|
|
|
|
|
t.Fatalf("expected 4 fragments, got %d: %v", len(texts), texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
if texts[0] != "你" || texts[1] != "好!有" || texts[2] != "什" || texts[3] != "麼" {
|
|
|
|
|
|
t.Fatalf("unexpected texts: %v", texts)
|
2026-03-30 14:09:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
func TestStreamParserDeduplicatesFinalFullText(t *testing.T) {
|
|
|
|
|
|
// 最後一個訊息是完整的累積文字,應被跳過(去重)
|
2026-03-30 14:09:15 +00:00
|
|
|
|
var texts []string
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(makeAssistantLine("Hello"))
|
|
|
|
|
|
p.Parse(makeAssistantLine(" world"))
|
|
|
|
|
|
// 最後一個是完整累積文字,應被去重
|
|
|
|
|
|
p.Parse(makeAssistantLine("Hello world"))
|
|
|
|
|
|
|
2026-03-30 14:09:15 +00:00
|
|
|
|
if len(texts) != 2 {
|
2026-04-01 00:53:34 +00:00
|
|
|
|
t.Fatalf("expected 2 fragments (final full text deduplicated), got %d: %v", len(texts), texts)
|
2026-03-30 14:09:15 +00:00
|
|
|
|
}
|
2026-04-01 00:53:34 +00:00
|
|
|
|
if texts[0] != "Hello" || texts[1] != " world" {
|
2026-03-30 14:09:15 +00:00
|
|
|
|
t.Fatalf("unexpected texts: %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserCallsOnDone(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
doneCount := 0
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() { doneCount++ },
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(makeResultLine())
|
2026-03-30 14:09:15 +00:00
|
|
|
|
if doneCount != 1 {
|
|
|
|
|
|
t.Fatalf("expected onDone called once, got %d", doneCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(texts) != 0 {
|
|
|
|
|
|
t.Fatalf("expected no text, got %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserIgnoresLinesAfterDone(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
doneCount := 0
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() { doneCount++ },
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(makeResultLine())
|
|
|
|
|
|
p.Parse(makeAssistantLine("late"))
|
2026-03-30 14:09:15 +00:00
|
|
|
|
if len(texts) != 0 {
|
|
|
|
|
|
t.Fatalf("expected no text after done, got %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
if doneCount != 1 {
|
|
|
|
|
|
t.Fatalf("expected onDone called once, got %d", doneCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserIgnoresNonAssistantLines(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
b1, _ := json.Marshal(map[string]interface{}{"type": "user", "message": map[string]interface{}{}})
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(string(b1))
|
2026-03-30 14:09:15 +00:00
|
|
|
|
b2, _ := json.Marshal(map[string]interface{}{
|
|
|
|
|
|
"type": "assistant",
|
|
|
|
|
|
"message": map[string]interface{}{"content": []interface{}{}},
|
|
|
|
|
|
})
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(string(b2))
|
|
|
|
|
|
p.Parse(`{"type":"assistant","message":{"content":[{"type":"code","text":"x"}]}}`)
|
2026-03-30 14:09:15 +00:00
|
|
|
|
|
|
|
|
|
|
if len(texts) != 0 {
|
|
|
|
|
|
t.Fatalf("expected no texts, got %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserIgnoresParseErrors(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
doneCount := 0
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() { doneCount++ },
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse("not json")
|
|
|
|
|
|
p.Parse("{")
|
|
|
|
|
|
p.Parse("")
|
2026-03-30 14:09:15 +00:00
|
|
|
|
|
|
|
|
|
|
if len(texts) != 0 || doneCount != 0 {
|
|
|
|
|
|
t.Fatalf("expected nothing, got texts=%v done=%d", texts, doneCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserJoinsMultipleTextParts(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p := CreateStreamParser(
|
2026-03-30 14:09:15 +00:00
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
obj := map[string]interface{}{
|
|
|
|
|
|
"type": "assistant",
|
|
|
|
|
|
"message": map[string]interface{}{
|
|
|
|
|
|
"content": []map[string]interface{}{
|
|
|
|
|
|
{"type": "text", "text": "Hello"},
|
|
|
|
|
|
{"type": "text", "text": " "},
|
|
|
|
|
|
{"type": "text", "text": "world"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
b, _ := json.Marshal(obj)
|
2026-04-01 00:53:34 +00:00
|
|
|
|
p.Parse(string(b))
|
2026-03-30 14:09:15 +00:00
|
|
|
|
|
|
|
|
|
|
if len(texts) != 1 || texts[0] != "Hello world" {
|
|
|
|
|
|
t.Fatalf("expected ['Hello world'], got %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-01 00:53:34 +00:00
|
|
|
|
|
|
|
|
|
|
func TestStreamParserFlushTriggersDone(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
doneCount := 0
|
|
|
|
|
|
p := CreateStreamParser(
|
|
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func() { doneCount++ },
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.Parse(makeAssistantLine("Hello"))
|
|
|
|
|
|
// agent 結束但沒有 result/success,手動 flush
|
|
|
|
|
|
p.Flush()
|
|
|
|
|
|
if doneCount != 1 {
|
|
|
|
|
|
t.Fatalf("expected onDone called once after Flush, got %d", doneCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 再 flush 不應重複觸發
|
|
|
|
|
|
p.Flush()
|
|
|
|
|
|
if doneCount != 1 {
|
|
|
|
|
|
t.Fatalf("expected onDone called only once, got %d", doneCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserFlushAfterDoneIsNoop(t *testing.T) {
|
|
|
|
|
|
doneCount := 0
|
|
|
|
|
|
p := CreateStreamParser(
|
|
|
|
|
|
func(text string) {},
|
|
|
|
|
|
func() { doneCount++ },
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.Parse(makeResultLine())
|
|
|
|
|
|
p.Flush()
|
|
|
|
|
|
if doneCount != 1 {
|
|
|
|
|
|
t.Fatalf("expected onDone called once, got %d", doneCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func makeThinkingLine(thinking string) string {
|
|
|
|
|
|
obj := map[string]interface{}{
|
|
|
|
|
|
"type": "assistant",
|
|
|
|
|
|
"message": map[string]interface{}{
|
|
|
|
|
|
"content": []map[string]interface{}{
|
|
|
|
|
|
{"type": "thinking", "thinking": thinking},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
b, _ := json.Marshal(obj)
|
|
|
|
|
|
return string(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func makeThinkingAndTextLine(thinking, text string) string {
|
|
|
|
|
|
obj := map[string]interface{}{
|
|
|
|
|
|
"type": "assistant",
|
|
|
|
|
|
"message": map[string]interface{}{
|
|
|
|
|
|
"content": []map[string]interface{}{
|
|
|
|
|
|
{"type": "thinking", "thinking": thinking},
|
|
|
|
|
|
{"type": "text", "text": text},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
b, _ := json.Marshal(obj)
|
|
|
|
|
|
return string(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserWithThinkingCallsOnThinking(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
var thinkings []string
|
|
|
|
|
|
p := CreateStreamParserWithThinking(
|
|
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
func(thinking string) { thinkings = append(thinkings, thinking) },
|
|
|
|
|
|
func() {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.Parse(makeThinkingLine("思考中..."))
|
|
|
|
|
|
p.Parse(makeAssistantLine("回答"))
|
|
|
|
|
|
|
|
|
|
|
|
if len(thinkings) != 1 || thinkings[0] != "思考中..." {
|
|
|
|
|
|
t.Fatalf("expected thinkings=['思考中...'], got %v", thinkings)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(texts) != 1 || texts[0] != "回答" {
|
|
|
|
|
|
t.Fatalf("expected texts=['回答'], got %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserWithThinkingNilOnThinkingIgnoresThinking(t *testing.T) {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
p := CreateStreamParserWithThinking(
|
|
|
|
|
|
func(text string) { texts = append(texts, text) },
|
|
|
|
|
|
nil,
|
|
|
|
|
|
func() {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.Parse(makeThinkingLine("忽略的思考"))
|
|
|
|
|
|
p.Parse(makeAssistantLine("文字"))
|
|
|
|
|
|
|
|
|
|
|
|
if len(texts) != 1 || texts[0] != "文字" {
|
|
|
|
|
|
t.Fatalf("expected texts=['文字'], got %v", texts)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestStreamParserWithThinkingDeduplication(t *testing.T) {
|
|
|
|
|
|
var thinkings []string
|
|
|
|
|
|
p := CreateStreamParserWithThinking(
|
|
|
|
|
|
func(text string) {},
|
|
|
|
|
|
func(thinking string) { thinkings = append(thinkings, thinking) },
|
|
|
|
|
|
func() {},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
p.Parse(makeThinkingLine("A"))
|
|
|
|
|
|
p.Parse(makeThinkingLine("B"))
|
|
|
|
|
|
// 重複的完整思考,應被跳過
|
|
|
|
|
|
p.Parse(makeThinkingLine("AB"))
|
|
|
|
|
|
|
|
|
|
|
|
if len(thinkings) != 2 || thinkings[0] != "A" || thinkings[1] != "B" {
|
|
|
|
|
|
t.Fatalf("expected thinkings=['A','B'], got %v", thinkings)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|