Files
DeepSeek-Reasonix/internal/transcript/content_fields_test.go
T
SivanCola 27a45a260f fix(transcript): unify history snapshot and live event projection / 统一历史快照与实时事件的投影 (#9983)
* fix(transcript): unify history snapshot and live event projection

Problem:
Reasonix desktop 1.38.2 renders duplicated session content. When the
persisted history snapshot runs ahead of the live event stream, transcript
hydration appends already-rendered messages again ([A,B,C] + [A,B] ->
[A,B,C,A,B]), and events replayed after hydration can be dropped because
queued events are re-enqueued after the replay cursor already advanced.

Root cause:
History hydration dedupe only matched exact equality between the tail of
history and the head of the live stream. Message identity was not carried
through sampling events, the event merger, or persistence, so local,
remote, restore, cancel, and paging paths each maintained their own
projection logic with mismatched boundaries.

Fix:
- Thread message identity (Message.ID) through sampling events, the event
  merger, and persistence; identity stays out of model requests.
- Merge history and live streams by message ID through one projection
  shared by local Wails sessions and remote Serve sessions, with atomic
  snapshot boundaries (snapshot plus covered event sequence committed
  together) and receipt dedupe separated from commit.
- Controller now persists first, projects second, publishes events last.
- New shared Go package internal/transcript owns display record types and
  streaming coalescing; the snapshot API supports paging and 64KiB content
  chunking.
- Restore path installs a display checkpoint verified against the session
  summary; legacy Serve surfaces get a degradation notice.
- Extract shared modules (remoteStatus, historyItems, historyTypes,
  transcriptContentResolver, event extension surface, ledger metrics) and
  split oversized functions to satisfy repolint without baseline changes.

Verification:
- go run ./tools/repolint: clean, no baseline update.
- go test ./internal/{control,serve,event,turnevent,transcript,agent,store}/...: ok.
- go test -race ./internal/{control,turnevent,event,transcript}/...: ok.
- cd desktop && go test .: ok.
- frontend: tsc --noEmit, pnpm test:transcript (incl. transcript-store 68
  passed), transcript-snapshot-client, new-session-load-race (51 passed),
  turn-event-projection-reset: all pass.

* chore(frontend): ratchet locale/raw bundle budgets for transcript projection

The legacy-transcript degradation notice adds one short zh-TW string,
pushing the zh-TW locale chunk to 65551 B gzip (64.015 KiB) over the
64.0 KiB ceiling; zh stays at 64793 B under its existing 63.3 KiB
ceiling. Ratchet only the zh-TW ceiling one decimal to 64.1 KiB.

The unified transcript projection (snapshot client/state, content
resolver, extracted history-items and remote-status owners) adds a
measured 24.5 KiB raw on the initial path (2458.7 KiB total) while gzip
stays at 459.1 KiB inside the existing 469.3 KiB budget. Ratchet the
raw ceiling to 2458.8 KiB with 0.1 KiB of build headroom.

Verification: node scripts/check-bundle-budget.mjs passes all budgets
after a production build.

* style(transcript): satisfy golangci modernize and staticcheck receivers

CI lint flagged the new internal/transcript package: integer-range loops,
a copyloopvar in the nested-ref test, WaitGroup.Go modernization, and
inconsistent Buffer receiver names. Apply the mechanical modernizations
and unify Buffer methods on one receiver name; no behavior change.

Also point the clear-session identity contract test at historyTypes.ts,
where SessionClearResult moved during the shared-module extraction (the
types.ts re-export keeps the import surface unchanged).

Verification: golangci-lint 2.12.2 run ./... reports 0 issues in both the
root and desktop modules; go test ./internal/transcript/ ok;
clear-session-identity-contract test passes.

* fix(transcript): deduplicate active snapshot records

Problem: active records could be returned both in the page and activeRecords, causing the frontend to reject the snapshot; long-history snapshots also scanned settled turns under the projection mutex.\n\nFix: suppress duplicate active records, add frontend defensive deduplication, and bound active-owner discovery to the current turn when identity is available.\n\nVerification: go test ./internal/transcript; frontend tsc; transcript snapshot client and projection reset tests.

* fix(transcript): preserve recovery actions and bounded replay

Problem: installing an authoritative snapshot could hide recovery actions, strand long user messages at their preview, or stall remote replay on a legal oversized ledger page.

Root cause: terminal display metadata was omitted, content patches confused message identity with mounted keys, and replay used a different wire budget from its reader. The row renderer also omitted the recovery callback.

Fix: retain terminal recovery and read receipts, bind recovery actions through the production row renderer, match snapshot content by stable message identity while preserving mounted keys, and recover oversized replay through bounded snapshot content. Copy checkpoint metadata without serializing immutable bodies under the projection lock.

Verification: regression cases failed before these repairs and pass afterward. Related root Go packages, the full desktop module, targeted race suites, frontend transcript/remote/stream suites, typechecks, production build, repolint and go vet pass. Browser checks confirm full content and recovery command delivery; the native Electron smoke passes. A 32 MiB checkpoint capture benchmark improves from about 130 ms and 128 MB allocated to 11 ms and 14 MB allocated.

* chore(transcript): retain main-v2 bundle budgets after integration

Problem: the earlier transcript branch carried bundle ceiling increases measured before the Electron migration.

Root cause: those measurements no longer describe the integrated bundle.

Fix: restore the unchanged main-v2 ceilings and remove the obsolete measurement notes.

Verification: the integrated production bundle passes the original limits at 2396 KiB initial raw assets and 57 KiB for the Traditional Chinese locale.

* fix(transcript): satisfy current Go iteration lint rules

Problem: the integrated branch retained an integer loop flagged by intrange and introduced a backwards slice loop flagged by modernize.

Fix: use the equivalent integer range and slices.Backward forms without changing iteration order or recovery selection.

Verification: golangci-lint passes for affected root packages and the desktop module; focused control and transcript tests pass.

* docs(desktop): regenerate transcript host inventory

Problem: full root tests detected that the migration inventory still described 580 commands after the transcript APIs raised the host contract to 592.

Fix: regenerate both inventory representations from the integrated desktop sources, preserving the existing classification policy.

Verification: go test ./tools/desktopinventory passes, including the source-archive check without Git.

* fix(transcript): keep recovery tokens out of displayed prompts

Problem: connecting the recovery button through a plain prompt would show its internal command and token as the optimistic user message.

Fix: preserve separate display and submission text through local and remote transcript prompt ports. The visible action uses existing localized copy while the controller receives the unchanged recovery token.

Verification: production row-renderer assertions, local submission lifecycle, remote composer/reconciliation tests, frontend typechecks and production build pass. A real-browser button click reports localized display text and the correct token-bearing submission, with no console errors.

* test(desktop): share browser asset loader in discovered suites

Problem: the full discovered frontend run failed only at topicbar-region because its transitive SVG import reached Node without an asset loader.

Root cause: dedicated suites used the existing SVG/image/CSS loader while the discovery runner registered only the CSS loader.

Fix: use the same existing asset loader for all discovered component suites; no test is excluded and no product rendering changes.

Verification: the previously failing topicbar suite passes through that loader; the final full discovery run remains enabled.

* test(desktop): advertise only callable host fixture methods

Problem: the remote runtime browser replay timed out after generated transcript methods were added to a fallback table with undefined implementations.

Root cause: the test preload advertised every object key as a callable method, falsely enabling snapshot negotiation and preventing history hydration.

Fix: derive the stub contract from callable table entries, preserving its live update behavior. Add a regression for adding and removing optional snapshot support.

Verification: the new stub test, session-load race tests, frontend typecheck and the complete app-browser chain pass. The original runtime-state browser reproduction now restores durable history after lost completion and switches back to the local session without errors.
2026-09-11 16:13:17 +08:00

71 lines
2.4 KiB
Go

package transcript
import (
"encoding/json"
"strings"
"testing"
"reasonix/internal/provider"
)
func TestSnapshotLargeSessionAndNestedContent(t *testing.T) {
body := strings.Repeat("正文", 12<<20) // 72 MiB: larger than the unpinned cut budget.
baseline := []Message{{RecordID: "m:u", MessageID: "u", Role: "user", Content: "question"},
{RecordID: "m:a", MessageID: "a", Role: "assistant", Content: "answer", ToolCalls: []ToolCall{{ID: "call", Name: "write_file", Arguments: body}},
MemoryCitations: []provider.MemoryCitation{{ID: "citation", Note: strings.Repeat("note", 20000)}}}}
projection, err := NewProjection(Identity{SessionID: "s", RuntimeEpoch: "epoch"}, baseline, 0)
if err != nil {
t.Fatal(err)
}
snapshot, err := projection.Snapshot(PageRequest{})
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(snapshot)
if err != nil || len(encoded) > 32<<10 {
t.Fatalf("bounded response bytes=%d error=%v", len(encoded), err)
}
var arguments *ContentRef
for _, ref := range snapshot.Records[1].Refs {
if strings.Join(ref.Path, "/") == "toolCalls/0/arguments" {
arguments = &ref
}
chunk, err := projection.Content(ContentRequest{ContentRef: ref})
if err != nil || len(chunk.Data) > contentChunkBytes || chunk.NextOffset != len(chunk.Data) {
t.Fatalf("nested ref %v: %+v %v", ref.Path, chunk, err)
}
}
if arguments == nil || arguments.Bytes != len(body) {
t.Fatalf("missing exact argument ref: %+v", snapshot.Records[1].Refs)
}
end := len(body) - 6
chunk, err := projection.Content(ContentRequest{ContentRef: *arguments, Offset: end})
if err != nil || !chunk.Done || chunk.Data != body[end:] || chunk.NextOffset != len(body) {
t.Fatalf("tail chunk %+v %v", chunk, err)
}
if len(projection.snapshots) != 1 {
t.Fatalf("large current cut must remain available: %d", len(projection.snapshots))
}
}
func BenchmarkSnapshotLargeContentChunk(b *testing.B) {
body := strings.Repeat("x", 8<<20)
projection, err := NewProjection(Identity{SessionID: "s"}, []Message{{RecordID: "m:a", Role: "assistant", MessageID: "a", Content: body}}, 0)
if err != nil {
b.Fatal(err)
}
snapshot, err := projection.Snapshot(PageRequest{})
if err != nil {
b.Fatal(err)
}
request := ContentRequest{ContentRef: snapshot.Records[0].Refs[0], Offset: 1234}
b.ReportAllocs()
b.ResetTimer()
for range b.N {
chunk, err := projection.Content(request)
if err != nil || len(chunk.Data) != contentChunkBytes {
b.Fatal(err)
}
}
}