diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index a7f5437b2..e983940c9 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6826,8 +6826,8 @@ "summary": "Align Assistant live tool progress with OpenCode source workflow", "target_id": "v6-product-lane-expansion", "claimed_at": "2026-06-06T04:02:24Z", - "heartbeat_at": "2026-06-06T04:28:32Z", - "expires_at": "2026-06-06T12:28:32Z", + "heartbeat_at": "2026-06-06T04:46:07Z", + "expires_at": "2026-06-06T12:46:07Z", "work_item": { "kind": "lane-followup", "id": "architecture-post-rc-canonicalization" diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 3fe3abc34..23ddb4720 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -457,6 +457,15 @@ runtime cost control, and shared AI transport surfaces. Assistant stream events must pass through `chat.StreamEvent.ClientSafe()`; provider `thinking` chunks are runtime-only and may be retained internally for model continuity, but they are dropped before the browser/API boundary. + The referenced OpenCode source at fetched `origin/dev` commit + `1399323b78a04229d9bfe00c7436d7f41770fda8` stores assistant output as typed + text, reasoning, and `tool-invocation` parts in + `packages/opencode/src/session/message.ts` and mutates those typed parts + through explicit stream events in + `packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx`; Pulse adapts that + invariant by stripping operational decorative status glyphs, warning icons, + and check/cross badges from browser-safe assistant prose while preserving + ordinary Unicode answer text such as units. The agentic stream may translate the first private provider reasoning delta before visible output into a neutral `model_thinking` workflow status so the drawer shows live activity without exposing chain-of-thought. Neutral diff --git a/internal/ai/chat/agentic_sanitize.go b/internal/ai/chat/agentic_sanitize.go index a972a9bb6..a53f48dce 100644 --- a/internal/ai/chat/agentic_sanitize.go +++ b/internal/ai/chat/agentic_sanitize.go @@ -66,6 +66,35 @@ var ( // instead of a structured tool call. Gate on canonical tool names so a // random prose function call is not stripped. plainFunctionToolCallRe = regexp.MustCompile(`(?:^|[^a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)[ \t\r\n]*\(`) + + // Operational providers often decorate alert/status answers with emoji + // badges even when instructed not to. Pulse renders state through typed + // tool/progress rows, so these glyphs are treated as presentation artifacts + // at the browser/API boundary rather than assistant prose. + decorativeAssistantSymbolReplacer = strings.NewReplacer( + "\ufe0f", "", + "⚠️", "", "⚠", "", + "🚨", "", "πŸ›‘", "", "β›”", "", + "πŸ”΄", "", "🟠", "", "🟑", "", "🟒", "", "πŸ”΅", "", "🟣", "", "🟀", "", "⚫", "", "βšͺ", "", + "βœ…", "", "❌", "", "❎", "", "βœ”οΈ", "", "βœ”", "", "β˜‘οΈ", "", "β˜‘", "", "βœ–οΈ", "", "βœ–", "", "βœ—", "", "✘", "", + "ℹ️", "", "β„Ή", "", "❗", "", "❕", "", "❓", "", "❔", "", + "πŸ”§", "", "πŸ› οΈ", "", "πŸ› ", "", "🧰", "", + "πŸ”₯", "", "πŸ’‘", "", "πŸ“Œ", "", "πŸ“", "", "πŸ“Š", "", "πŸ“ˆ", "", "πŸ“‰", "", + ) + decorativeAssistantSymbols = []string{ + "⚠️", "⚠", "🚨", "πŸ›‘", "β›”", + "πŸ”΄", "🟠", "🟑", "🟒", "πŸ”΅", "🟣", "🟀", "⚫", "βšͺ", + "βœ…", "❌", "❎", "βœ”οΈ", "βœ”", "β˜‘οΈ", "β˜‘", "βœ–οΈ", "βœ–", "βœ—", "✘", + "ℹ️", "β„Ή", "❗", "❕", "❓", "❔", + "πŸ”§", "πŸ› οΈ", "πŸ› ", "🧰", + "πŸ”₯", "πŸ’‘", "πŸ“Œ", "πŸ“", "πŸ“Š", "πŸ“ˆ", "πŸ“‰", + } + decorativeWhitespaceGapRe = regexp.MustCompile(`([^\s])[ \t]{2,}([^\s])`) + decorativeSpaceBeforePunctRe = regexp.MustCompile(`[ \t]+([,.;:!?])`) + decorativeTightHeadingRe = regexp.MustCompile(`^([ \t]{0,3}#{1,6})([^#\s])`) + decorativeTightListMarkerRe = regexp.MustCompile(`^([ \t]*(?:[-*+]|[0-9]+[.)]))([^ \t])`) + decorativeTightColonRe = regexp.MustCompile(`(:)([A-Z][A-Za-z])`) + decorativeAssistantFenceStartRe = regexp.MustCompile(`^[ \t]*(?:` + "```" + `|~~~)`) ) // cleanToolCallArtifacts removes LLM-internal tool call format leakage from content. @@ -85,7 +114,68 @@ func cleanToolCallArtifacts(content string) string { content = prefix } - return content + return cleanDecorativeAssistantSymbols(content) +} + +func cleanDecorativeAssistantSymbols(content string) string { + if content == "" { + return content + } + + var builder strings.Builder + builder.Grow(len(content)) + + inFence := false + lines := strings.SplitAfter(content, "\n") + for _, segment := range lines { + line := strings.TrimSuffix(segment, "\n") + hasNewline := len(segment) > len(line) + + if decorativeAssistantFenceStartRe.MatchString(line) { + builder.WriteString(line) + if hasNewline { + builder.WriteByte('\n') + } + inFence = !inFence + continue + } + if inFence { + builder.WriteString(line) + if hasNewline { + builder.WriteByte('\n') + } + continue + } + + cleaned := decorativeAssistantSymbolReplacer.Replace(line) + if cleaned != line { + if lineStartsWithDecorativeAssistantSymbol(line) { + cleaned = strings.TrimLeft(cleaned, " \t") + } + cleaned = decorativeTightHeadingRe.ReplaceAllString(cleaned, "$1 $2") + cleaned = decorativeTightListMarkerRe.ReplaceAllString(cleaned, "$1 $2") + cleaned = decorativeTightColonRe.ReplaceAllString(cleaned, "$1 $2") + cleaned = decorativeWhitespaceGapRe.ReplaceAllString(cleaned, "$1 $2") + cleaned = decorativeSpaceBeforePunctRe.ReplaceAllString(cleaned, "$1") + } + + builder.WriteString(cleaned) + if hasNewline { + builder.WriteByte('\n') + } + } + + return builder.String() +} + +func lineStartsWithDecorativeAssistantSymbol(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + for _, symbol := range decorativeAssistantSymbols { + if strings.HasPrefix(trimmed, symbol) { + return true + } + } + return false } // containsToolCallMarker checks if content contains any known LLM-internal tool call markers. @@ -156,13 +246,10 @@ func appendVisibleContentBeforeToolLeak( idx := toolCallArtifactIndex(candidate) if idx < 0 { visible, held := splitTrailingPotentialToolNamePrefix(text) - if visible != "" { - builder.WriteString(visible) - } if pending != nil { *pending = held } - return visible, false + return appendSanitizedVisibleDelta(builder, visible), false } if idx > len(existing) { @@ -174,7 +261,7 @@ func appendVisibleContentBeforeToolLeak( return "", true } visibleDelta, _ = splitTrailingPotentialToolNamePrefix(candidate[len(existing):idx]) - builder.WriteString(visibleDelta) + visibleDelta = appendSanitizedVisibleDelta(builder, visibleDelta) } else if isCompactedToolPrelude(candidate[:idx]) { builder.Reset() if pending != nil { @@ -190,8 +277,21 @@ func flushPendingVisibleContent(builder *strings.Builder, pending *string) strin } visible := *pending *pending = "" - builder.WriteString(visible) - return visible + return appendSanitizedVisibleDelta(builder, visible) +} + +func appendSanitizedVisibleDelta(builder *strings.Builder, visible string) string { + if visible == "" { + return "" + } + existing := builder.String() + cleanedCandidate := cleanDecorativeAssistantSymbols(existing + visible) + if !strings.HasPrefix(cleanedCandidate, existing) { + cleanedCandidate = existing + cleanDecorativeAssistantSymbols(visible) + } + delta := strings.TrimPrefix(cleanedCandidate, existing) + builder.WriteString(delta) + return delta } func splitTrailingPotentialToolNamePrefix(content string) (visible string, held string) { diff --git a/internal/ai/chat/agentic_sanitize_test.go b/internal/ai/chat/agentic_sanitize_test.go index 6d965441a..13d2198f2 100644 --- a/internal/ai/chat/agentic_sanitize_test.go +++ b/internal/ai/chat/agentic_sanitize_test.go @@ -122,6 +122,16 @@ func TestCleanToolCallArtifacts(t *testing.T) { } } +func TestCleanToolCallArtifactsCleansDecorativeOperationalSymbols(t *testing.T) { + input := "### πŸ”΄ Critical Alerts\n###⚠️Warnings\n- ⚠️ Active AI Patrol Finding\n3.βœ… Backup is healthy\nCheck ⚠️ the alert, then βœ… the backup.\nNext Steps:βœ…Would you like me to investigate?\nTemperature is 58Β°C.\n\n```text\n⚠️ literal status stays inside code\n```\n" + expected := "### Critical Alerts\n### Warnings\n- Active AI Patrol Finding\n3. Backup is healthy\nCheck the alert, then the backup.\nNext Steps: Would you like me to investigate?\nTemperature is 58Β°C.\n\n```text\n⚠️ literal status stays inside code\n```\n" + + got := cleanToolCallArtifacts(input) + if got != expected { + t.Fatalf("cleanToolCallArtifacts() = %q, want %q", got, expected) + } +} + func TestContainsToolCallMarker(t *testing.T) { tests := []struct { name string @@ -237,3 +247,41 @@ func TestAppendVisibleContentBeforeToolLeak_ClearsCompactedPreludeWhenSplit(t *t t.Fatalf("compacted prelude should be cleared after split leak, got delta=%q builder=%q pending=%q", delta, builder.String(), pending) } } + +func TestAppendVisibleContentBeforeToolLeak_CleansDecorativeSymbolsAcrossChunks(t *testing.T) { + var builder strings.Builder + var pending string + + delta, leakFound := appendVisibleContentBeforeToolLeak(&builder, &pending, "###") + if leakFound { + t.Fatal("markdown heading prefix should not be treated as a tool leak") + } + if delta != "###" || builder.String() != "###" { + t.Fatalf("unexpected first delta=%q builder=%q", delta, builder.String()) + } + + delta, leakFound = appendVisibleContentBeforeToolLeak(&builder, &pending, "⚠️Critical Alerts") + if leakFound { + t.Fatal("decorative status glyph should not be treated as a tool leak") + } + if delta != " Critical Alerts" || builder.String() != "### Critical Alerts" { + t.Fatalf("decorative heading should stream as valid markdown, got delta=%q builder=%q", delta, builder.String()) + } + + delta, leakFound = appendVisibleContentBeforeToolLeak(&builder, &pending, "\nNext Steps:") + if leakFound { + t.Fatal("plain heading text should not be treated as a tool leak") + } + if delta != "\nNext Steps:" { + t.Fatalf("unexpected next-steps delta=%q", delta) + } + + delta, leakFound = appendVisibleContentBeforeToolLeak(&builder, &pending, "βœ…Would you like me to investigate?") + if leakFound { + t.Fatal("decorative status glyph should not be treated as a tool leak") + } + expected := "### Critical Alerts\nNext Steps: Would you like me to investigate?" + if delta != " Would you like me to investigate?" || builder.String() != expected { + t.Fatalf("decorative colon spacing should stream cleanly, got delta=%q builder=%q", delta, builder.String()) + } +} diff --git a/internal/ai/chat/stream_event_test.go b/internal/ai/chat/stream_event_test.go index a853c7f55..e196073af 100644 --- a/internal/ai/chat/stream_event_test.go +++ b/internal/ai/chat/stream_event_test.go @@ -38,3 +38,26 @@ func TestStreamEventClientSafeCleansContentToolCallArtifacts(t *testing.T) { t.Fatalf("content text = %q, want cleaned prose", content.Text) } } + +func TestStreamEventClientSafeCleansDecorativeOperationalSymbols(t *testing.T) { + data, err := json.Marshal(ContentData{ + Text: "### πŸ”΄ Critical Alerts\n- ⚠️ Active AI Patrol Finding\nTemperature is 58Β°C.", + }) + if err != nil { + t.Fatal(err) + } + + event, ok := (StreamEvent{Type: "content", Data: data}).ClientSafe() + if !ok { + t.Fatal("content event should remain visible") + } + + var content ContentData + if err := json.Unmarshal(event.Data, &content); err != nil { + t.Fatal(err) + } + expected := "### Critical Alerts\n- Active AI Patrol Finding\nTemperature is 58Β°C." + if content.Text != expected { + t.Fatalf("content text = %q, want %q", content.Text, expected) + } +}