Files
DeepSeek-Reasonix/internal/transcript/projection.go
T
esengine 2dfa1ca44c fix(transcript): stream live deltas for sessions on the legacy projection
A session opened from a legacy topic path follows its transcript through
the legacy projection (storageBackend "legacy"). There every turn event,
text and reasoning deltas included, takes the next ledger sequence, so
each follow change advanced commitSeq by one but carried no firstSeq.
The desktop follow client treats a change without firstSeq as a display
frame that must ride the current cut, rejected the very first change of
the turn as "transcript frame cut mismatch", dropped the subscription
and re-baselined once a second. No delta reached the reducer, so neither
thinking nor answer streamed and the reply appeared whole at turn end.

The stream_attempt commit on that path also asked for a reset, because
the legacy path never recorded a result sequence. Recording the message
event's sequence covers rounds with text or reasoning, but a tool-call-
only round and an interrupted round publish no message event at all.

Projection.Apply now publishes the sequence each envelope occupies as
the change's firstSeq. A commit settles on the recorded message sequence
or, when the round had no message event, on its own sequence: the agent
emits the commit only after the round's record is durably appended, so
that sequence is already covered. The entry is dropped at the commit so
the map stays bounded. ApplyFrame (the exclusive runtime path) is
unchanged.

Test: TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts
opens a legacy topic through StartTopicActivation on a real App with a
scripted streaming provider, follows through TranscriptFollowForTab and
applies the renderer's acceptance rules (mirroring
transcriptFollowClient.ts validate: coverage, sampling, resultKind,
records-based result identity with its 192 cap). Subtests run a
streamed reply, a tool-call-only round and a cancelled round, each
followed by a streamed reply.
- main-v2: all three fail with "frame_cut_mismatch at revision 3".
- message-sequence fix only: tool-call-only and cancelled rounds fail
  with "revision_gap" at the commit.
- this change: all pass, every delta delivered with no re-baseline.
Replaying captured follow responses through the real
TranscriptFollowClient accepted 0 deltas before and all of them after.

Fixes #10968
2026-09-26 21:32:08 -07:00

421 lines
14 KiB
Go

package transcript
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"sync"
"reasonix/internal/event"
"reasonix/internal/eventwire"
"reasonix/internal/turnevent"
)
const ProtocolVersion = 1
type Identity struct {
SessionID string `json:"sessionId"`
HeadID string `json:"headId"`
RewriteEpoch uint64 `json:"rewriteEpoch"`
RuntimeEpoch string `json:"runtimeEpoch"`
}
type ActiveAttempt struct {
ID string `json:"id"`
MessageID string `json:"messageId"`
TurnID string `json:"turnId"`
NextIndex uint64 `json:"nextIndex"`
}
// Runtime is reduced from the same ordered events as the visible records.
// In particular it is never sampled independently after a history read.
type Runtime struct {
FinalMessageID string `json:"finalMessageId,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
SamplingCount int `json:"samplingCount"`
ToolCount int `json:"toolCount"`
TurnID string `json:"turnId,omitempty"`
SubmissionID string `json:"submissionId,omitempty"`
Status event.TurnStatus `json:"status,omitempty"`
Phase string `json:"phase,omitempty"`
StartedAt int64 `json:"startedAt,omitempty"`
PendingEvents []eventwire.Event `json:"pendingEvents"`
CompletionSummary *eventwire.CompletionSummary `json:"completionSummary,omitempty"`
TurnUsage *TurnUsage `json:"turnUsage,omitempty"`
}
type Boundary struct {
ProtocolVersion int `json:"protocolVersion"`
SnapshotID string `json:"snapshotId"`
Identity Identity `json:"identity"`
ProjectionRevision uint64 `json:"projectionRevision"`
CoveredThroughSeq uint64 `json:"coveredThroughSeq"`
DurableSeq uint64 `json:"durableSeq"`
}
// Projection has one commit boundary for rows, runtime and coverage. All
// mutations happen after durable append and before publishing the event.
// It performs no callbacks or I/O under its mutex.
type Projection struct {
mu sync.Mutex
incarnation string
identity Identity
revision uint64
covered uint64
durable uint64
followers map[string]*follower
results map[string]uint64
buffer Buffer
runtime Runtime
startedTurnID string
attempts map[string]ActiveAttempt
toolCalls map[string]bool
prompts map[string]eventwire.Event
snapshots map[string]frozenSnapshot
snapshotOrder []string
snapshotBytes int
recordSerial uint64
// outline is the complete turn index of a frozen cut. It is built by
// freezeLocked and read only from frozen cuts, so it always describes the
// same revision as the records paged beside it.
outline []OutlineEntry
recordPositions map[string]int
}
// ensureRecordIdentity owns the last-resort identity for display-only rows.
// Canonical messages retain their existing m:/tool: identities; transient
// frames without a business sequence receive an identity scoped to this
// projection incarnation and keep it for every later snapshot.
func (p *Projection) ensureRecordIdentity(message *Message) {
if message.RecordID != "" {
return
}
switch {
case message.Role == "tool" && message.ToolCallID != "":
message.RecordID = "tool:" + message.ToolCallID
case message.MessageID != "":
message.RecordID = "m:" + message.MessageID
default:
p.recordSerial++
message.RecordID = fmt.Sprintf("view:%s:%d", p.incarnation, p.recordSerial)
}
}
func (p *Projection) ensureBufferRecordIdentities() {
for _, row := range p.buffer.messages {
p.ensureRecordIdentity(&row.message)
}
}
func NewProjection(identity Identity, baseline []Message, covered uint64) (*Projection, error) {
p := &Projection{incarnation: rand.Text(), identity: identity, covered: covered, revision: 1,
attempts: make(map[string]ActiveAttempt), prompts: make(map[string]eventwire.Event)}
// Take ownership of nested metadata as well as the slice. Callers may
// reuse their conversion buffers immediately after construction.
encoded, err := json.Marshal(baseline)
if err != nil {
return nil, newBaselineError(err, "baseline_encode_failed", len(baseline), -1, -1, Message{})
}
var owned []Message
if err = json.Unmarshal(encoded, &owned); err != nil {
return nil, newBaselineError(err, "baseline_decode_failed", len(baseline), -1, -1, Message{})
}
p.buffer.byMessageID = make(map[string]*bufferedMessage)
seen := make(map[string]int)
for index, m := range owned {
if m.Role == "user" {
p.buffer.userTurns++
if m.HistoryTurn == 0 {
m.HistoryTurn = p.buffer.userTurns
}
}
if m.RecordID == "" {
switch {
case m.Role == "tool" && m.ToolCallID != "":
m.RecordID = "tool:" + m.ToolCallID
case m.MessageID != "":
m.RecordID = "m:" + m.MessageID
default:
return nil, newBaselineError(errors.New("transcript baseline has a record without identity"), "missing_record_identity", len(owned), index, -1, m)
}
}
if previous, exists := seen[m.RecordID]; exists {
return nil, newBaselineError(fmt.Errorf("duplicate transcript record %q", m.RecordID), "duplicate_record_identity", len(owned), index, previous, m)
}
seen[m.RecordID] = index
row := &bufferedMessage{message: m}
if m.Role == "assistant" {
row.content.replace(m.Content)
row.reasoning.replace(m.Reasoning)
row.message.Content, row.message.Reasoning = "", ""
}
p.buffer.messages = append(p.buffer.messages, row)
if m.MessageID != "" && (m.Role == "assistant" || m.Role == "user") {
p.buffer.byMessageID[m.MessageID] = row
}
}
return p, nil
}
func (p *Projection) Apply(envelope turnevent.Envelope) error {
p.mu.Lock()
defer p.mu.Unlock()
if envelope.SessionID != p.identity.SessionID || (envelope.RuntimeEpoch != "" && envelope.RuntimeEpoch != p.identity.RuntimeEpoch) {
return errors.New("transcript event identity mismatch")
}
if envelope.Sequence <= p.covered {
return nil
}
if envelope.Sequence != p.covered+1 {
return errors.New("transcript projection sequence gap")
}
return p.applyLocked(envelope, envelope.Sequence, envelope.Sequence)
}
// ApplyFrame uses an independent display revision. The caller supplies the
// business cut; a token, phase or usage notification cannot allocate log sequence.
func (p *Projection) ApplyFrame(envelope turnevent.Envelope, covered uint64) error {
p.mu.Lock()
defer p.mu.Unlock()
if (envelope.SessionID != "" && envelope.SessionID != p.identity.SessionID) || (envelope.RuntimeEpoch != "" && envelope.RuntimeEpoch != p.identity.RuntimeEpoch) {
return errors.New("transcript event identity mismatch")
}
if covered < p.covered {
return errors.New("transcript business coverage regression")
}
if err := p.applyLocked(envelope, covered, 0); err != nil {
return err
}
p.trimSettledLocked()
return nil
}
// first is the business sequence the envelope itself occupies, or zero for a
// display frame that rides an existing cut.
func (p *Projection) applyLocked(envelope turnevent.Envelope, covered, first uint64) error {
// Detach pointer payloads before retaining them. Event publication cannot
// mutate a previously committed snapshot through an aliased tool slice.
encoded, err := json.Marshal(envelope)
if err != nil {
return err
}
var owned turnevent.Envelope
if err = json.Unmarshal(encoded, &owned); err != nil {
return err
}
if e, ok := EventFromEnvelope(owned); ok {
p.buffer.Apply(e)
p.ensureBufferRecordIdentities()
if e.Kind == event.TurnDone {
p.applyTerminalNotices(e)
}
if m := p.buffer.byMessageID[e.MessageID]; m != nil {
if m.message.CreatedAt == 0 {
m.message.CreatedAt = owned.CreatedAt
}
if owned.SubmissionID != "" {
m.message.SubmissionID = owned.SubmissionID
}
}
}
p.applyRuntimeLocked(owned)
w := owned.Event
p.covered = covered
p.revision++
// Legacy ledger numbering is never a chat coverage cursor.
w.Sequence = 0
if first != 0 && owned.Kind == "message" && w.MessageID != "" {
if p.results == nil {
p.results = make(map[string]uint64)
}
p.results[w.MessageID] = first
}
state := p.runtime
change := Change{Event: &w, Runtime: &state, FirstSeq: first}
if owned.Kind == "text" || owned.Kind == "reasoning" || owned.Kind == "tool_call_delta" || (owned.Kind == "tool_dispatch" && w.Tool != nil && w.Tool.Partial) {
if attempt, ok := p.attempts[w.AttemptID]; ok {
change.AttemptID, change.Index = attempt.ID, attempt.NextIndex
attempt.NextIndex++
p.attempts[attempt.ID] = attempt
}
}
if owned.Kind == "stream_attempt" && w.StreamAttempt != nil && w.StreamAttempt.Action == "commit" {
change.AttemptID, change.ResultSeq = w.StreamAttempt.ID, p.results[w.MessageID]
if first != 0 {
// A tool-only or interrupted round publishes no message event; its
// commit follows the durable write, so its own sequence is committed.
if change.ResultSeq == 0 {
change.ResultSeq = first
}
delete(p.results, w.MessageID)
}
change.ResultKind = "message/complete"
if w.StreamAttempt.Reason == "interrupted" {
change.ResultKind = "message/interrupted"
}
change.ResetRequired = change.ResultSeq == 0
}
p.publishChangeLocked(change)
return nil
}
func mergeTurnUsage(current *TurnUsage, usage *eventwire.Usage) *TurnUsage {
if usage == nil {
return current
}
if current == nil {
zero := 0
current = &TurnUsage{CacheReadTokens: &zero, ReasoningTokens: &zero}
}
current.UncachedInputTokens += usage.CacheMissTokens
if usage.CacheMissTokens == 0 && usage.CacheHitTokens == 0 {
current.UncachedInputTokens += usage.PromptTokens
}
current.OutputTokens += usage.CompletionTokens
requestTotal := usage.TotalTokens
if requestTotal <= 0 {
requestTotal = usage.PromptTokens + usage.CompletionTokens
}
current.TotalTokens += requestTotal
cacheRead := valueOrZero(current.CacheReadTokens) + usage.CacheHitTokens
current.CacheReadTokens = &cacheRead
reasoning := valueOrZero(current.ReasoningTokens) + usage.ReasoningTokens
current.ReasoningTokens = &reasoning
if usage.CostQuote != nil && usage.CostQuote.ModelRef != "" {
if !slices.Contains(current.Routes, usage.CostQuote.ModelRef) {
current.Routes = append(current.Routes, usage.CostQuote.ModelRef)
}
}
return current
}
func valueOrZero(value *int) int {
if value == nil {
return 0
}
return *value
}
// SetRuntimeEpoch is called only by the controller's idle routing boundary.
// Existing rows survive a runtime rebind; outstanding snapshot leases do not.
func (p *Projection) SetRuntimeEpoch(epoch string) {
p.mu.Lock()
defer p.mu.Unlock()
if p.identity.RuntimeEpoch != epoch {
p.identity.RuntimeEpoch = epoch
p.incarnation = rand.Text()
p.revision++
for _, f := range p.followers {
f.reset = true
select {
case f.wake <- struct{}{}:
default:
}
}
clear(p.followers)
}
}
func (p *Projection) boundaryLocked() Boundary {
return Boundary{ProtocolVersion: ProtocolVersion,
SnapshotID: fmt.Sprintf("%s:%d", p.incarnation, p.revision),
Identity: p.identity, ProjectionRevision: p.revision, CoveredThroughSeq: p.covered, DurableSeq: p.durable}
}
func (p *Projection) Boundary() Boundary {
p.mu.Lock()
defer p.mu.Unlock()
return p.boundaryLocked()
}
func (p *Projection) runtimeLocked() (Runtime, []ActiveAttempt) {
runtime := p.runtime
runtime.PendingEvents = make([]eventwire.Event, 0, len(p.prompts))
keys := make([]string, 0, len(p.prompts))
for key := range p.prompts {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
runtime.PendingEvents = append(runtime.PendingEvents, p.prompts[key])
}
attempts := make([]ActiveAttempt, 0, len(p.attempts))
for _, attempt := range p.attempts {
attempts = append(attempts, attempt)
}
sort.Slice(attempts, func(i, j int) bool { return attempts[i].ID < attempts[j].ID })
return runtime, attempts
}
func (p *Projection) applyRuntimeLocked(owned turnevent.Envelope) {
w := owned.Event
if owned.TurnID != "" {
p.runtime.TurnID, p.runtime.Status = owned.TurnID, owned.Status
p.runtime.SubmissionID = owned.SubmissionID
}
switch owned.Kind {
case "turn_started":
p.runtime.FinalMessageID, p.runtime.DurationMs = "", 0
p.runtime.SamplingCount, p.runtime.ToolCount = 0, 0
p.toolCalls = make(map[string]bool)
p.retireRecoveryNotices()
if p.startedTurnID != owned.TurnID || p.runtime.StartedAt == 0 {
p.runtime.StartedAt = owned.CreatedAt
p.startedTurnID = owned.TurnID
}
p.runtime.Phase = ""
p.runtime.CompletionSummary = nil
p.runtime.TurnUsage = nil
p.buffer.completion = nil
case "usage":
p.runtime.TurnUsage = mergeTurnUsage(p.runtime.TurnUsage, w.Usage)
case "turn_phase":
p.runtime.Phase = w.Phase
case "completion_summary":
p.runtime.CompletionSummary = w.Completion
case "stream_attempt":
if w.StreamAttempt != nil {
if w.StreamAttempt.Action == "begin" {
p.runtime.SamplingCount++
p.attempts[w.StreamAttempt.ID] = ActiveAttempt{ID: w.StreamAttempt.ID, MessageID: w.MessageID, TurnID: owned.TurnID}
} else {
delete(p.attempts, w.StreamAttempt.ID)
}
}
case "tool_dispatch":
if w.Tool != nil && w.Tool.ID != "" && !p.toolCalls[w.Tool.ID] {
if p.toolCalls == nil {
p.toolCalls = make(map[string]bool)
}
p.toolCalls[w.Tool.ID] = true
p.runtime.ToolCount++
}
case "ask_request", "approval_request", "mcp_interaction":
id := w.PromptID
if id == "" {
id = owned.ItemID
}
if id != "" {
p.prompts[id] = w
}
case "prompt_answered":
delete(p.prompts, owned.ItemID)
case "turn_done":
durationMs := int64(0)
if p.runtime.StartedAt > 0 && owned.CreatedAt >= p.runtime.StartedAt {
durationMs = owned.CreatedAt - p.runtime.StartedAt
}
p.runtime.DurationMs = durationMs
p.buffer.attachTurnStats(owned.TurnID, p.runtime.TurnUsage, durationMs, owned.CreatedAt, p.runtime.FinalMessageID)
clear(p.prompts)
clear(p.attempts)
if owned.TranscriptDigest != "" {
p.identity.HeadID = owned.HeadID
p.identity.RewriteEpoch = owned.RewriteEpoch
}
}
}