Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 8 additions & 34 deletions services/local-service/internal/orchestrator/memory_handoffs.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ func (s *Service) attachMemoryReadPlans(taskID, runID string, snapshot taskconte
}

// previewMemoryContext performs the same storage-backed retrieval as execution
// planning, but it only returns the matched summaries so clarification bubbles
// can acknowledge recent context without changing task ownership decisions.
// planning, but it only returns retrieval hits for downstream reuse in the
// clarification and confirmation flow without changing task ownership decisions.
func (s *Service) previewMemoryContext(taskID, runID string, snapshot taskcontext.TaskContextSnapshot) []memory.RetrievalHit {
if s == nil || s.memory == nil {
return nil
Expand All @@ -221,8 +221,8 @@ func (s *Service) previewMemoryContext(taskID, runID string, snapshot taskcontex
}

// clarificationPreviewHits prefers the retrieval_context already materialized on
// the task so clarification bubbles and later execution reuse the same memory
// evidence even after storage round-trips or follow-up confirmation RPCs.
// the task so clarification, confirmation, and later execution reuse the same
// memory evidence even after storage round-trips or follow-up confirmation RPCs.
func (s *Service) clarificationPreviewHits(task runengine.TaskRecord, snapshot taskcontext.TaskContextSnapshot) []memory.RetrievalHit {
if s == nil {
return nil
Expand Down Expand Up @@ -307,23 +307,8 @@ func readPlanRetrievalContextItems(plan map[string]any) []map[string]any {
}
}

func clarificationBubbleTextForLanguage(suggestionIntent map[string]any, hits []memory.RetrievalHit, replyLanguage string) string {
base := clarificationBaseTextForLanguage(suggestionIntent, replyLanguage)
if len(hits) == 0 {
return base
}

summary := strings.TrimSpace(hits[0].Summary)
if summary == "" {
return base
}

trimmedSummary := truncateText(summary, 72)
if replyLanguage == languagepolicy.ReplyLanguageEnglish {
return fmt.Sprintf("Based on your earlier context (%s), %s", trimmedSummary, clarificationFollowUpPrompt(suggestionIntent, true))
}

return fmt.Sprintf("结合你之前提到的内容(%s),%s", trimmedSummary, clarificationFollowUpPrompt(suggestionIntent, false))
func clarificationBubbleTextForLanguage(suggestionIntent map[string]any, _ []memory.RetrievalHit, replyLanguage string) string {
return clarificationBaseTextForLanguage(suggestionIntent, replyLanguage)
}

func clarificationBaseTextForLanguage(suggestionIntent map[string]any, replyLanguage string) string {
Expand All @@ -335,7 +320,8 @@ func clarificationBaseTextForLanguage(suggestionIntent map[string]any, replyLang

func initialClarificationPromptForLanguage(snapshot taskcontext.TaskContextSnapshot, startFlow bool, replyLanguage string) string {
if strings.TrimSpace(replyLanguage) == "" {
replyLanguage = clarificationReplyLanguage(snapshot)
preferredInput := clarificationLanguageEvidence(snapshot)
replyLanguage = languagepolicy.PreferredReplyLanguage(preferredInput)
}
if replyLanguage == languagepolicy.ReplyLanguageEnglish {
if startFlow {
Expand All @@ -349,18 +335,6 @@ func initialClarificationPromptForLanguage(snapshot taskcontext.TaskContextSnaps
return "我还不确定你想如何处理这段内容,请确认目标。"
}

func clarificationReplyLanguage(snapshot taskcontext.TaskContextSnapshot) string {
preferredInput := firstNonEmptyString(snapshot.Text, snapshot.ErrorText)
if preferredInput == "" {
preferredInput = firstNonEmptyString(snapshot.SelectionText, memoryQueryFromSnapshot(snapshot))
}
currentLanguage := languagepolicy.PreferredReplyLanguage(preferredInput)
if shouldPreferRememberedSessionLanguage(snapshot, currentLanguage) {
return strings.TrimSpace(snapshot.SessionReplyLanguage)
}
return currentLanguage
}

func clarificationFollowUpPrompt(taskIntent map[string]any, english bool) string {
switch stringValue(taskIntent, "name", "") {
case "translate":
Expand Down
24 changes: 9 additions & 15 deletions services/local-service/internal/orchestrator/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11302,7 +11302,7 @@ func TestServiceConfirmTaskReusesRetrievalHitsAfterConfirmation(t *testing.T) {
}
}

func TestServiceSubmitInputClarificationIncludesRetrievedMemoryContext(t *testing.T) {
func TestServiceSubmitInputClarificationDoesNotExposeRetrievedMemoryContext(t *testing.T) {
service, _ := newTestServiceWithModelClient(t, stubModelClient{
output: `{"route":"clarification_needed","reply":""}`,
})
Expand Down Expand Up @@ -11332,11 +11332,11 @@ func TestServiceSubmitInputClarificationIncludesRetrievedMemoryContext(t *testin

bubble := result["bubble_message"].(map[string]any)
text := stringValue(bubble, "text", "")
if !strings.Contains(text, "Based on your earlier context") {
t.Fatalf("expected clarification bubble to mention prior memory context, got %+v", bubble)
if strings.Contains(text, "Based on your earlier context") {
t.Fatalf("expected clarification bubble to hide prior memory framing, got %+v", bubble)
}
if !strings.Contains(text, "project alpha focuses on rollout notes") {
t.Fatalf("expected clarification bubble to include retrieved summary, got %+v", bubble)
if strings.Contains(text, "project alpha focuses on rollout notes") {
t.Fatalf("expected clarification bubble to hide retrieved summary, got %+v", bubble)
}
}

Expand Down Expand Up @@ -11375,11 +11375,8 @@ func TestServiceClarificationReusesMaterializedMemoryHits(t *testing.T) {

startBubble := result["bubble_message"].(map[string]any)
startText := stringValue(startBubble, "text", "")
if !strings.Contains(startText, "first retrieved summary should stay stable") {
t.Fatalf("expected start clarification bubble to reuse stored retrieval hit, got %+v", startBubble)
}
if strings.Contains(startText, "second search result should not replace clarification context") {
t.Fatalf("expected start clarification bubble to avoid fresh search drift, got %+v", startBubble)
if strings.Contains(startText, "first retrieved summary should stay stable") || strings.Contains(startText, "second search result should not replace clarification context") {
t.Fatalf("expected start clarification bubble to hide retrieved summaries, got %+v", startBubble)
}

taskID := result["task"].(map[string]any)["task_id"].(string)
Expand All @@ -11393,11 +11390,8 @@ func TestServiceClarificationReusesMaterializedMemoryHits(t *testing.T) {

confirmBubble := confirmResult["bubble_message"].(map[string]any)
confirmText := stringValue(confirmBubble, "text", "")
if !strings.Contains(confirmText, "first retrieved summary should stay stable") {
t.Fatalf("expected confirm clarification bubble to reuse stored retrieval hit, got %+v", confirmBubble)
}
if strings.Contains(confirmText, "second search result should not replace clarification context") {
t.Fatalf("expected confirm clarification bubble to avoid fresh search drift, got %+v", confirmBubble)
if strings.Contains(confirmText, "first retrieved summary should stay stable") || strings.Contains(confirmText, "second search result should not replace clarification context") {
t.Fatalf("expected confirm clarification bubble to hide retrieved summaries, got %+v", confirmBubble)
}
if memoryStore.SearchCalls() != 1 {
t.Fatalf("expected clarification flow to reuse materialized hits without re-searching, calls=%d", memoryStore.SearchCalls())
Expand Down
12 changes: 6 additions & 6 deletions services/local-service/internal/orchestrator/task_confirm.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) {
return nil, err
}
snapshot := snapshotFromTask(task)
replyLanguage := taskConfirmReplyLanguage(snapshot, correctionText)
replyLanguage := s.taskConfirmReplyLanguage(snapshot, correctionText)
intentValue := cloneMap(task.Intent)
updatedTitle := task.Title
if !confirmed && correctionText != "" {
Expand All @@ -45,7 +45,7 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) {
return nil, err
}
snapshot := snapshotFromTask(updatedTask)
replyLanguage := taskConfirmReplyLanguage(snapshot, correctionText)
replyLanguage := s.taskConfirmReplyLanguage(snapshot, correctionText)
clarificationText := rejectedIntentClarificationText(replyLanguage)
if clarificationHits := s.clarificationPreviewHits(updatedTask, snapshot); len(clarificationHits) > 0 {
clarificationText = clarificationText + " " + clarificationBubbleTextForLanguage(map[string]any{}, clarificationHits, replyLanguage)
Expand All @@ -68,7 +68,7 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) {
return nil, err
}
snapshot := snapshotFromTask(updatedTask)
replyLanguage := taskConfirmReplyLanguage(snapshot, correctionText)
replyLanguage := s.taskConfirmReplyLanguage(snapshot, correctionText)
clarificationText := rejectedIntentClarificationText(replyLanguage)
if clarificationHits := s.clarificationPreviewHits(updatedTask, snapshot); len(clarificationHits) > 0 {
clarificationText = clarificationText + " " + clarificationBubbleTextForLanguage(map[string]any{}, clarificationHits, replyLanguage)
Expand All @@ -87,7 +87,7 @@ func (s *Service) ConfirmTask(params map[string]any) (map[string]any, error) {
}
if strings.TrimSpace(stringValue(intentValue, "name", "")) == "" {
snapshot := snapshotFromTask(task)
replyLanguage := taskConfirmReplyLanguage(snapshot, correctionText)
replyLanguage := s.taskConfirmReplyLanguage(snapshot, correctionText)
clarificationText := missingIntentClarificationText(replyLanguage)
if clarificationHits := s.clarificationPreviewHits(task, snapshot); len(clarificationHits) > 0 {
clarificationText = clarificationText + " " + clarificationBubbleTextForLanguage(map[string]any{}, clarificationHits, replyLanguage)
Expand Down Expand Up @@ -157,11 +157,11 @@ func confirmationAcceptedText(replyLanguage string) string {
return "已按新的要求开始处理"
}

func taskConfirmReplyLanguage(snapshot taskcontext.TaskContextSnapshot, correctionText string) string {
func (s *Service) taskConfirmReplyLanguage(snapshot taskcontext.TaskContextSnapshot, correctionText string) string {
if trimmed := strings.TrimSpace(correctionText); trimmed != "" {
return languagepolicy.PreferredReplyLanguage(trimmed)
}
return clarificationReplyLanguage(snapshot)
return s.confirmationReplyLanguage(snapshot)
}

func isEnglishReplyLanguage(replyLanguage string) bool {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package orchestrator

import (
"strings"
"testing"

"github.com/cialloclaw/cialloclaw/services/local-service/internal/intent"
"github.com/cialloclaw/cialloclaw/services/local-service/internal/languagepolicy"
"github.com/cialloclaw/cialloclaw/services/local-service/internal/runengine"
"github.com/cialloclaw/cialloclaw/services/local-service/internal/taskcontext"
)

func TestBubbleTextForConfirmationUsesSettingsDefaultLanguage(t *testing.T) {
service, _ := newTestServiceWithModelClient(t, stubModelClient{
output: `{"intent":{"name":"summarize","arguments":{"style":"brief"}}}`,
})
if _, _, _, _, err := service.runEngine.UpdateSettings(map[string]any{
"general": map[string]any{"language": languagepolicy.ReplyLanguageEnglish},
}); err != nil {
t.Fatalf("update settings failed: %v", err)
}

text := service.bubbleTextForConfirmation(
runengine.TaskRecord{},
taskcontext.TaskContextSnapshot{},
intent.Suggestion{RequiresConfirm: true},
true,
)
if !strings.Contains(text, "Please confirm the goal first.") {
t.Fatalf("expected english clarification bubble from settings default, got %q", text)
}
}

func TestBubbleTextForConfirmationUsesChineseDefaultLanguageWithoutInputSignal(t *testing.T) {
service, _ := newTestServiceWithModelClient(t, stubModelClient{
output: `{"intent":{"name":"summarize","arguments":{"style":"brief"}}}`,
})

text := service.bubbleTextForConfirmation(
runengine.TaskRecord{},
taskcontext.TaskContextSnapshot{},
intent.Suggestion{RequiresConfirm: true},
true,
)
if !strings.Contains(text, "请先确认") {
t.Fatalf("expected chinese clarification bubble from default settings fallback, got %q", text)
}
}

func TestBubbleTextForConfirmationPrefersRememberedSessionLanguage(t *testing.T) {
service, _ := newTestServiceWithModelClient(t, stubModelClient{
output: `{"intent":{"name":"summarize","arguments":{"style":"brief"}}}`,
})
if _, _, _, _, err := service.runEngine.UpdateSettings(map[string]any{
"general": map[string]any{"language": languagepolicy.ReplyLanguageEnglish},
}); err != nil {
t.Fatalf("update settings failed: %v", err)
}

text := service.bubbleTextForConfirmation(
runengine.TaskRecord{},
taskcontext.TaskContextSnapshot{
InputType: "text",
Text: "open it",
SessionReplyLanguage: languagepolicy.ReplyLanguageChinese,
SessionContextText: "请继续上一段中文任务上下文",
},
intent.Suggestion{RequiresConfirm: true},
true,
)
if !strings.Contains(text, "请先确认") {
t.Fatalf("expected remembered chinese session language to override default english, got %q", text)
}
}

func TestBubbleTextForConfirmationUsesEnglishPageContextWithoutTextSignal(t *testing.T) {
service, _ := newTestServiceWithModelClient(t, stubModelClient{
output: `{"intent":{"name":"summarize","arguments":{"style":"brief"}}}`,
})

text := service.bubbleTextForConfirmation(
runengine.TaskRecord{},
taskcontext.TaskContextSnapshot{
PageTitle: "Release notes dashboard",
},
intent.Suggestion{RequiresConfirm: true},
true,
)
if !strings.Contains(text, "Please confirm the goal first.") {
t.Fatalf("expected english clarification bubble from page context, got %q", text)
}
}

func TestFallbackConfirmIntentTextUsesEnglishAction(t *testing.T) {
text := fallbackConfirmIntentText(
taskcontext.TaskContextSnapshot{},
intent.Suggestion{
Intent: map[string]any{
"name": "summarize",
},
},
languagepolicy.ReplyLanguageEnglish,
)
if strings.Contains(text, "总结") || strings.Contains(text, "处理") {
t.Fatalf("expected english fallback confirmation action, got %q", text)
}
if text != "Do you want me to summarize?" {
t.Fatalf("expected english fallback confirmation question, got %q", text)
}
}

func TestBuildTaskConfirmQuestionPromptUsesReplyLanguage(t *testing.T) {
snapshot := taskcontext.TaskContextSnapshot{Text: "openclaw docs"}
suggestion := intent.Suggestion{
TaskTitle: "Open docs",
TaskSourceType: "floating_ball",
DirectDeliveryType: "bubble",
Intent: map[string]any{
"name": "open_page",
"arguments": map[string]any{
"url": "https://openclaw.example",
},
},
}

englishPrompt := buildTaskConfirmQuestionPrompt(snapshot, suggestion, languagepolicy.ReplyLanguageEnglish)
if !strings.Contains(englishPrompt, "Return exactly one concise English question and nothing else.") {
t.Fatalf("expected english confirm prompt instruction, got %q", englishPrompt)
}
chinesePrompt := buildTaskConfirmQuestionPrompt(snapshot, suggestion, languagepolicy.ReplyLanguageChinese)
if !strings.Contains(chinesePrompt, "Return exactly one concise Chinese question and nothing else.") {
t.Fatalf("expected chinese confirm prompt instruction, got %q", chinesePrompt)
}
}
Loading
Loading