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
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:
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user