WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
// Package sse provides helpers for consuming server-sent event streams from
// the WeKnora SDK (KnowledgeQAStream, ContinueStream).
//
// Accumulator is the canonical sink: every callback event appends to a
// buffered Content string and updates terminal-state fields like References
// and SessionID. The non-streaming JSON mode reads .Result() once .Done()
// is true; streaming mode writes Content tokens directly to stdout and
// only consults the accumulator for the final References footer.
package sse
import (
"strings"
sdk "github.com/Tencent/WeKnora/client"
)
// Accumulator buffers a KnowledgeQAStream callback sequence.
//
// Zero value is ready to use. Not safe for concurrent Append calls - the SDK
// invokes its callback sequentially on a single goroutine, so this matches
// the only contract that exists today.
//
// Demuxes by ResponseType so the answer string is not polluted by thinking
// or reflection fragments - the model layer (internal/models/chat/
// remote_api.go) emits ResponseTypeThinking events whenever the upstream
// LLM produces reasoning_content frames, and without demux those would
// be silently concatenated into Result().
type Accumulator struct {
answer strings.Builder
thinking strings.Builder
References []*sdk.SearchResult
SessionID string
AssistantMessageID string
finished bool
}
// Append consumes one StreamResponse event. Safe to call multiple times;
// idempotent post-Done so callers do not need to special-case late events.
func (a *Accumulator) Append(r *sdk.StreamResponse) {
if r == nil || a.finished {
return
}
switch r.ResponseType {
case sdk.ResponseTypeAnswer:
if r.Content != "" {
a.answer.WriteString(r.Content)
}
case sdk.ResponseTypeThinking, sdk.ResponseTypeReflection:
if r.Content != "" {
a.thinking.WriteString(r.Content)
}
default:
// Frames without a typed ResponseType (legacy / metadata-only
// payloads) that still carry an answer fragment fall through here.
// Preserve the legacy contract by treating untyped Content as
// answer text.
if r.ResponseType == "" && r.Content != "" {
a.answer.WriteString(r.Content)
}
}
if r.SessionID != "" && a.SessionID == "" {
a.SessionID = r.SessionID
}
if r.AssistantMessageID != "" && a.AssistantMessageID == "" {
a.AssistantMessageID = r.AssistantMessageID
}
// Capture references whenever they arrive - they may land on a
// dedicated `references` event before the terminal `complete`.
if r.KnowledgeReferences != nil {
a.References = r.KnowledgeReferences
}
// Stream is only truly done on response_type=complete. Other events
// (notably the leading agent_query metadata frame) carry done=true to
// mark their own per-event completion, but the answer fragments arrive
// in subsequent response_type=answer events. Gating on response_type
// avoids the off-by-one termination that would discard the entire
// answer.
if r.ResponseType == sdk.ResponseTypeComplete {
a.finished = true
}
}
// Done reports whether a terminal event was observed.
func (a *Accumulator) Done() bool { return a.finished }
// Result returns the accumulated answer-event content.
func (a *Accumulator) Result() string { return a.answer.String() }
// Thinking returns the accumulated reasoning / reflection content surfaced
// by the upstream LLM (only populated when the active model produces
// reasoning_content). Empty for non-reasoning models.
func (a *Accumulator) Thinking() string { return a.thinking.String() }
+123
View File
@@ -0,0 +1,123 @@
package sse_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/sse"
sdk "github.com/Tencent/WeKnora/client"
)
// terminator is the canonical "stream is done" event the accumulator
// recognises. The server emits response_type=complete after the answer
// fragments and any references; per-event done=true on agent_query /
// answer frames is NOT terminal.
var terminator = &sdk.StreamResponse{ResponseType: sdk.ResponseTypeComplete, Done: true}
func TestAccumulator_AppendsContent(t *testing.T) {
a := &sse.Accumulator{}
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "Hello "})
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "world"})
if got := a.Result(); got != "Hello world" {
t.Errorf("got %q, want %q", got, "Hello world")
}
if a.Done() {
t.Error("expected Done=false before terminal event")
}
}
func TestAccumulator_FinalizesOnComplete(t *testing.T) {
a := &sse.Accumulator{}
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "answer"})
// References may arrive on a dedicated event before the terminator.
a.Append(&sdk.StreamResponse{
ResponseType: sdk.ResponseTypeReferences,
KnowledgeReferences: []*sdk.SearchResult{{KnowledgeID: "k1"}},
})
a.Append(terminator)
if !a.Done() {
t.Error("expected Done=true after response_type=complete")
}
if len(a.References) != 1 {
t.Errorf("expected 1 reference, got %d", len(a.References))
}
if a.References[0].KnowledgeID != "k1" {
t.Errorf("references payload not preserved: %+v", a.References[0])
}
}
func TestAccumulator_IgnoresAgentQueryDone(t *testing.T) {
// Server emits a leading agent_query frame with done=true to deliver
// session metadata; the accumulator must NOT treat that as terminal -
// otherwise the answer fragments that follow would be discarded.
a := &sse.Accumulator{}
a.Append(&sdk.StreamResponse{
ResponseType: sdk.ResponseTypeAgentQuery,
Done: true,
SessionID: "sess_123",
AssistantMessageID: "msg_456",
})
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "real answer"})
a.Append(terminator)
if !a.Done() {
t.Error("expected Done=true after response_type=complete")
}
if got := a.Result(); got != "real answer" {
t.Errorf("agent_query done=true should not terminate; got %q", got)
}
if a.SessionID != "sess_123" {
t.Errorf("session metadata from agent_query frame lost: got %q", a.SessionID)
}
}
func TestAccumulator_IgnoresPostComplete(t *testing.T) {
a := &sse.Accumulator{}
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "first"})
a.Append(terminator)
a.Append(&sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: "after"})
if got := a.Result(); got != "first" {
t.Errorf("post-complete append should be no-op, got %q", got)
}
}
func TestAccumulator_NilSafe(t *testing.T) {
a := &sse.Accumulator{}
a.Append(nil)
if got := a.Result(); got != "" {
t.Errorf("expected empty result for nil append, got %q", got)
}
if a.Done() {
t.Error("nil append must not finalize")
}
}
func TestAccumulator_CapturesSessionMetadata(t *testing.T) {
a := &sse.Accumulator{}
a.Append(&sdk.StreamResponse{
ResponseType: sdk.ResponseTypeAnswer,
SessionID: "sess_123",
AssistantMessageID: "msg_456",
Content: "hi",
})
a.Append(terminator)
if a.SessionID != "sess_123" {
t.Errorf("SessionID: got %q", a.SessionID)
}
if a.AssistantMessageID != "msg_456" {
t.Errorf("AssistantMessageID: got %q", a.AssistantMessageID)
}
}
func TestAccumulator_FirstSessionMetadataWins(t *testing.T) {
// Subsequent events must not overwrite the first non-empty value - the
// session id is set once at session start and any later override would be
// a server bug we should not silently mask.
a := &sse.Accumulator{}
a.Append(&sdk.StreamResponse{SessionID: "sess_first", AssistantMessageID: "msg_first"})
a.Append(&sdk.StreamResponse{SessionID: "sess_second", AssistantMessageID: "msg_second"})
if a.SessionID != "sess_first" {
t.Errorf("SessionID overwritten: got %q want sess_first", a.SessionID)
}
if a.AssistantMessageID != "msg_first" {
t.Errorf("AssistantMessageID overwritten: got %q want msg_first", a.AssistantMessageID)
}
}
+90
View File
@@ -0,0 +1,90 @@
package sse
import (
"strings"
sdk "github.com/Tencent/WeKnora/client"
)
// AgentToolEvent captures one tool_call / tool_result event from an
// agent SSE stream. Kind is the SDK event type (typed, not bare string)
// so consumers can compare against sdk.AgentResponseTypeToolCall /
// sdk.AgentResponseTypeToolResult without retyping the constants.
// NOTE: Kind is the event kind, NOT the function name; the function
// name typically lives in Data for tool_call events.
type AgentToolEvent struct {
ID string `json:"id"`
Kind sdk.AgentResponseType `json:"kind,omitempty"`
Result string `json:"result,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
// AgentAccumulator buffers an AgentQAStream callback sequence. Distinct
// from Accumulator (KnowledgeQAStream) because the agent event model is
// wider - events include thinking / reflection / tool_call / tool_result
// / answer / references / error / complete. Done is scoped to an individual
// event stream (thinking, reflection, answer, etc.); only response_type=complete
// terminates the whole agent stream.
//
// Zero value is ready to use. Not safe for concurrent Append calls - the
// SDK callback contract is sequential on a single goroutine.
//
// API mirrors sse.Accumulator: private builders + accessor methods so the
// "Append is idempotent post-Done" invariant cannot be broken by external
// mutation. References and ToolEvents stay exported as plain slices since
// they carry no such invariant.
type AgentAccumulator struct {
answer strings.Builder
thinking strings.Builder
References []*sdk.SearchResult
ToolEvents []AgentToolEvent
done bool
}
// Answer returns the accumulated `answer`-event content.
func (a *AgentAccumulator) Answer() string { return a.answer.String() }
// Thinking returns the accumulated `thinking` / `reflection` content
// surfaced by the agent during its reasoning pass.
func (a *AgentAccumulator) Thinking() string { return a.thinking.String() }
// Done reports whether the stream emitted a terminal frame.
func (a *AgentAccumulator) Done() bool { return a.done }
// Append consumes one AgentStreamResponse event. Idempotent post-Done so
// callers do not need to special-case late events.
func (a *AgentAccumulator) Append(r *sdk.AgentStreamResponse) {
if r == nil || a.done {
return
}
switch r.ResponseType {
case sdk.AgentResponseTypeAnswer:
if r.Content != "" {
a.answer.WriteString(r.Content)
}
case sdk.AgentResponseTypeThinking, sdk.AgentResponseTypeReflection:
if r.Content != "" {
a.thinking.WriteString(r.Content)
}
case sdk.AgentResponseTypeToolCall, sdk.AgentResponseTypeToolResult:
a.ToolEvents = append(a.ToolEvents, AgentToolEvent{
ID: r.ID,
Kind: r.ResponseType,
Result: r.Content,
Data: r.Data,
})
}
// References can arrive on a dedicated `references` event OR
// piggyback on another event's KnowledgeReferences field; always
// capture the latest, matching sse.Accumulator's "always replace"
// semantic for the parallel KnowledgeQAStream contract.
if r.KnowledgeReferences != nil {
a.References = r.KnowledgeReferences
}
// Thinking / reflection / answer streams each emit their own Done=true
// marker before later events arrive. Only the explicit complete frame is
// terminal for the whole agent run.
if r.ResponseType == sdk.AgentResponseTypeComplete {
a.done = true
}
}
@@ -0,0 +1,49 @@
package sse_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/sse"
sdk "github.com/Tencent/WeKnora/client"
)
func TestAgentAccumulator_FinalizesOnlyOnComplete(t *testing.T) {
a := &sse.AgentAccumulator{}
preTerminal := []*sdk.AgentStreamResponse{
{ResponseType: sdk.AgentResponseTypeThinking, Content: "think", Done: true},
{ResponseType: sdk.AgentResponseTypeToolCall, ID: "call_1", Content: "search"},
{ResponseType: sdk.AgentResponseTypeReflection, Content: "reflect", Done: true},
{ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
{ID: "chunk_1", Content: "bulky passage"},
}},
{ResponseType: sdk.AgentResponseTypeAnswer, Content: "final answer", Done: true},
}
for _, event := range preTerminal {
a.Append(event)
if a.Done() {
t.Fatalf("%s Done=true terminated the whole stream", event.ResponseType)
}
}
a.Append(&sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeComplete, Done: true})
if !a.Done() {
t.Fatal("complete event did not terminate the stream")
}
if got := a.Answer(); got != "final answer" {
t.Errorf("answer=%q, want final answer", got)
}
if got := a.Thinking(); got != "thinkreflect" {
t.Errorf("thinking=%q, want thinkreflect", got)
}
if len(a.ToolEvents) != 1 {
t.Errorf("tool_events=%d, want 1", len(a.ToolEvents))
}
if len(a.References) != 1 || a.References[0].Content != "bulky passage" {
t.Errorf("references were not preserved: %+v", a.References)
}
a.Append(&sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeAnswer, Content: "late"})
if got := a.Answer(); got != "final answer" {
t.Errorf("post-complete event was appended: %q", got)
}
}
+289
View File
@@ -0,0 +1,289 @@
package sse
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/Tencent/WeKnora/cli/internal/format"
sdk "github.com/Tencent/WeKnora/client"
)
// ProjectedEvent is the bounded event representation shared by
// buffered JSON, human-readable text, and MCP tool results. It deliberately
// mirrors the SDK field names so consumers can move between projected JSON and
// raw NDJSON without learning a second event vocabulary.
type ProjectedEvent struct {
ID string `json:"id,omitempty"`
ResponseType string `json:"response_type"`
Content string `json:"content,omitempty"`
Done bool `json:"done"`
KnowledgeReferences []format.ReferenceIndex `json:"knowledge_references,omitempty"`
SessionID string `json:"session_id,omitempty"`
AssistantMessageID string `json:"assistant_message_id,omitempty"`
ToolCalls []sdk.LLMToolCall `json:"tool_calls,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
// Projector observes every SDK frame for lifecycle state, then returns the
// presentation event selected by the output contract. Default output keeps
// answer frames only. Reference enables bounded citation events; verbose
// enables execution and lifecycle events.
type Projector struct {
verbose bool
reference bool
fallbackKBID string
seen bool
done bool
sessionID string
assistantMessageID string
}
func NewProjector(verbose, reference bool, fallbackKBID string) *Projector {
return &Projector{verbose: verbose, reference: reference, fallbackKBID: fallbackKBID}
}
func (p *Projector) Seen() bool { return p.seen }
func (p *Projector) Done() bool { return p.done }
func (p *Projector) SessionID() string { return p.sessionID }
func (p *Projector) AssistantMessageID() string { return p.assistantMessageID }
// Chat consumes one KnowledgeQAStream frame.
func (p *Projector) Chat(r *sdk.StreamResponse) (ProjectedEvent, bool) {
if r == nil || p.done {
return ProjectedEvent{}, false
}
p.seen = true
if p.sessionID == "" && r.SessionID != "" {
p.sessionID = r.SessionID
}
if p.assistantMessageID == "" && r.AssistantMessageID != "" {
p.assistantMessageID = r.AssistantMessageID
}
if r.ResponseType == sdk.ResponseTypeComplete {
p.done = true
}
if r.ResponseType == sdk.ResponseTypeError && r.Done {
p.done = true
}
responseType := string(r.ResponseType)
isAnswer := r.ResponseType == sdk.ResponseTypeAnswer || (r.ResponseType == "" && r.Content != "")
isReference := r.ResponseType == sdk.ResponseTypeReferences
include := isAnswer || (p.verbose && !isReference) || (p.reference && isReference)
if !include && p.reference && len(r.KnowledgeReferences) > 0 {
return p.chatReferenceEvent(r), true
}
if !include {
return ProjectedEvent{}, false
}
if responseType == "" {
responseType = string(sdk.ResponseTypeAnswer)
}
event := ProjectedEvent{
ID: r.ID,
ResponseType: responseType,
Content: r.Content,
Done: r.Done,
SessionID: r.SessionID,
AssistantMessageID: r.AssistantMessageID,
ToolCalls: r.ToolCalls,
Data: r.Data,
}
if p.reference {
event.KnowledgeReferences = format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID)
}
return event, true
}
// Agent consumes one AgentQAStream frame.
func (p *Projector) Agent(r *sdk.AgentStreamResponse) (ProjectedEvent, bool) {
if r == nil || p.done {
return ProjectedEvent{}, false
}
p.seen = true
if r.ResponseType == sdk.AgentResponseTypeComplete {
p.done = true
}
if r.ResponseType == sdk.AgentResponseTypeError && r.Done {
p.done = true
}
isAnswer := r.ResponseType == sdk.AgentResponseTypeAnswer
isReference := r.ResponseType == sdk.AgentResponseTypeReferences
include := isAnswer || (p.verbose && !isReference) || (p.reference && isReference)
if !include && p.reference && len(r.KnowledgeReferences) > 0 {
return p.agentReferenceEvent(r), true
}
if !include {
return ProjectedEvent{}, false
}
event := ProjectedEvent{
ID: r.ID,
ResponseType: string(r.ResponseType),
Content: r.Content,
Done: r.Done,
Data: r.Data,
}
if p.reference {
event.KnowledgeReferences = format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID)
}
return event, true
}
func (p *Projector) chatReferenceEvent(r *sdk.StreamResponse) ProjectedEvent {
return ProjectedEvent{
ID: r.ID,
ResponseType: string(sdk.ResponseTypeReferences),
Done: r.Done,
KnowledgeReferences: format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID),
SessionID: r.SessionID,
AssistantMessageID: r.AssistantMessageID,
}
}
func (p *Projector) agentReferenceEvent(r *sdk.AgentStreamResponse) ProjectedEvent {
return ProjectedEvent{
ID: r.ID,
ResponseType: string(sdk.AgentResponseTypeReferences),
Done: r.Done,
KnowledgeReferences: format.IndexReferences(r.KnowledgeReferences, p.fallbackKBID),
}
}
// TextRenderer writes projected events as a human-readable stream.
// TTY detection is intentionally absent: terminals and pipes receive the same
// filtering semantics; callers may add styling later without changing them.
type TextRenderer struct {
w io.Writer
verbose bool
lastKey string
atBOL bool
}
func NewTextRenderer(w io.Writer, verbose bool) *TextRenderer {
return &TextRenderer{w: w, verbose: verbose, atBOL: true}
}
func (r *TextRenderer) Write(ev ProjectedEvent) error {
switch ev.ResponseType {
case string(sdk.ResponseTypeAnswer):
return r.writeContentBlock(ev, "answer")
case string(sdk.ResponseTypeThinking), string(sdk.ResponseTypeReflection):
return r.writeContentBlock(ev, ev.ResponseType)
case string(sdk.ResponseTypeReferences):
return r.writeReferences(ev)
default:
return r.writeStructured(ev)
}
}
func (r *TextRenderer) Close() error {
if !r.atBOL {
return r.writeString("\n")
}
return nil
}
func (r *TextRenderer) writeContentBlock(ev ProjectedEvent, label string) error {
key := ev.ResponseType + "\x00" + ev.ID
if key != r.lastKey {
if err := r.ensureBOL(); err != nil {
return err
}
if r.verbose {
if err := r.writeString("[" + label + "]\n"); err != nil {
return err
}
}
r.lastKey = key
}
if ev.Content != "" {
if err := r.writeString(ev.Content); err != nil {
return err
}
}
if ev.Done {
r.lastKey = ""
return r.ensureBOL()
}
return nil
}
func (r *TextRenderer) writeReferences(ev ProjectedEvent) error {
if err := r.ensureBOL(); err != nil {
return err
}
if err := r.writeString("[references]\n"); err != nil {
return err
}
for _, ref := range ev.KnowledgeReferences {
line := "- chunk_id=" + ref.ChunkID
if ref.KBID != "" {
line += " kb_id=" + ref.KBID
}
if ref.ParentChunkID != "" {
line += " parent_chunk_id=" + ref.ParentChunkID
}
if err := r.writeString(line + "\n"); err != nil {
return err
}
}
r.lastKey = ""
return nil
}
func (r *TextRenderer) writeStructured(ev ProjectedEvent) error {
if err := r.ensureBOL(); err != nil {
return err
}
header := "[" + ev.ResponseType
if ev.ID != "" {
header += " " + ev.ID
}
header += "]"
if err := r.writeString(header); err != nil {
return err
}
if ev.Content != "" {
if err := r.writeString(" " + strings.ReplaceAll(ev.Content, "\n", " ")); err != nil {
return err
}
}
if len(ev.ToolCalls) > 0 {
b, _ := json.Marshal(ev.ToolCalls)
if err := r.writeString(" " + string(b)); err != nil {
return err
}
}
if len(ev.Data) > 0 {
b, _ := json.Marshal(ev.Data)
if err := r.writeString(" " + string(b)); err != nil {
return err
}
}
if err := r.writeString("\n"); err != nil {
return err
}
r.lastKey = ""
return nil
}
func (r *TextRenderer) ensureBOL() error {
if r.atBOL {
return nil
}
return r.writeString("\n")
}
func (r *TextRenderer) writeString(s string) error {
if s == "" {
return nil
}
if _, err := fmt.Fprint(r.w, s); err != nil {
return err
}
r.atBOL = strings.HasSuffix(s, "\n")
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package sse_test
import (
"bytes"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/sse"
sdk "github.com/Tencent/WeKnora/client"
)
func TestProjector_DefaultKeepsOnlyAnswerEvents(t *testing.T) {
p := sse.NewProjector(false, false, "kb_fallback")
input := []*sdk.StreamResponse{
{ID: "think", ResponseType: sdk.ResponseTypeThinking, Content: "hidden"},
{ID: "a", ResponseType: sdk.ResponseTypeAnswer, Content: "one", KnowledgeReferences: []*sdk.SearchResult{{ID: "piggyback", Content: "bulk"}}},
{ID: "refs", ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1", Content: "bulk"}}},
{ID: "a", ResponseType: sdk.ResponseTypeAnswer, Content: "two", Done: true},
{ResponseType: sdk.ResponseTypeComplete, Done: true},
}
var got []sse.ProjectedEvent
for _, event := range input {
if projected, ok := p.Chat(event); ok {
got = append(got, projected)
}
}
if !p.Done() || len(got) != 2 {
t.Fatalf("done=%v events=%+v", p.Done(), got)
}
if got[0].Content != "one" || got[1].Content != "two" {
t.Errorf("answer order changed: %+v", got)
}
if len(got[0].KnowledgeReferences) != 0 {
t.Errorf("default answer leaked piggyback references: %+v", got[0])
}
}
func TestProjector_VerboseAndReferenceIncludeBothDetailClasses(t *testing.T) {
p := sse.NewProjector(true, true, "kb_fallback")
input := []*sdk.AgentStreamResponse{
{ID: "t", ResponseType: sdk.AgentResponseTypeThinking, Content: "think"},
{ID: "call", ResponseType: sdk.AgentResponseTypeToolCall, Content: "search"},
{ID: "refs", ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1", KnowledgeBaseID: "kb1", ParentChunkID: "p1", Content: "bulk"}}},
{ID: "a", ResponseType: sdk.AgentResponseTypeAnswer, Content: "answer"},
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
}
var got []sse.ProjectedEvent
for _, event := range input {
if projected, ok := p.Agent(event); ok {
got = append(got, projected)
}
}
want := []string{"thinking", "tool_call", "references", "answer", "complete"}
if len(got) != len(want) {
t.Fatalf("events=%+v", got)
}
for i := range want {
if got[i].ResponseType != want[i] {
t.Errorf("event[%d]=%q, want %q", i, got[i].ResponseType, want[i])
}
}
refs := got[2].KnowledgeReferences
if len(refs) != 1 || refs[0].KBID != "kb1" || refs[0].ChunkID != "c1" || refs[0].ParentChunkID != "p1" {
t.Errorf("reference index=%+v", refs)
}
if input[2].KnowledgeReferences[0].Content != "bulk" {
t.Error("projection mutated the raw SDK event")
}
}
func TestProjector_ReferenceOnlyAddsIndexesWithoutExecutionTrace(t *testing.T) {
p := sse.NewProjector(false, true, "kb_fallback")
input := []*sdk.AgentStreamResponse{
{ID: "think", ResponseType: sdk.AgentResponseTypeThinking, Content: "hidden"},
{ID: "refs", ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1", KnowledgeBaseID: "kb1", Content: "bulk"}}},
{ID: "answer", ResponseType: sdk.AgentResponseTypeAnswer, Content: "answer"},
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
}
var got []sse.ProjectedEvent
for _, event := range input {
if projected, ok := p.Agent(event); ok {
got = append(got, projected)
}
}
if len(got) != 2 || got[0].ResponseType != "references" || got[1].ResponseType != "answer" {
t.Fatalf("reference-only events=%+v", got)
}
if len(got[0].KnowledgeReferences) != 1 || got[0].KnowledgeReferences[0].ChunkID != "c1" {
t.Errorf("reference indexes=%+v", got[0].KnowledgeReferences)
}
}
func TestProjector_VerboseDoesNotImplicitlyAddReferences(t *testing.T) {
p := sse.NewProjector(true, false, "kb")
input := []*sdk.AgentStreamResponse{
{ResponseType: sdk.AgentResponseTypeThinking, Content: "thinking"},
{ResponseType: sdk.AgentResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{{ID: "c1"}}},
{ResponseType: sdk.AgentResponseTypeAnswer, Content: "answer"},
{ResponseType: sdk.AgentResponseTypeComplete, Done: true},
}
var got []sse.ProjectedEvent
for _, event := range input {
if projected, ok := p.Agent(event); ok {
got = append(got, projected)
}
}
for _, event := range got {
if event.ResponseType == "references" || len(event.KnowledgeReferences) != 0 {
t.Fatalf("verbose leaked references without reference=true: %+v", got)
}
}
}
func TestProjector_TerminalErrorMarksDone(t *testing.T) {
p := sse.NewProjector(false, false, "kb")
_, include := p.Chat(&sdk.StreamResponse{
ResponseType: sdk.ResponseTypeAnswer,
Content: "partial",
})
if !include || p.Done() {
t.Fatalf("answer frame: include=%v done=%v", include, p.Done())
}
_, include = p.Chat(&sdk.StreamResponse{
ResponseType: sdk.ResponseTypeError,
Content: "boom",
Done: true,
})
if include {
t.Fatal("default projection should not include terminal error event")
}
if !p.Done() {
t.Fatal("terminal error did not mark projector done")
}
}
func TestTextRenderer_StreamsSelectedEvents(t *testing.T) {
var out bytes.Buffer
r := sse.NewTextRenderer(&out, true)
events := []sse.ProjectedEvent{
{ID: "t", ResponseType: "thinking", Content: "THINK", Done: true},
{ID: "call", ResponseType: "tool_call", Content: "SEARCH"},
{ID: "a", ResponseType: "answer", Content: "ANSWER", Done: true},
}
for _, event := range events {
if err := r.Write(event); err != nil {
t.Fatal(err)
}
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
got := out.String()
thinking := strings.Index(got, "THINK")
tool := strings.Index(got, "SEARCH")
answer := strings.Index(got, "ANSWER")
if !(thinking >= 0 && thinking < tool && tool < answer) {
t.Errorf("event order changed: %q", got)
}
}