Skip to content
Open
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
8 changes: 6 additions & 2 deletions go/adk/pkg/a2a/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"maps"
"os"
"strings"
"unicode/utf8"

a2atype "github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2asrv"
Expand Down Expand Up @@ -424,14 +425,17 @@ func (e *KAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestConte
}

// extractSessionName extracts session name from the first text part of a message.
// Truncation is rune-aware: cutting on a byte boundary can split a multi-byte
// UTF-8 rune and yield an invalid-UTF-8 name, which the Postgres session store
// rejects on session create. sessionNameMaxLength is therefore counted in runes.
func extractSessionName(message *a2atype.Message) string {
if message == nil {
return ""
}
for _, part := range message.Parts {
if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" {
if len(tp.Text) > sessionNameMaxLength {
return tp.Text[:sessionNameMaxLength] + "..."
if utf8.RuneCountInString(tp.Text) > sessionNameMaxLength {
return string([]rune(tp.Text)[:sessionNameMaxLength]) + "..."
}
return tp.Text
Comment on lines +437 to 440
}
Expand Down
62 changes: 62 additions & 0 deletions go/adk/pkg/a2a/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package a2a

import (
"testing"
"unicode/utf8"

a2atype "github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2asrv"
Expand Down Expand Up @@ -65,3 +66,64 @@ func TestNewAgentStatusEvent_MessageCarriesIDs(t *testing.T) {
t.Errorf("event TaskID = %q, want %q", ev.TaskID, a2atype.TaskID("task-xyz"))
}
}

// TestExtractSessionName verifies session names are truncated on rune boundaries.
// A byte-wise cut can split a multi-byte UTF-8 rune and produce an invalid-UTF-8
// name, which the Postgres session store rejects on session create. The result
// must always be valid UTF-8, and pure-ASCII behavior must be unchanged.
func TestExtractSessionName(t *testing.T) {
textMsg := func(s string) *a2atype.Message {
return &a2atype.Message{Parts: []a2atype.Part{a2atype.TextPart{Text: s}}}
}

tests := []struct {
name string
msg *a2atype.Message
want string
}{
{name: "nil message", msg: nil, want: ""},
{name: "no parts", msg: &a2atype.Message{}, want: ""},
{name: "short ascii", msg: textMsg("hello"), want: "hello"},
{
name: "ascii at limit is not truncated",
msg: textMsg("01234567890123456789"), // exactly 20 runes
want: "01234567890123456789",
},
{
name: "long ascii is truncated with ellipsis",
msg: textMsg("012345678901234567890"), // 21 runes
want: "01234567890123456789...",
},
{
// 7 CJK runes = 21 bytes: byte-slicing at 20 splits the 7th rune.
name: "multibyte over byte limit stays valid utf8",
msg: textMsg("こんにちは世界"),
want: "こんにちは世界",
},
{
name: "long multibyte truncated on rune boundary",
msg: textMsg("あいうえおかきくけこさしすせそたちつてとな"), // 21 runes
want: "あいうえおかきくけこさしすせそたちつてと...",
},
{
name: "skips empty text part and uses first non-empty",
msg: &a2atype.Message{Parts: []a2atype.Part{
a2atype.TextPart{Text: ""},
a2atype.TextPart{Text: "hi"},
}},
want: "hi",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractSessionName(tt.msg)
if got != tt.want {
t.Errorf("extractSessionName() = %q, want %q", got, tt.want)
}
if !utf8.ValidString(got) {
t.Errorf("extractSessionName() returned invalid UTF-8: %q", got)
}
})
}
}
Loading