Merge pull request #7064 from HaoyueQin/fix/pasted-text-expand-state-loss-7051

fix(desktop): preserve pasted text and file ref inline cards across session switches / 修复桌面端切换会话后粘贴文本与文件引用内联卡片状态丢失
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-31 00:56:26 +08:00
commit e498c4a796
4 files changed
+235 -25

No files matched your search

+74 -16
View File
@@ -18,6 +18,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
goruntime "runtime"
"sort"
"strconv"
@@ -5656,6 +5657,72 @@ func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int {
)
}
var pastedTextDisplayLabelPattern = regexp.MustCompile(`^\[(?:已粘贴文本|已貼上文字|Pasted text) #[0-9]+ · [0-9]+ (?:行|lines)\]$`)
// historyReplayUserContent keeps only user-authored replay data. Provider-facing
// capability, goal, hook, and resolved-reference context must not be resubmitted.
func historyReplayUserContent(content string) string {
return control.StripReferencedContextPrefix(control.StripComposePrefixes(content))
}
// collapseLegacyExpandedPasteDisplay repairs sessions whose user-authored replay
// source still contains an expanded pasted-text block. This includes transcripts
// written before RawContent existed. The expanded block remains in SubmitText so
// edit replay can still reconstruct the card and recover its full payload.
func collapseLegacyExpandedPasteDisplay(content string) string {
const beginPrefix = "--- Begin "
for scan := 0; scan < len(content); {
beginOffset := strings.Index(content[scan:], beginPrefix)
if beginOffset < 0 {
break
}
begin := scan + beginOffset
labelStart := begin + len(beginPrefix)
labelEndOffset := strings.Index(content[labelStart:], " ---")
if labelEndOffset < 0 {
break
}
labelEnd := labelStart + labelEndOffset
label := content[labelStart:labelEnd]
beginEnd := labelEnd + len(" ---")
if !pastedTextDisplayLabelPattern.MatchString(label) {
scan = beginEnd
continue
}
endMarker := "--- End " + label + " ---"
endOffset := strings.Index(content[beginEnd:], endMarker)
if endOffset < 0 {
scan = beginEnd
continue
}
labelCopy := strings.LastIndex(content[:begin], label)
if labelCopy < 0 || strings.TrimSpace(content[labelCopy+len(label):begin]) != "" {
scan = beginEnd
continue
}
end := beginEnd + endOffset + len(endMarker)
content = content[:labelCopy+len(label)] + content[end:]
scan = labelCopy + len(label)
}
return strings.TrimSpace(content)
}
// historyUserDisplayContent prefers a persisted display sidecar when one exists.
// Comparing it with the deterministic fallback distinguishes a sidecar hit
// without changing the resolver API used throughout history pagination.
func historyUserDisplayContent(msg provider.Message, resolveUserContent func(string) string) string {
resolved := strings.TrimSpace(resolveUserContent(msg.Content))
fallback := strings.TrimSpace(historyReplayUserContent(msg.Content))
if resolved != "" && resolved != fallback {
return resolved
}
replaySource := agent.UserMessageText(msg)
if msg.RawContent == "" {
replaySource = fallback
}
return collapseLegacyExpandedPasteDisplay(replaySource)
}
func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int {
out := make([]int, 0)
for index, msg := range msgs {
@@ -5666,9 +5733,7 @@ func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(str
if _, isSteer := agent.SteerText(content); isSteer {
continue
}
if msg.RawContent == "" {
content = resolveUserContent(msg.Content)
}
content = historyUserDisplayContent(msg, resolveUserContent)
if control.IsSyntheticUserMessage(content) {
continue
}
@@ -5722,11 +5787,7 @@ func historyMessagesWithPlannerDisplaysAndLookups(
out = append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText})
continue
}
if m.RawContent != "" {
content = agent.UserMessageText(m)
} else {
content = resolveUserContent(m.Content)
}
content = historyUserDisplayContent(m, resolveUserContent)
if control.IsSyntheticUserMessage(content) {
continue
}
@@ -5748,17 +5809,16 @@ func historyMessagesWithPlannerDisplaysAndLookups(
hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
}
if m.Role == provider.RoleUser && content != m.Content {
replay := historyReplayUserContent(m.Content)
if agent.ContainsMemoryCompilerExecution(m.Content) {
// Never expose the compiler contract itself. A safely unwrapped
// slash invocation is useful display metadata, though: it lets the
// frontend restore the selected skill/subagent in history and trash.
if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
if strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
hm.SubmitText = replay
}
} else if m.RawContent != "" {
hm.SubmitText = content
} else {
hm.SubmitText = m.Content
} else if replay != content {
hm.SubmitText = replay
}
}
if (m.Role == provider.RoleAssistant || m.LocalOnly) && len(m.ToolCalls) > 0 {
@@ -5865,9 +5925,7 @@ func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string)
if _, isSteer := agent.SteerText(content); isSteer {
return false
}
if msg.RawContent == "" {
content = resolveUserContent(msg.Content)
}
content = historyUserDisplayContent(msg, resolveUserContent)
return !control.IsSyntheticUserMessage(content)
}
+118 -7
View File
@@ -82,21 +82,80 @@ func TestHistoryMessagesPreferPersistedRawUserContent(t *testing.T) {
const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\nfix the bug"
msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: raw}}
got := historyMessages(msgs, func(string) string {
t.Fatal("raw_content should make provider wrapper parsing unnecessary")
return ""
})
got := historyMessages(msgs, historyReplayUserContent)
if len(got) != 1 || got[0].Content != raw {
t.Fatalf("history user content = %+v, want raw %q", got, raw)
}
if got[0].SubmitText != raw {
t.Fatalf("history submit text = %q, want raw %q", got[0].SubmitText, raw)
if got[0].SubmitText != "" {
t.Fatalf("provider-only wrapper should not become replay text, got %q", got[0].SubmitText)
}
if strings.Contains(got[0].Content, "capability-route") {
if strings.Contains(got[0].Content, "capability-route") || strings.Contains(got[0].SubmitText, "capability-route") {
t.Fatalf("provider-only wrapper leaked into history: %+v", got[0])
}
}
func TestHistoryMessagesRecoverLegacyExpandedPasteWithoutSidecar(t *testing.T) {
const label = "[已粘贴文本 #1 · 3 行]"
const display = "review this\n\n" + label
const expanded = display + "\n\n--- Begin " + label + " ---\nfirst\nsecond\nthird\n--- End " + label + " ---"
const rendered = "<active-goal>\nship the release\n</active-goal>\n\n" + expanded
msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: expanded}}
got := historyMessages(msgs, historyReplayUserContent)
if len(got) != 1 || got[0].Content != display {
t.Fatalf("legacy pasted display = %+v, want %q", got, display)
}
if got[0].SubmitText != expanded {
t.Fatalf("legacy pasted replay = %q, want expanded user input", got[0].SubmitText)
}
if strings.Contains(got[0].SubmitText, "<active-goal>") {
t.Fatalf("transient goal leaked into legacy replay: %+v", got[0])
}
}
func TestHistoryMessagesExpandedRawSupportsSidecarAndPreviousClients(t *testing.T) {
const label = "[Pasted text #1 · 2 lines]"
const display = "inspect\n\n" + label
const expanded = display + "\n\n--- Begin " + label + " ---\none\ntwo\n--- End " + label + " ---"
const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\n" + expanded
msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: expanded}}
// Previous desktop releases use RawContent as their replay source. Keeping
// the expanded markers there lets them reconstruct the same inline card
// instead of rendering an opaque label with no accessible payload.
previousReplay := agent.UserMessageText(msgs[0])
if !strings.Contains(previousReplay, "--- Begin "+label+" ---") || !strings.Contains(previousReplay, "--- End "+label+" ---") {
t.Fatalf("previous-client replay lost pasted payload markers: %q", previousReplay)
}
got := historyMessages(msgs, func(content string) string {
if content != rendered {
t.Fatalf("sidecar resolver content = %q, want rendered content", content)
}
return display
})
if len(got) != 1 || got[0].Content != display || got[0].SubmitText != expanded {
t.Fatalf("legacy sidecar history = %+v, want display %q and expanded replay", got, display)
}
if strings.Contains(got[0].SubmitText, "capability-route") {
t.Fatalf("provider-only wrapper leaked into sidecar replay: %+v", got[0])
}
}
func TestHistoryMessagesLegacyReferenceReplayExcludesResolvedContext(t *testing.T) {
const raw = "@src/main.go explain the entrypoint"
const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\nReferenced context:\n\n<file path=\"src/main.go\">\npackage main\n</file>\n\n" + raw
msgs := []provider.Message{{Role: provider.RoleUser, Content: rendered, RawContent: raw}}
got := historyMessages(msgs, historyReplayUserContent)
if len(got) != 1 || got[0].Content != raw || got[0].SubmitText != "" {
t.Fatalf("legacy reference history = %+v, want compact raw replay", got)
}
if strings.Contains(got[0].Content, "<file") || strings.Contains(got[0].SubmitText, "<file") {
t.Fatalf("resolved reference leaked into editable history: %+v", got[0])
}
}
func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
raw := historyMemoryCompilerContract(t, "ship the refactor")
msgs := []provider.Message{
@@ -823,6 +882,58 @@ func TestPreviewSessionMessagesLoadsWithoutResuming(t *testing.T) {
}
}
func TestPreviewSessionMessagesUpgradesLegacyExpandedPaste(t *testing.T) {
const label = "[Pasted text #1 · 2 lines]"
const display = "inspect this\n\n" + label
const expanded = display + "\n\n--- Begin " + label + " ---\none\ntwo\n--- End " + label + " ---"
const rendered = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\n" + expanded
dir := t.TempDir()
path := filepath.Join(dir, "legacy.jsonl")
session := agent.NewSession("")
session.Add(provider.Message{Role: provider.RoleUser, Content: rendered, RawContent: expanded})
if err := session.Save(path); err != nil {
t.Fatalf("Save legacy session: %v", err)
}
if err := recordSessionDisplay(dir, path, rendered, display); err != nil {
t.Fatalf("record legacy display: %v", err)
}
got, err := previewSessionMessages(dir, path)
if err != nil {
t.Fatalf("previewSessionMessages: %v", err)
}
if len(got) != 1 || got[0].Content != display || got[0].SubmitText != expanded {
t.Fatalf("upgraded legacy preview = %+v, want display %q and expanded replay", got, display)
}
if strings.Contains(got[0].SubmitText, "capability-route") {
t.Fatalf("provider-only wrapper leaked after session restart: %+v", got[0])
}
}
func TestPreviewSessionMessagesUpgradesContentOnlyExpandedPaste(t *testing.T) {
const label = "[Pasted text #1 · 2 lines]"
const display = "inspect this\n\n" + label
const expanded = display + "\n\n--- Begin " + label + " ---\none\ntwo\n--- End " + label + " ---"
dir := t.TempDir()
path := filepath.Join(dir, "content-only.jsonl")
session := agent.NewSession("")
// Releases before Context Engine v2 persisted user turns without RawContent.
session.Add(provider.Message{Role: provider.RoleUser, Content: expanded})
if err := session.Save(path); err != nil {
t.Fatalf("Save content-only session: %v", err)
}
got, err := previewSessionMessages(dir, path)
if err != nil {
t.Fatalf("previewSessionMessages: %v", err)
}
if len(got) != 1 || got[0].Content != display || got[0].SubmitText != expanded {
t.Fatalf("upgraded content-only preview = %+v, want display %q and expanded replay", got, display)
}
}
func TestPreviewSessionMessagesIncludesProcessEvents(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "events.jsonl")
+1 -2
View File
@@ -15,7 +15,6 @@ import (
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/filelock"
"reasonix/internal/fileutil"
"reasonix/internal/store"
@@ -1269,7 +1268,7 @@ func sessionDisplayResolverFromMap(displays sessionDisplayMap, sessionPath strin
return display
}
}
return control.StripComposePrefixes(content)
return historyReplayUserContent(content)
}
}
@@ -16,6 +16,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/hook"
"reasonix/internal/provider"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
@@ -272,6 +273,7 @@ func TestTurnOrchestratorStopHookIgnoresCanceledTurnContext(t *testing.T) {
type recordingSessionRunner struct {
session *agent.Session
inputs []string
raw []string
}
type deliveryScopeErrorRunner struct {
@@ -369,6 +371,7 @@ func TestRecoveryPauseKeepsGoalRunningAndDeliveryScope(t *testing.T) {
func (r *recordingSessionRunner) Run(ctx context.Context, input string) error {
r.inputs = append(r.inputs, input)
r.raw = append(r.raw, agent.RawUserInput(ctx, input))
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
return nil
}
@@ -512,6 +515,45 @@ func TestTurnOrchestratorRefTurnRecordsVisibleDisplay(t *testing.T) {
}
}
func TestTurnOrchestratorRefTurnPreservesExpandedPasteForRouting(t *testing.T) {
const label = "[Pasted text #1 · 2 lines]"
const display = "inspect\n\n" + label
const expanded = display + "\n\n--- Begin " + label + " ---\nroute-expanded-paste\nfunc main() {}\n--- End " + label + " ---"
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
runner := &recordingSessionRunner{session: sess}
reg := tool.NewRegistry()
reg.Add(capabilityTestTool{name: "run_skill"})
c := New(Options{
Runner: runner,
Executor: exec,
Registry: reg,
Skills: []skill.Skill{{
Name: "paste-review",
Description: "review code",
Triggers: []string{"route-expanded-paste"},
Scope: skill.ScopeBuiltin,
}},
})
resolve := func(context.Context, string) (string, []string) {
return "<file path=\"notes.txt\">\nreference\n</file>", nil
}
if err := c.runRefTurnWithResolverSync(context.Background(), expanded, expanded, display, "", resolve); err != nil {
t.Fatal(err)
}
if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "Referenced context:") || !strings.Contains(runner.inputs[0], expanded) {
t.Fatalf("provider input = %+v, want resolved context and expanded paste", runner.inputs)
}
if !strings.Contains(runner.inputs[0], "skill:paste-review prefer") {
t.Fatalf("expanded pasted text did not drive capability routing:\n%s", runner.inputs[0])
}
if len(runner.raw) != 1 || runner.raw[0] != expanded {
t.Fatalf("persisted raw input = %+v, want complete user input %q", runner.raw, expanded)
}
}
func TestTurnOrchestratorAutoReasoningLanguageUsesRawPromptForRefTurns(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "auth.go"), []byte("package main\nfunc AuthHandler() error { return errors.New(\"not authorized\") }\n"), 0o644); err != nil {