mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
4 files changed
+235
-25
No files matched your search
+74
-16
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user