From 5bf8063315a1348fbe3b3636d10c134a8842b664 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:34:06 +0530 Subject: [PATCH] fix(go-adk): truncate A2A session name on rune boundaries extractSessionName derived the session name from the first text part of an inbound A2A message and capped it with a byte-wise slice (tp.Text[:sessionNameMaxLength]). Because len() and the slice operate on bytes, a first message longer than the 20-byte cap that begins with multi-byte UTF-8 (CJK, emoji, accented Latin) was cut in the middle of a rune, yielding an invalid-UTF-8 name. That value is persisted via CreateSession: Postgres rejects the invalid text and fails the turn, while SQLite stores mojibake. Count the cap in runes and slice on a rune boundary so the result is always valid UTF-8. Pure-ASCII behavior is unchanged. Adds a table-driven test covering ASCII, at-limit, and multi-byte truncation. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- go/adk/pkg/a2a/executor.go | 8 +++-- go/adk/pkg/a2a/executor_test.go | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 55ebfb2cb5..7b37b013e8 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -6,6 +6,7 @@ import ( "maps" "os" "strings" + "unicode/utf8" a2atype "github.com/a2aproject/a2a-go/a2a" "github.com/a2aproject/a2a-go/a2asrv" @@ -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 } diff --git a/go/adk/pkg/a2a/executor_test.go b/go/adk/pkg/a2a/executor_test.go index 91af7fe160..f91c8e9698 100644 --- a/go/adk/pkg/a2a/executor_test.go +++ b/go/adk/pkg/a2a/executor_test.go @@ -2,6 +2,7 @@ package a2a import ( "testing" + "unicode/utf8" a2atype "github.com/a2aproject/a2a-go/a2a" "github.com/a2aproject/a2a-go/a2asrv" @@ -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) + } + }) + } +}