mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
Squash merge of PR #1204 (rebased onto main, minor conflicts resolved). Adds structured per-turn reply footer, cardkit-v1 streaming, status footer interface, claude context usage tracking, and rich card body improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
435 lines
15 KiB
Go
435 lines
15 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newClaudeFooterEngine returns an Engine with all three footer-related flags
|
|
// turned on, suitable for invoking buildClaudeStatusLineFooter as a method.
|
|
func newClaudeFooterEngine() *Engine {
|
|
e := &Engine{}
|
|
e.SetReplyFooterEnabled(true)
|
|
e.SetShowContextIndicator(true)
|
|
e.SetShowWorkdirIndicator(true)
|
|
return e
|
|
}
|
|
|
|
func TestFormatStatusTokenCount(t *testing.T) {
|
|
cases := map[int]string{
|
|
0: "0",
|
|
1: "1",
|
|
999: "999",
|
|
1000: "1.0k",
|
|
40800: "40.8k",
|
|
-5: "0",
|
|
}
|
|
for in, want := range cases {
|
|
got := formatStatusTokenCount(in)
|
|
if got != want {
|
|
t.Errorf("formatStatusTokenCount(%d) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeStatusLineFooter_NilUsage(t *testing.T) {
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
if got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws"); got != "" {
|
|
t.Errorf("expected empty footer for nil usage, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeStatusLineFooter_NoCacheTokens(t *testing.T) {
|
|
// Other agents (codex/gemini) populate ContextUsage without cache
|
|
// tokens; we must NOT emit the claude-style footer for them.
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
contextUsage: &ContextUsage{
|
|
InputTokens: 1000,
|
|
OutputTokens: 200,
|
|
ContextWindow: 1_000_000,
|
|
UsedTokens: 1000,
|
|
},
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
if got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws"); got != "" {
|
|
t.Errorf("expected empty footer when cache tokens absent, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildClaudeStatusLineFooter_FullRender(t *testing.T) {
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
contextUsage: &ContextUsage{
|
|
InputTokens: 1,
|
|
OutputTokens: 168,
|
|
CacheCreationInputTokens: 971,
|
|
CachedInputTokens: 40800,
|
|
ContextWindow: 1_000_000,
|
|
UsedTokens: 1 + 971 + 40800,
|
|
},
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws")
|
|
// 41772 / 1_000_000 = 4.17% → rounds to 4%.
|
|
// Output is two lines:
|
|
// line 1: <model id> · out N · in N cw N cr N · ctx N%
|
|
// line 2: <workspace dir>
|
|
lines := strings.Split(got, "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("expected 2 lines (metrics + dir), got %d: %q", len(lines), got)
|
|
}
|
|
parts := strings.Split(lines[0], " · ")
|
|
if len(parts) != 4 {
|
|
t.Fatalf("expected 4 segments on line 1, got %d: %q", len(parts), lines[0])
|
|
}
|
|
if parts[0] != "claude-opus-4-7[1m]" {
|
|
t.Errorf("segment 0 = %q, want raw model id", parts[0])
|
|
}
|
|
if parts[1] != "out 168" {
|
|
t.Errorf("out segment = %q, want %q", parts[1], "out 168")
|
|
}
|
|
if parts[2] != "in 1 cw 971 cr 40.8k" {
|
|
t.Errorf("in/cw/cr segment = %q, want %q", parts[2], "in 1 cw 971 cr 40.8k")
|
|
}
|
|
if parts[3] != "ctx 4%" {
|
|
t.Errorf("ctx segment = %q, want %q", parts[3], "ctx 4%")
|
|
}
|
|
if lines[1] == "" || !strings.Contains(lines[1], "ws") {
|
|
t.Errorf("line 2 = %q, want workspace path containing 'ws'", lines[1])
|
|
}
|
|
}
|
|
|
|
// TestBuildClaudeStatusLineFooter_FooterDisabled verifies that the master
|
|
// reply_footer toggle suppresses the footer outright.
|
|
func TestBuildClaudeStatusLineFooter_FooterDisabled(t *testing.T) {
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
contextUsage: &ContextUsage{
|
|
InputTokens: 1,
|
|
OutputTokens: 168,
|
|
CacheCreationInputTokens: 971,
|
|
CachedInputTokens: 40800,
|
|
ContextWindow: 1_000_000,
|
|
UsedTokens: 1 + 971 + 40800,
|
|
},
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
e.SetReplyFooterEnabled(false)
|
|
if got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws"); got != "" {
|
|
t.Errorf("reply_footer=false must suppress footer, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildClaudeStatusLineFooter_HideContextLine hides line 1 (model/tokens/ctx)
|
|
// but keeps line 2 (workdir) — only the workspace path should appear.
|
|
func TestBuildClaudeStatusLineFooter_HideContextLine(t *testing.T) {
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
contextUsage: &ContextUsage{
|
|
InputTokens: 1,
|
|
OutputTokens: 168,
|
|
CacheCreationInputTokens: 971,
|
|
CachedInputTokens: 40800,
|
|
ContextWindow: 1_000_000,
|
|
UsedTokens: 1 + 971 + 40800,
|
|
},
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
e.SetShowContextIndicator(false)
|
|
got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws")
|
|
if strings.Contains(got, "\n") {
|
|
t.Errorf("line 1 should be hidden — got multi-line footer: %q", got)
|
|
}
|
|
if got == "" || !strings.Contains(got, "ws") {
|
|
t.Errorf("expected workdir-only footer containing 'ws', got %q", got)
|
|
}
|
|
if strings.Contains(got, "ctx ") || strings.Contains(got, "out ") {
|
|
t.Errorf("metrics segments must not appear when show_context_indicator=false: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildClaudeStatusLineFooter_HideWorkdirLine hides line 2 (workdir) but
|
|
// keeps line 1 (metrics) — no newline, no path.
|
|
func TestBuildClaudeStatusLineFooter_HideWorkdirLine(t *testing.T) {
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
contextUsage: &ContextUsage{
|
|
InputTokens: 1,
|
|
OutputTokens: 168,
|
|
CacheCreationInputTokens: 971,
|
|
CachedInputTokens: 40800,
|
|
ContextWindow: 1_000_000,
|
|
UsedTokens: 1 + 971 + 40800,
|
|
},
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
e.SetShowWorkdirIndicator(false)
|
|
got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws")
|
|
if strings.Contains(got, "\n") {
|
|
t.Errorf("line 2 should be hidden — got multi-line footer: %q", got)
|
|
}
|
|
if !strings.Contains(got, "ctx ") {
|
|
t.Errorf("metrics line missing ctx segment: %q", got)
|
|
}
|
|
if strings.Contains(got, "ws") {
|
|
t.Errorf("workspace path must not appear when show_workdir_indicator=false: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildClaudeStatusLineFooter_HideBothLines disables both per-line flags
|
|
// while keeping the master reply_footer on — footer collapses to empty.
|
|
func TestBuildClaudeStatusLineFooter_HideBothLines(t *testing.T) {
|
|
session := &controllableAgentSession{
|
|
model: "claude-opus-4-7[1m]",
|
|
workDir: "/tmp/ws",
|
|
contextUsage: &ContextUsage{
|
|
InputTokens: 1,
|
|
OutputTokens: 168,
|
|
CacheCreationInputTokens: 971,
|
|
CachedInputTokens: 40800,
|
|
ContextWindow: 1_000_000,
|
|
UsedTokens: 1 + 971 + 40800,
|
|
},
|
|
}
|
|
e := newClaudeFooterEngine()
|
|
e.SetShowContextIndicator(false)
|
|
e.SetShowWorkdirIndicator(false)
|
|
if got := e.buildClaudeStatusLineFooter(nil, session, "/tmp/ws"); got != "" {
|
|
t.Errorf("both lines hidden should yield empty footer, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ── buildReplyFooter (legacy single-line) toggle matrix ───────────────────────
|
|
|
|
// stubFooterAgent is a minimal Agent that exposes GetModel/GetReasoningEffort/
|
|
// GetWorkDir but never reports usage — so buildReplyFooter renders only
|
|
// model · effort · contextLeft · cwd segments deterministically.
|
|
type stubFooterAgent struct {
|
|
stubAgent
|
|
model string
|
|
effort string
|
|
workDir string
|
|
}
|
|
|
|
func (a *stubFooterAgent) GetModel() string { return a.model }
|
|
func (a *stubFooterAgent) GetReasoningEffort() string { return a.effort }
|
|
func (a *stubFooterAgent) GetWorkDir() string { return a.workDir }
|
|
|
|
// newLegacyFooterEngine returns an Engine with all three footer flags on,
|
|
// suitable for invoking buildReplyFooter directly against the legacy single-
|
|
// line footer path.
|
|
func newLegacyFooterEngine() *Engine {
|
|
e := &Engine{}
|
|
e.SetReplyFooterEnabled(true)
|
|
e.SetShowContextIndicator(true)
|
|
e.SetShowWorkdirIndicator(true)
|
|
return e
|
|
}
|
|
|
|
func TestBuildReplyFooter_LegacyAllSegments(t *testing.T) {
|
|
e := newLegacyFooterEngine()
|
|
e.i18n = NewI18n(LangEnglish)
|
|
agent := &stubFooterAgent{model: "gpt-5.4", effort: "xhigh", workDir: "/tmp/ws"}
|
|
got := e.buildReplyFooter(agent, nil, "/tmp/ws", "100% left")
|
|
wantSubs := []string{"gpt-5.4", "xhigh", "100% left", "ws"}
|
|
for _, sub := range wantSubs {
|
|
if !strings.Contains(got, sub) {
|
|
t.Errorf("legacy footer = %q, missing %q", got, sub)
|
|
}
|
|
}
|
|
// Segments joined by " · ".
|
|
if !strings.Contains(got, " · ") {
|
|
t.Errorf("legacy footer = %q, expected ' · ' separators", got)
|
|
}
|
|
}
|
|
|
|
func TestCompactReplyFooterPath_HomeRelativeDeepPathStaysFull(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
shortPath := filepath.Join(homeDir, "codes", "cc-connect")
|
|
if got, want := compactReplyFooterPath(shortPath), "~/codes/cc-connect"; got != want {
|
|
t.Fatalf("short home path = %q, want %q", got, want)
|
|
}
|
|
|
|
deepPath := filepath.Join(homeDir, "code", "TechStudio", "projects", "core", "agents", "ceo")
|
|
if got, want := compactReplyFooterPath(deepPath), "~/code/TechStudio/projects/core/agents/ceo"; got != want {
|
|
t.Fatalf("deep home path = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildReplyFooter_LegacyHidesContextSegments(t *testing.T) {
|
|
e := newLegacyFooterEngine()
|
|
e.SetShowContextIndicator(false)
|
|
e.i18n = NewI18n(LangEnglish)
|
|
agent := &stubFooterAgent{model: "gpt-5.4", effort: "xhigh", workDir: "/tmp/ws"}
|
|
// With model/effort/contextLeft all suppressed, only cwd would remain —
|
|
// and a workdir-only footer is suppressed entirely (regression #701).
|
|
if got := e.buildReplyFooter(agent, nil, "/tmp/ws", "100% left"); got != "" {
|
|
t.Errorf("legacy footer with show_context_indicator=false = %q, want empty (workdir-only suppressed)", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildReplyFooter_LegacyHidesWorkdirSegment(t *testing.T) {
|
|
e := newLegacyFooterEngine()
|
|
e.SetShowWorkdirIndicator(false)
|
|
e.i18n = NewI18n(LangEnglish)
|
|
agent := &stubFooterAgent{model: "gpt-5.4", effort: "xhigh", workDir: "/tmp/ws"}
|
|
got := e.buildReplyFooter(agent, nil, "/tmp/ws", "100% left")
|
|
if got == "" {
|
|
t.Fatalf("legacy footer should still render line-1 segments")
|
|
}
|
|
if strings.Contains(got, "ws") {
|
|
t.Errorf("legacy footer with show_workdir_indicator=false = %q, must not contain workdir", got)
|
|
}
|
|
for _, sub := range []string{"gpt-5.4", "xhigh", "100% left"} {
|
|
if !strings.Contains(got, sub) {
|
|
t.Errorf("legacy footer = %q, missing %q (line-1 must remain)", got, sub)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildReplyFooter_LegacyMasterToggleOff(t *testing.T) {
|
|
e := newLegacyFooterEngine()
|
|
e.SetReplyFooterEnabled(false)
|
|
e.i18n = NewI18n(LangEnglish)
|
|
agent := &stubFooterAgent{model: "gpt-5.4", effort: "xhigh", workDir: "/tmp/ws"}
|
|
if got := e.buildReplyFooter(agent, nil, "/tmp/ws", "100% left"); got != "" {
|
|
t.Errorf("reply_footer=false must short-circuit, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ── sendChunksWithStatusFooter helper ─────────────────────────────────────────
|
|
|
|
// stubFooterSendingPlatform implements StatusFooterSender, capturing every
|
|
// call so tests can verify the routing decision (footer-aware vs plain Send).
|
|
type stubFooterSendingPlatform struct {
|
|
stubPlatformEngine
|
|
footerCalls []string // "<content>|FOOTER|<footer>"
|
|
failFooter bool // when true, SendWithStatusFooter returns an error
|
|
}
|
|
|
|
func (p *stubFooterSendingPlatform) SendWithStatusFooter(_ context.Context, _ any, content, footer string) error {
|
|
p.footerCalls = append(p.footerCalls, content+"|FOOTER|"+footer)
|
|
if p.failFooter {
|
|
return fmt.Errorf("simulated SendWithStatusFooter failure")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestSendChunksWithStatusFooter_RoutesToFooterSender(t *testing.T) {
|
|
p := &stubFooterSendingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
send := func(pl Platform, rctx any, content string) error {
|
|
return pl.Send(context.Background(), rctx, content)
|
|
}
|
|
ok := sendChunksWithStatusFooter(context.Background(), p, "ctx", "hello body", "model · ctx 5%", send)
|
|
if !ok {
|
|
t.Fatal("expected success")
|
|
}
|
|
if len(p.footerCalls) != 1 {
|
|
t.Fatalf("expected 1 SendWithStatusFooter call, got %d", len(p.footerCalls))
|
|
}
|
|
if !strings.Contains(p.footerCalls[0], "hello body") || !strings.Contains(p.footerCalls[0], "model · ctx 5%") {
|
|
t.Errorf("footer call payload = %q, missing body or footer", p.footerCalls[0])
|
|
}
|
|
// Plain Send must NOT be called when footer routing succeeds.
|
|
if got := p.getSent(); len(got) != 0 {
|
|
t.Errorf("plain Send fallthrough = %#v, want empty when footer routing succeeded", got)
|
|
}
|
|
}
|
|
|
|
func TestSendChunksWithStatusFooter_FallsBackInlineWhenFooterSenderFails(t *testing.T) {
|
|
p := &stubFooterSendingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}, failFooter: true}
|
|
send := func(pl Platform, rctx any, content string) error {
|
|
return pl.Send(context.Background(), rctx, content)
|
|
}
|
|
ok := sendChunksWithStatusFooter(context.Background(), p, "ctx", "hello body", "model · ctx 5%", send)
|
|
if !ok {
|
|
t.Fatal("expected success after fallback")
|
|
}
|
|
if len(p.footerCalls) != 1 {
|
|
t.Fatalf("expected 1 attempted footer call, got %d", len(p.footerCalls))
|
|
}
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("expected 1 inline-fallback Send, got %#v", sent)
|
|
}
|
|
// Inline fallback applies appendReplyFooter using the upstream core footer shape.
|
|
if !strings.Contains(sent[0], "hello body") || !strings.Contains(sent[0], "*model · ctx 5%*") {
|
|
t.Errorf("inline fallback content = %q, missing body or footer", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestSendChunksWithStatusFooter_NoFooter(t *testing.T) {
|
|
p := &stubFooterSendingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
send := func(pl Platform, rctx any, content string) error {
|
|
return pl.Send(context.Background(), rctx, content)
|
|
}
|
|
ok := sendChunksWithStatusFooter(context.Background(), p, "ctx", "hello body", "", send)
|
|
if !ok {
|
|
t.Fatal("expected success")
|
|
}
|
|
// No footer → routes via plain Send only, never tries SendWithStatusFooter.
|
|
if len(p.footerCalls) != 0 {
|
|
t.Errorf("expected 0 footer calls when statusFooter empty, got %d", len(p.footerCalls))
|
|
}
|
|
if got := p.getSent(); len(got) != 1 || got[0] != "hello body" {
|
|
t.Errorf("plain Send sequence = %#v, want one chunk verbatim", got)
|
|
}
|
|
}
|
|
|
|
func TestSendChunksWithStatusFooter_PlatformWithoutFooterSender(t *testing.T) {
|
|
// stubPlatformEngine does NOT implement StatusFooterSender → must fall
|
|
// through to inline appendReplyFooter on the last chunk.
|
|
p := &stubPlatformEngine{n: "test"}
|
|
send := func(pl Platform, rctx any, content string) error {
|
|
return pl.Send(context.Background(), rctx, content)
|
|
}
|
|
ok := sendChunksWithStatusFooter(context.Background(), p, "ctx", "hello body", "model · ctx 5%", send)
|
|
if !ok {
|
|
t.Fatal("expected success")
|
|
}
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("expected 1 send, got %#v", sent)
|
|
}
|
|
if !strings.Contains(sent[0], "*model · ctx 5%*") {
|
|
t.Errorf("expected wrapped footer inline, got %q", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestAppendReplyFooter_UsesUpstreamWrappedFooterShape(t *testing.T) {
|
|
got := appendReplyFooter("hello world", "line1 metrics\n~/path/to/ws")
|
|
wantSubs := []string{
|
|
"hello world",
|
|
"\n\n*line1 metrics\n~/path/to/ws*",
|
|
}
|
|
for _, sub := range wantSubs {
|
|
if !strings.Contains(got, sub) {
|
|
t.Errorf("appendReplyFooter result missing %q\nfull: %q", sub, got)
|
|
}
|
|
}
|
|
// Single-line footer keeps the platform-agnostic wrapped shape.
|
|
got = appendReplyFooter("body", "single")
|
|
if got != "body\n\n*single*" {
|
|
t.Errorf("single-line append = %q, want %q", got, "body\n\n*single*")
|
|
}
|
|
// Empty footer is a no-op.
|
|
if got := appendReplyFooter("body", ""); got != "body" {
|
|
t.Errorf("empty footer should be no-op, got %q", got)
|
|
}
|
|
}
|