fix(core): suppress ellipsis-only events and handle context indicator in footer

- Skip ellipsis-only thinking/text events that add no content
- When replyFooterEnabled, render context indicator inside footer instead of appending separately
- Fix duplicate side channel text when context indicator is present
- Add regression tests for duplicate suppression with context indicator

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude committed 2026-05-08 16:09:40 +08:00
1 parent 099f7bdf26
commit 34af331a89
5 files changed
+283 -39

No files matched your search

+2
View File
@@ -139,3 +139,5 @@ Desktop.ini
*.pfx
secrets/
credentials/
.codex
+1 -1
View File
@@ -134,7 +134,7 @@ test-release: pre-test
test-release-local:
go test ./tests/release_local/...
go test ./config
go test ./core -run 'TestEngineSendToSessionWithAttachments|TestCmdList_AllSessionsVisibleAfterRepeatedNew|TestCmdList_SessionVisibleDuringAgentProcessing|TestEngine_Alias|TestEngine_BannedWords|TestEngine_DisabledCommands'
go test ./core -run 'TestEngineSendToSessionWithAttachments|TestProcessInteractiveEvents_SuppressesDuplicateSideChannelText|TestCmdList_AllSessionsVisibleAfterRepeatedNew|TestCmdList_SessionVisibleDuringAgentProcessing|TestEngine_Alias|TestEngine_BannedWords|TestEngine_DisabledCommands'
go test ./platform/feishu -run 'TestUserIDFromEventFallsBackToUserID|TestResolveUserNameSkipsInvalidLookupID|TestNew_CanDisableInteractiveCards'
# Legacy: runs unit tests only
+86 -37
View File
@@ -3278,23 +3278,23 @@ func (e *Engine) runUnsolicitedReader(ctx context.Context, cancel context.Cancel
if autoApprove {
result = PermissionResult{Behavior: "allow", UpdatedInput: event.ToolInputRaw}
}
reqID := event.RequestID
respondCtx := ctx // capture current unsolicited reader context
go func() {
// Run in a goroutine to keep reader iterations fast, but honour
// the reader's context so we don't call into a dead session after
// stopUnsolicitedReader cancels the context.
select {
case <-respondCtx.Done():
return
default:
}
if err := agentSession.RespondPermission(reqID, result); err != nil {
if respondCtx.Err() == nil {
slog.Error("unsolicited: failed to respond permission", "error", err)
reqID := event.RequestID
respondCtx := ctx // capture current unsolicited reader context
go func() {
// Run in a goroutine to keep reader iterations fast, but honour
// the reader's context so we don't call into a dead session after
// stopUnsolicitedReader cancels the context.
select {
case <-respondCtx.Done():
return
default:
}
}
}()
if err := agentSession.RespondPermission(reqID, result); err != nil {
if respondCtx.Err() == nil {
slog.Error("unsolicited: failed to respond permission", "error", err)
}
}
}()
if !autoApprove {
toolName := event.ToolName
if toolName == "" {
@@ -3485,6 +3485,9 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
switch event.Type {
case EventThinking:
if isEllipsisOnly(event.Content) {
break
}
if hasRichCard {
// When thinking messages are suppressed, skip card creation.
if !e.display.ThinkingMessages {
@@ -3712,7 +3715,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
}
case EventText:
if event.Content != "" {
if event.Content != "" && !isEllipsisOnly(event.Content) {
if len(textParts) == 0 {
if hasRichCard {
if cardMessageID == nil {
@@ -3943,16 +3946,23 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
})
}
contextText := ""
if e.showContextIndicator && !isSilent {
if sdkPlausible {
cleanResponse += contextIndicator(event.InputTokens)
contextText = contextIndicatorText(event.InputTokens)
} else if selfPct > 0 {
cleanResponse += fmt.Sprintf("\n[ctx: ~%d%%]", selfPct)
contextText = fmt.Sprintf("[ctx: ~%d%%]", selfPct)
}
}
if !isSilent {
if footer := e.buildReplyFooter(replyAgent, state.agentSession, workspaceDir, replyFooterContextText(replyFooterSessionContextUsage(state.agentSession), e.i18n)); footer != "" {
footerContext := replyFooterContextText(replyFooterSessionContextUsage(state.agentSession), e.i18n)
if contextText != "" && e.replyFooterEnabled {
footerContext = contextText
}
if footer := e.buildReplyFooter(replyAgent, state.agentSession, workspaceDir, footerContext); footer != "" {
cleanResponse = appendReplyFooter(cleanResponse, footer)
} else if contextText != "" {
cleanResponse += "\n" + contextText
}
}
fullResponse = cleanResponse
@@ -4030,26 +4040,22 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess
// only send the unsent remainder. When tool progress is hidden, tool events don't surface
// side-channel messages and segmentStart stays 0, so keep normal finalize flow.
sp.discard()
if segmentStart < len(textParts) {
unsent := strings.Join(textParts[segmentStart:], "")
if unsent != "" {
for _, chunk := range splitMessage(unsent, maxPlatformMessageLen) {
if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil {
return
}
}
}
unsent := strings.Join(textParts[segmentStart:], "")
if strings.TrimSpace(unsent) != "" {
unsent = appendFinalMetadataToSegment(unsent, fullResponse)
}
} else if suppressDuplicate {
sp.discard()
if metaOnly := strings.TrimSpace(strings.TrimPrefix(fullResponse, baseResponse)); metaOnly != "" {
for _, chunk := range splitMessage(metaOnly, maxPlatformMessageLen) {
if unsent != "" {
for _, chunk := range splitMessage(unsent, maxPlatformMessageLen) {
if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil {
return
}
}
}
} else if suppressDuplicate {
sp.discard()
slog.Debug("EventResult: suppressed duplicate side-channel text", "response_len", len(fullResponse))
} else if sp.finish(fullResponse) {
slog.Debug("EventResult: finalized stream preview in-place", "response_len", len(fullResponse))
} else {
slog.Debug("EventResult: sending via p.Send (preview inactive or failed)", "response_len", len(fullResponse), "chunks", len(splitMessage(fullResponse, maxPlatformMessageLen)))
for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) {
@@ -5243,6 +5249,12 @@ func (e *Engine) buildReplyFooter(agent Agent, session AgentSession, workspaceDi
var parts []string
hasStatus := false
contextLeft = strings.TrimSpace(contextLeft)
contextFirst := strings.HasPrefix(contextLeft, "[ctx:")
if contextFirst {
parts = append(parts, contextLeft)
hasStatus = true
}
if model := replyFooterModel(session, agent); model != "" {
parts = append(parts, model)
hasStatus = true
@@ -5251,8 +5263,10 @@ func (e *Engine) buildReplyFooter(agent Agent, session AgentSession, workspaceDi
parts = append(parts, effort)
hasStatus = true
}
if left := strings.TrimSpace(contextLeft); left != "" {
parts = append(parts, left)
if contextFirst {
// Already added before model so "[ctx]" stays on the same footer line.
} else if contextLeft != "" {
parts = append(parts, contextLeft)
hasStatus = true
} else if usage := e.replyFooterUsageText(session, agent); usage != "" {
parts = append(parts, usage)
@@ -5481,6 +5495,28 @@ func appendReplyFooter(content, footer string) string {
return content + "\n\n*" + footer + "*"
}
func appendFinalMetadataToSegment(segment, fullResponse string) string {
segment = strings.TrimRight(segment, "\n ")
if segment == "" {
return fullResponse
}
fullResponse = strings.TrimSpace(fullResponse)
if fullResponse == "" || strings.TrimSpace(segment) == fullResponse {
return segment
}
metadata := ""
if idx := strings.LastIndex(fullResponse, "\n\n*"); idx >= 0 && strings.HasSuffix(fullResponse, "*") {
metadata = fullResponse[idx:]
} else if match := ctxSelfReportRe.FindString(fullResponse); match != "" {
metadata = "\n" + strings.TrimSpace(match)
}
if metadata == "" || strings.Contains(segment, strings.TrimSpace(metadata)) {
return segment
}
return segment + metadata
}
func (e *Engine) cmdShow(p Platform, msg *Message, args []string) {
rawRef := strings.TrimSpace(strings.Join(args, " "))
if rawRef == "" {
@@ -8609,7 +8645,7 @@ func (e *Engine) SendToSessionWithAttachments(sessionKey, message string, images
if err := p.Send(e.ctx, replyCtx, message); err != nil {
return err
}
if state != nil && (len(images) > 0 || len(files) > 0) {
if state != nil {
state.mu.Lock()
state.sideText = strings.TrimSpace(message)
state.mu.Unlock()
@@ -13328,6 +13364,14 @@ const modelContextWindow = 200_000 // generic fallback window for heuristic cont
// contextIndicator returns a suffix like "\n[ctx: ~42%]" based on SDK-reported input tokens.
func contextIndicator(inputTokens int) string {
text := contextIndicatorText(inputTokens)
if text == "" {
return ""
}
return "\n" + text
}
func contextIndicatorText(inputTokens int) string {
if inputTokens <= 0 {
return ""
}
@@ -13335,7 +13379,7 @@ func contextIndicator(inputTokens int) string {
if pct > 100 {
pct = 100
}
return fmt.Sprintf("\n[ctx: ~%d%%]", pct)
return fmt.Sprintf("[ctx: ~%d%%]", pct)
}
// ctxSelfReportRe matches agent self-reported context lines like "[ctx: ~42%]".
@@ -13378,6 +13422,11 @@ func couldBeSilentPrefix(text string) bool {
return strings.HasPrefix("NO_REPLY", strings.ToUpper(t))
}
func isEllipsisOnly(text string) bool {
t := strings.TrimSpace(text)
return t == "..." || t == "…"
}
// parseSelfReportedCtx extracts the percentage from a self-reported "[ctx: ~XX%]" line.
func parseSelfReportedCtx(s string) int {
m := ctxSelfReportRe.FindString(s)
+133
View File
@@ -994,6 +994,36 @@ func TestProcessInteractiveEvents_SuppressesDuplicateSideChannelText(t *testing.
}
}
func TestProcessInteractiveEvents_SuppressesDuplicateSideChannelTextWithContextIndicator(t *testing.T) {
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
sessionKey := "test:user1"
session := e.sessions.GetOrCreateActive(sessionKey)
agentSession := newControllableSession("s1")
state := &interactiveState{
agentSession: agentSession,
platform: p,
replyCtx: "ctx-1",
}
e.interactiveStates[sessionKey] = state
sideText := "已发送 AGENTS.md 文件给你。"
if err := e.SendToSessionWithAttachments(sessionKey, sideText, nil, []FileAttachment{{
MimeType: "text/markdown",
Data: []byte("body"),
FileName: "AGENTS.md",
}}); err != nil {
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
}
agentSession.events <- Event{Type: EventResult, Content: sideText, InputTokens: 52000, Done: true}
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, nil)
if got := p.getSent(); len(got) != 1 || got[0] != sideText {
t.Fatalf("sent text = %#v, want only the side-channel message without duplicate ctx reply", got)
}
}
func TestProcessInteractiveEvents_DoesNotSuppressDifferentFinalText(t *testing.T) {
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
@@ -1075,6 +1105,109 @@ func TestProcessInteractiveEvents_AppendsReplyFooterWhenEnabled(t *testing.T) {
}
}
func TestProcessInteractiveEvents_AppendsContextIndicatorInsideReplyFooter(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
agent := &stubReplyFooterAgent{
stubModelModeAgent: stubModelModeAgent{model: "glm-5.1"},
workDir: filepath.Join(homeDir, "code", "TechStudio", "projects", "core", "agents", "ceo"),
}
p := &stubPlatformEngine{n: "telegram"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
e.SetReplyFooterEnabled(true)
sessionKey := "telegram:user-footer-context"
session := e.sessions.GetOrCreateActive(sessionKey)
agentSession := newControllableSession("s-footer-context")
state := &interactiveState{
agentSession: agentSession,
platform: p,
replyCtx: "ctx-footer-context",
agent: agent,
}
e.interactiveStates[sessionKey] = state
agentSession.events <- Event{Type: EventResult, Content: "answer", InputTokens: 28000, Done: true}
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-footer-context", time.Now(), nil, nil, state.replyCtx)
sent := p.getSent()
if len(sent) != 1 {
t.Fatalf("sent = %#v, want one final reply", sent)
}
want := "answer\n\n*[ctx: ~14%] · glm-5.1 · ~/code/TechStudio/projects/core/agents/ceo*"
if sent[0] != want {
t.Fatalf("final reply = %q, want %q", sent[0], want)
}
}
func TestProcessInteractiveEvents_ToolSegmentsKeepFinalFooter(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
agent := &stubReplyFooterAgent{
stubModelModeAgent: stubModelModeAgent{model: "glm-5.1"},
workDir: filepath.Join(homeDir, "code", "TechStudio", "projects", "core", "agents", "ceo"),
}
p := &stubPlatformEngine{n: "telegram"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
e.SetReplyFooterEnabled(true)
e.SetDisplayConfig(DisplayCfg{ThinkingMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500, ToolMessages: true})
sessionKey := "telegram:user-tool-footer"
session := e.sessions.GetOrCreateActive(sessionKey)
agentSession := newControllableSession("s-tool-footer")
state := &interactiveState{
agentSession: agentSession,
platform: p,
replyCtx: "ctx-tool-footer",
agent: agent,
}
e.interactiveStates[sessionKey] = state
agentSession.events <- Event{Type: EventText, Content: "先检查一下。"}
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "pwd"}
agentSession.events <- Event{Type: EventText, Content: "已处理完成。"}
agentSession.events <- Event{Type: EventResult, Content: "已处理完成。", InputTokens: 28000, Done: true}
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-tool-footer", time.Now(), nil, nil, state.replyCtx)
sent := p.getSent()
if len(sent) == 0 {
t.Fatal("sent = nil, want final reply")
}
final := sent[len(sent)-1]
want := "已处理完成。\n\n*[ctx: ~14%] · glm-5.1 · ~/code/TechStudio/projects/core/agents/ceo*"
if final != want {
t.Fatalf("final reply = %q, want %q\nall sent = %#v", final, want, sent)
}
}
func TestProcessInteractiveEvents_DropsStandaloneEllipsisProgress(t *testing.T) {
p := &stubPlatformEngine{n: "telegram"}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
e.SetDisplayConfig(DisplayCfg{ThinkingMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500, ToolMessages: true})
sessionKey := "telegram:user-ellipsis"
session := e.sessions.GetOrCreateActive(sessionKey)
agentSession := newControllableSession("s-ellipsis")
state := &interactiveState{
agentSession: agentSession,
platform: p,
replyCtx: "ctx-ellipsis",
}
e.interactiveStates[sessionKey] = state
agentSession.events <- Event{Type: EventThinking, Content: "..."}
agentSession.events <- Event{Type: EventText, Content: "..."}
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-ellipsis", time.Now(), nil, nil, state.replyCtx)
sent := p.getSent()
if len(sent) != 1 || sent[0] != "done" {
t.Fatalf("sent = %#v, want only final answer without standalone ellipsis progress", sent)
}
}
func TestProcessInteractiveEvents_DoesNotAppendReplyFooterWhenDisabled(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
@@ -118,12 +118,16 @@ func (s *recordingSession) Close() error {
}
func (s *recordingSession) releaseFirstResult(content string) {
s.releaseFirstEvent(core.Event{Type: core.EventResult, Content: content, Done: true})
}
func (s *recordingSession) releaseFirstEvent(event core.Event) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.blocked {
return
}
s.events <- core.Event{Type: core.EventResult, Content: content, Done: true}
s.events <- event
s.blocked = false
}
@@ -327,6 +331,62 @@ func TestSendToSessionWithAttachmentsDeliversTextImagesAndFiles(t *testing.T) {
}
}
func TestSendToSessionWithAttachmentsDoesNotDuplicateEchoedFinalTextWithContextIndicator(t *testing.T) {
engine, agent, platform := newMediaEngine(t)
agent.session.blockFirstResult()
msg := mediaMessage("start long task")
engine.ReceiveMessage(platform, msg)
agent.session.waitRecords(t, 1)
sideText := "delivery ready"
err := engine.SendToSessionWithAttachments(
msg.SessionKey,
sideText,
nil,
[]core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}},
)
if err != nil {
t.Fatalf("SendToSessionWithAttachments() error = %v", err)
}
agent.session.releaseFirstEvent(core.Event{
Type: core.EventResult,
Content: sideText,
InputTokens: 52000,
Done: true,
})
deadline := time.Now().Add(300 * time.Millisecond)
var lastTexts []string
for time.Now().Before(deadline) {
texts, _, _, _ := platform.snapshot()
lastTexts = texts
count := 0
for _, text := range texts {
if strings.Contains(text, sideText) {
count++
}
if strings.Contains(text, "[ctx:") {
t.Fatalf("unexpected duplicate context indicator reply: %#v", texts)
}
}
if count > 1 {
t.Fatalf("texts = %#v, want no duplicate delivery message", texts)
}
time.Sleep(10 * time.Millisecond)
}
count := 0
for _, text := range lastTexts {
if strings.Contains(text, sideText) {
count++
}
}
if count != 1 {
t.Fatalf("texts = %#v, want exactly one side-channel delivery message", lastTexts)
}
}
func TestSendToSessionWithAttachmentsRespectsDisabledAttachmentSend(t *testing.T) {
engine, agent, platform := newMediaEngine(t)
msg := mediaMessage("establish active session")