opencode-cursor-agent/internal/parser/stream_test.go

305 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
func TestStreamParserFragmentMode(t *testing.T) {
// cursor --stream-partial-output 模式:每個訊息是獨立 token fragment
var texts []string
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
p.Parse(makeAssistantLine("你"))
p.Parse(makeAssistantLine("好!有"))
p.Parse(makeAssistantLine("什"))
p.Parse(makeAssistantLine("麼"))
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)
}
}
func TestStreamParserDeduplicatesFinalFullText(t *testing.T) {
// 最後一個訊息是完整的累積文字,應被跳過(去重)
var texts []string
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
p.Parse(makeAssistantLine("Hello"))
p.Parse(makeAssistantLine(" world"))
// 最後一個是完整累積文字,應被去重
p.Parse(makeAssistantLine("Hello world"))
if len(texts) != 2 {
t.Fatalf("expected 2 fragments (final full text deduplicated), got %d: %v", len(texts), texts)
}
if texts[0] != "Hello" || texts[1] != " world" {
t.Fatalf("unexpected texts: %v", texts)
}
}
func TestStreamParserCallsOnDone(t *testing.T) {
var texts []string
doneCount := 0
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse(makeResultLine())
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
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse(makeResultLine())
p.Parse(makeAssistantLine("late"))
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
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() {},
)
b1, _ := json.Marshal(map[string]interface{}{"type": "user", "message": map[string]interface{}{}})
p.Parse(string(b1))
b2, _ := json.Marshal(map[string]interface{}{
"type": "assistant",
"message": map[string]interface{}{"content": []interface{}{}},
})
p.Parse(string(b2))
p.Parse(`{"type":"assistant","message":{"content":[{"type":"code","text":"x"}]}}`)
if len(texts) != 0 {
t.Fatalf("expected no texts, got %v", texts)
}
}
func TestStreamParserIgnoresParseErrors(t *testing.T) {
var texts []string
doneCount := 0
p := CreateStreamParser(
func(text string) { texts = append(texts, text) },
func() { doneCount++ },
)
p.Parse("not json")
p.Parse("{")
p.Parse("")
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
p := CreateStreamParser(
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)
p.Parse(string(b))
if len(texts) != 1 || texts[0] != "Hello world" {
t.Fatalf("expected ['Hello world'], got %v", texts)
}
}
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)
}
}
// TestStreamParserThinkingDuplicateButTextStillEmitted 驗證 bug 修復:
// 當 thinking 重複(去重跳過)但同一行有 text 時text 仍必須輸出。
func TestStreamParserThinkingDuplicateButTextStillEmitted(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() {},
)
// 第一行thinking="思考中" + textthinking 為新增,兩者都應輸出)
p.Parse(makeThinkingAndTextLine("思考中", "第一段"))
// 第二行thinking 與上一行相同(去重),但 text 是新的text 仍應輸出
p.Parse(makeThinkingAndTextLine("思考中", "第二段"))
if len(thinkings) != 1 || thinkings[0] != "思考中" {
t.Fatalf("expected thinkings=['思考中'], got %v", thinkings)
}
if len(texts) != 2 || texts[0] != "第一段" || texts[1] != "第二段" {
t.Fatalf("expected texts=['第一段','第二段'], got %v", texts)
}
}