Skip to content

Commit b2d6a18

Browse files
authored
fix(coderd): truncate task prompt to 160 characters in notifications (#20147) (#20153)
Truncates the task prompt used in notifications to a maximum of 160 characters. The length of 160 characters was chosen arbitrarily. (cherry picked from commit ffcb7a1)
1 parent c0cd32c commit b2d6a18

File tree

4 files changed

+123
-13
lines changed

4 files changed

+123
-13
lines changed

coderd/aitasks_test.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9+
"strings"
910
"testing"
1011
"time"
12+
"unicode/utf8"
1113

1214
"github.com/google/uuid"
1315
"github.com/stretchr/testify/assert"
@@ -977,6 +979,7 @@ func TestTasksNotification(t *testing.T) {
977979
isAITask bool
978980
isNotificationSent bool
979981
notificationTemplate uuid.UUID
982+
taskPrompt string
980983
}{
981984
// Should not send a notification when the agent app is not an AI task.
982985
{
@@ -985,6 +988,7 @@ func TestTasksNotification(t *testing.T) {
985988
newAppStatus: codersdk.WorkspaceAppStatusStateWorking,
986989
isAITask: false,
987990
isNotificationSent: false,
991+
taskPrompt: "NoAITask",
988992
},
989993
// Should not send a notification when the new app status is neither 'Working' nor 'Idle'.
990994
{
@@ -993,6 +997,7 @@ func TestTasksNotification(t *testing.T) {
993997
newAppStatus: codersdk.WorkspaceAppStatusStateComplete,
994998
isAITask: true,
995999
isNotificationSent: false,
1000+
taskPrompt: "NonNotifiedState",
9961001
},
9971002
// Should not send a notification when the new app status equals the latest status (Working).
9981003
{
@@ -1001,6 +1006,7 @@ func TestTasksNotification(t *testing.T) {
10011006
newAppStatus: codersdk.WorkspaceAppStatusStateWorking,
10021007
isAITask: true,
10031008
isNotificationSent: false,
1009+
taskPrompt: "NonNotifiedTransition",
10041010
},
10051011
// Should send TemplateTaskWorking when the AI task transitions to 'Working'.
10061012
{
@@ -1010,6 +1016,7 @@ func TestTasksNotification(t *testing.T) {
10101016
isAITask: true,
10111017
isNotificationSent: true,
10121018
notificationTemplate: notifications.TemplateTaskWorking,
1019+
taskPrompt: "TemplateTaskWorking",
10131020
},
10141021
// Should send TemplateTaskWorking when the AI task transitions to 'Working' from 'Idle'.
10151022
{
@@ -1022,6 +1029,7 @@ func TestTasksNotification(t *testing.T) {
10221029
isAITask: true,
10231030
isNotificationSent: true,
10241031
notificationTemplate: notifications.TemplateTaskWorking,
1032+
taskPrompt: "TemplateTaskWorkingFromIdle",
10251033
},
10261034
// Should send TemplateTaskIdle when the AI task transitions to 'Idle'.
10271035
{
@@ -1031,6 +1039,17 @@ func TestTasksNotification(t *testing.T) {
10311039
isAITask: true,
10321040
isNotificationSent: true,
10331041
notificationTemplate: notifications.TemplateTaskIdle,
1042+
taskPrompt: "TemplateTaskIdle",
1043+
},
1044+
// Long task prompts should be truncated to 160 characters.
1045+
{
1046+
name: "LongTaskPrompt",
1047+
latestAppStatuses: []codersdk.WorkspaceAppStatusState{codersdk.WorkspaceAppStatusStateWorking},
1048+
newAppStatus: codersdk.WorkspaceAppStatusStateIdle,
1049+
isAITask: true,
1050+
isNotificationSent: true,
1051+
notificationTemplate: notifications.TemplateTaskIdle,
1052+
taskPrompt: "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
10341053
},
10351054
} {
10361055
t.Run(tc.name, func(t *testing.T) {
@@ -1067,7 +1086,7 @@ func TestTasksNotification(t *testing.T) {
10671086
}).Seed(workspaceBuildSeed).Params(database.WorkspaceBuildParameter{
10681087
WorkspaceBuildID: workspaceBuildID,
10691088
Name: codersdk.AITaskPromptParameterName,
1070-
Value: "task prompt",
1089+
Value: tc.taskPrompt,
10711090
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
10721091
agent[0].Apps = []*proto.App{{
10731092
Id: workspaceAgentAppID.String(),
@@ -1115,7 +1134,13 @@ func TestTasksNotification(t *testing.T) {
11151134
require.Len(t, sent, 1)
11161135
require.Equal(t, memberUser.ID, sent[0].UserID)
11171136
require.Len(t, sent[0].Labels, 2)
1118-
require.Equal(t, "task prompt", sent[0].Labels["task"])
1137+
// NOTE: len(string) is the number of bytes in the string, not the number of runes.
1138+
require.LessOrEqual(t, utf8.RuneCountInString(sent[0].Labels["task"]), 160)
1139+
if len(tc.taskPrompt) > 160 {
1140+
require.Contains(t, tc.taskPrompt, strings.TrimSuffix(sent[0].Labels["task"], "…"))
1141+
} else {
1142+
require.Equal(t, tc.taskPrompt, sent[0].Labels["task"])
1143+
}
11191144
require.Equal(t, workspace.Name, sent[0].Labels["workspace"])
11201145
} else {
11211146
// Then: No notification is sent

coderd/util/strings/strings.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,64 @@ func JoinWithConjunction(s []string) string {
2323
)
2424
}
2525

26-
// Truncate returns the first n characters of s.
27-
func Truncate(s string, n int) string {
26+
type TruncateOption int
27+
28+
func (o TruncateOption) String() string {
29+
switch o {
30+
case TruncateWithEllipsis:
31+
return "TruncateWithEllipsis"
32+
case TruncateWithFullWords:
33+
return "TruncateWithFullWords"
34+
default:
35+
return fmt.Sprintf("TruncateOption(%d)", o)
36+
}
37+
}
38+
39+
const (
40+
// TruncateWithEllipsis adds a Unicode ellipsis character to the end of the string.
41+
TruncateWithEllipsis TruncateOption = 1 << 0
42+
// TruncateWithFullWords ensures that words are not split in the middle.
43+
// As a special case, if there is no word boundary, the string is truncated.
44+
TruncateWithFullWords TruncateOption = 1 << 1
45+
)
46+
47+
// Truncate truncates s to n characters.
48+
// Additional behaviors can be specified using TruncateOptions.
49+
func Truncate(s string, n int, opts ...TruncateOption) string {
50+
var options TruncateOption
51+
for _, opt := range opts {
52+
options |= opt
53+
}
2854
if n < 1 {
2955
return ""
3056
}
3157
if len(s) <= n {
3258
return s
3359
}
34-
return s[:n]
60+
61+
maxLen := n
62+
if options&TruncateWithEllipsis != 0 {
63+
maxLen--
64+
}
65+
var sb strings.Builder
66+
// If we need to truncate to full words, find the last word boundary before n.
67+
if options&TruncateWithFullWords != 0 {
68+
lastWordBoundary := strings.LastIndexFunc(s[:maxLen], unicode.IsSpace)
69+
if lastWordBoundary < 0 {
70+
// We cannot find a word boundary. At this point, we'll truncate the string.
71+
// It's better than nothing.
72+
_, _ = sb.WriteString(s[:maxLen])
73+
} else { // lastWordBoundary <= maxLen
74+
_, _ = sb.WriteString(s[:lastWordBoundary])
75+
}
76+
} else {
77+
_, _ = sb.WriteString(s[:maxLen])
78+
}
79+
80+
if options&TruncateWithEllipsis != 0 {
81+
_, _ = sb.WriteString("…")
82+
}
83+
return sb.String()
3584
}
3685

3786
var bmPolicy = bluemonday.StrictPolicy()

coderd/util/strings/strings_test.go

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package strings_test
22

33
import (
4+
"fmt"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -23,17 +24,47 @@ func TestTruncate(t *testing.T) {
2324
s string
2425
n int
2526
expected string
27+
options []strings.TruncateOption
2628
}{
27-
{"foo", 4, "foo"},
28-
{"foo", 3, "foo"},
29-
{"foo", 2, "fo"},
30-
{"foo", 1, "f"},
31-
{"foo", 0, ""},
32-
{"foo", -1, ""},
29+
{"foo", 4, "foo", nil},
30+
{"foo", 3, "foo", nil},
31+
{"foo", 2, "fo", nil},
32+
{"foo", 1, "f", nil},
33+
{"foo", 0, "", nil},
34+
{"foo", -1, "", nil},
35+
{"foo bar", 7, "foo bar", []strings.TruncateOption{strings.TruncateWithEllipsis}},
36+
{"foo bar", 6, "foo b…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
37+
{"foo bar", 5, "foo …", []strings.TruncateOption{strings.TruncateWithEllipsis}},
38+
{"foo bar", 4, "foo…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
39+
{"foo bar", 3, "fo…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
40+
{"foo bar", 2, "f…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
41+
{"foo bar", 1, "…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
42+
{"foo bar", 0, "", []strings.TruncateOption{strings.TruncateWithEllipsis}},
43+
{"foo bar", 7, "foo bar", []strings.TruncateOption{strings.TruncateWithFullWords}},
44+
{"foo bar", 6, "foo", []strings.TruncateOption{strings.TruncateWithFullWords}},
45+
{"foo bar", 5, "foo", []strings.TruncateOption{strings.TruncateWithFullWords}},
46+
{"foo bar", 4, "foo", []strings.TruncateOption{strings.TruncateWithFullWords}},
47+
{"foo bar", 3, "foo", []strings.TruncateOption{strings.TruncateWithFullWords}},
48+
{"foo bar", 2, "fo", []strings.TruncateOption{strings.TruncateWithFullWords}},
49+
{"foo bar", 1, "f", []strings.TruncateOption{strings.TruncateWithFullWords}},
50+
{"foo bar", 0, "", []strings.TruncateOption{strings.TruncateWithFullWords}},
51+
{"foo bar", 7, "foo bar", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
52+
{"foo bar", 6, "foo…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
53+
{"foo bar", 5, "foo…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
54+
{"foo bar", 4, "foo…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
55+
{"foo bar", 3, "fo…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
56+
{"foo bar", 2, "f…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
57+
{"foo bar", 1, "…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
58+
{"foo bar", 0, "", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
59+
{"This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 160, "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
3360
} {
34-
t.Run(tt.expected, func(t *testing.T) {
61+
tName := fmt.Sprintf("%s_%d", tt.s, tt.n)
62+
for _, opt := range tt.options {
63+
tName += fmt.Sprintf("_%v", opt)
64+
}
65+
t.Run(tName, func(t *testing.T) {
3566
t.Parallel()
36-
actual := strings.Truncate(tt.s, tt.n)
67+
actual := strings.Truncate(tt.s, tt.n, tt.options...)
3768
require.Equal(t, tt.expected, actual)
3869
})
3970
}

coderd/workspaceagents.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,11 @@ func (api *API) enqueueAITaskStateNotification(
484484
}
485485
}
486486

487+
// As task prompt may be particularly long, truncate it to 160 characters for notifications.
488+
if len(taskName) > 160 {
489+
taskName = strutil.Truncate(taskName, 160, strutil.TruncateWithEllipsis, strutil.TruncateWithFullWords)
490+
}
491+
487492
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
488493
// nolint:gocritic // Need notifier actor to enqueue notifications
489494
dbauthz.AsNotifier(ctx),

0 commit comments

Comments
 (0)