Files
coder/coderd/exp_chats_hooks_test.go
T
Michael Suchacz df1c0f9710 feat: show what a chat lifecycle hook changed (#27655)
## Stack Context

Follow-up fixes from live UAT of the merged chat lifecycle hooks stack
(#27430). Two PRs:

1. **This PR**: make hook effects visible and correctly attributed in
the transcript.
2. [`mike/chat-hooks-uat/dispatch-capacity`]: reserve dispatch capacity
so an admission burst can't fail running turns.

## Why?

UAT found three ways the transcript misrepresented what a lifecycle hook
did. All three are user-visible and share the same surface
(`chathooks/effects.go`, `codersdk.ChatMessagePart`, the conversation
timeline), so they're reviewed together.

**A prompt `input_override` silently discarded attachments.**
`ComposeUserPromptContent` replaced the entire submitted part list with
one text part, dropping `file` and `file-reference` parts along with
their `chat_file_links`. The user saw their attachments vanish with no
explanation. The override now replaces submitted *text* parts only and
preserves non-text parts in order. A consumer that wants to block
attachments uses `deny`, which is the documented mechanism for refusing
a submission.

**Every user-visible `system` row was labelled "Lifecycle hook".** The
timeline keyed the notice off `role === "system"`. That was correct only
by accident, because the hook `user_message` was the sole client-visible
system row. The backend now emits the notice as a typed `hook-notice`
part and the timeline renders on that, so a future system row can't be
mislabelled as a policy notice.

**Nothing marked a tool call the hook had rewritten.** A consumer could
replace tool input via `input_override` and the transcript showed the
rewritten input as if the model had produced it. `ChatMessagePart` gains
`hook_rewritten`, set from `preflight.Overrides` on the same path that
already carries `ToolCallCreatedAt`, and the tool row renders a
"Modified by policy" badge.

`ToolCall.PolicyProvider` renders the badge itself, at four wrap sites:
the `Tool` dispatch wrapper, the `ReadFilesTool` aggregate and its
per-file rows, and `ReadFileTimelineBlock` (grouped and single
`read_file` rows bypass `Tool`). Renderer props do not include the flag;
descendants consume it through the provider context.

The badge is emitted by the provider rather than by the shared header
because several renderer branches return early without one, including
the auth-required `execute` card, a completed `ask_user_question`, and
an empty question payload. Those branches would drop the attribution
with no type or runtime error, and the gap is not greppable: every
renderer file contains a header somewhere, only individual branches do
not. Emitting at the provider removes the possibility instead of
enumerating the cases.

A rewritten call is wrapped in a group labelled by its badge, so one
rewritten file inside a merged read is attributed on its own rather than
inheriting the group's badge. `HeaderButton` still appends the policy
wording to an explicit `ariaLabel`, since an explicit `aria-label`
replaces the name computed from descendants.

Provider-executed calls are excluded from attribution. Hooks never see
them, and duplicate tool-call ID rejection deliberately skips them, so a
reused ID would otherwise mark a provider-executed call as
policy-rewritten.

## Testing

Go: `coderd/x/chatd/...`, `coderd/x/agenthooks/...`, `codersdk/...`, and
`coderd -run 'Hook|Chat'`. Frontend: `tsc` plus every `AgentsPage`
story; the only failures are `MCP Tool Completed` and `Scroll To Bottom
Button Works With Inverse Scroll`, both of which fail on trunk.

A registry-wide story asserts every registered renderer shows the badge,
verified against three inverted toggles: removing the badge, hiding it
with `display:none`, and skipping the provider for one renderer (which
names that renderer). Storybook also covers the rewritten subagent
spawn, a completed empty question payload, a non-hook system message,
and a failed `read_file` guarding the accessible name.

> Mux opened this PR on Mike's behalf.
2026-08-03 18:27:39 +02:00

623 lines
23 KiB
Go

package coderd_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/x/agenthooks"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestPostChatsInitialPromptHookErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
statusCode int
response string
wantStatus int
wantMessage string
wantKind codersdk.ChatErrorKind
}{
{
name: "deny",
statusCode: http.StatusOK,
response: `{"permission":{"decision":"deny"},"user_message":"blocked by policy"}`,
wantStatus: http.StatusForbidden,
wantMessage: "blocked by policy",
wantKind: codersdk.ChatErrorKindHookDenied,
},
{
name: "dispatch failure",
statusCode: http.StatusInternalServerError,
wantStatus: http.StatusBadGateway,
wantKind: codersdk.ChatErrorKindHookDispatchFailed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
requests := make(chan agenthooks.Request, 2)
consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request agenthooks.Request
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
requests <- request
w.WriteHeader(test.statusCode)
if test.response != "" {
_, err := w.Write([]byte(test.response))
require.NoError(t, err)
}
}))
t.Cleanup(consumer.Close)
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.ChatWorkerDisabled = true
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String("test-hook-secret-32-bytes-minimum!!")
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(time.Second)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
user := coderdtest.CreateFirstUser(t, client.Client)
model := createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1")
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats", codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "blocked prompt",
}},
})
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, test.wantStatus, res.StatusCode)
// Both outcomes share this wire shape, differing only in kind.
var response struct {
codersdk.Response
Kind codersdk.ChatErrorKind `json:"kind"`
}
require.NoError(t, json.NewDecoder(res.Body).Decode(&response))
require.Equal(t, test.wantKind, response.Kind)
if test.wantMessage != "" {
require.Equal(t, test.wantMessage, response.Message)
}
request := testutil.RequireReceive(ctx, t, requests)
require.Equal(t, agenthooks.EventUserPromptSubmit, request.Type)
require.NotEqual(t, uuid.Nil, request.Meta.ChatID)
_, err = db.GetChatByID(dbauthz.AsSystemRestricted(ctx), request.Meta.ChatID)
require.ErrorIs(t, err, sql.ErrNoRows)
})
}
}
func TestChatLifecycleHooksExperimentDisabled(t *testing.T) {
t.Parallel()
var hookRequests atomic.Int32
consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hookRequests.Add(1)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(consumer.Close)
client, _ := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.ChatWorkerDisabled = true
opts.DeploymentValues.Experiments = serpent.StringArray{
string(codersdk.ExperimentChatAdvisor),
string(codersdk.ExperimentChatVirtualDesktop),
}
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String("test-hook-secret-32-bytes-minimum!!")
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(time.Second)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
user := coderdtest.CreateFirstUser(t, client.Client)
model := createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1")
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "prompt with hooks disabled",
}},
})
require.NoError(t, err)
require.Zero(t, hookRequests.Load())
}
func TestChatPromptHookContextHiddenFromAPI(t *testing.T) {
t.Parallel()
const secret = "test-hook-secret-32-bytes-minimum!!"
consumer := newHookConsumer(t, secret, agenthooks.Hooks{
UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) {
return agenthooks.Response{
ModelContext: "prompt context",
UserMessage: "prompt notice",
}, nil
},
})
t.Cleanup(consumer.Close)
client, _ := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.ChatWorkerDisabled = true
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String(secret)
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(time.Second)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
user := coderdtest.CreateFirstUser(t, client.Client)
model := createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1")
ctx := testutil.Context(t, testutil.WaitLong)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "initial prompt",
}},
})
require.NoError(t, err)
messages, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.Len(t, messages.Messages, 1)
require.Equal(t, []codersdk.ChatMessagePart{
codersdk.ChatMessageText("initial prompt"),
{Type: codersdk.ChatMessagePartTypeHookNotice, Text: "prompt notice"},
}, messages.Messages[0].Content)
}
func TestChatLifecycleHooksWorkedExample(t *testing.T) {
t.Parallel()
const (
secret = "test-hook-secret-32-bytes-minimum!!"
deniedToolCallID = "call_denied"
allowedToolCallID = "call_allowed"
)
ctx := testutil.Context(t, testutil.WaitLong)
var modelCalls atomic.Int32
secondModelRequest := make(chan []byte, 1)
thirdModelRequest := make(chan []byte, 1)
modelURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("Lifecycle hooks")
}
switch modelCalls.Add(1) {
case 1:
chunk := chattest.OpenAIToolCallChunk("read_secret", `{"path":"/tmp/secret"}`)
chunk.Choices[0].ToolCalls[0].ID = deniedToolCallID
return chattest.OpenAIStreamingResponse(chunk)
case 2:
secondModelRequest <- bytes.Clone(req.RawBody)
chunk := chattest.OpenAIToolCallChunk("search_docs", `{"query":"customer secret"}`)
chunk.Choices[0].ToolCalls[0].ID = allowedToolCallID
return chattest.OpenAIStreamingResponse(chunk)
case 3:
thirdModelRequest <- bytes.Clone(req.RawBody)
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...)
default:
return chattest.OpenAIErrorResponse(http.StatusInternalServerError, "unexpected_call", "unexpected model call")
}
})
hookEvents := make(chan agenthooks.EventType, 16)
recordHook := func(event agenthooks.EventType) {
hookEvents <- event
}
consumer := newHookConsumer(t, secret, agenthooks.Hooks{
SessionStart: func(context.Context, agenthooks.Meta, agenthooks.SessionStartData) (agenthooks.Response, error) {
recordHook(agenthooks.EventSessionStart)
return agenthooks.Response{}, nil
},
UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) {
recordHook(agenthooks.EventUserPromptSubmit)
return agenthooks.Response{}, nil
},
PreToolUse: func(_ context.Context, _ agenthooks.Meta, tool agenthooks.PreToolUseData) (agenthooks.Response, error) {
recordHook(agenthooks.EventPreToolUse)
switch tool.ToolUseID {
case deniedToolCallID:
return agenthooks.Response{Permission: &agenthooks.Permission{
Decision: agenthooks.PermissionDeny,
Reason: "secret reads are blocked",
}}, nil
case allowedToolCallID:
return agenthooks.Response{Permission: &agenthooks.Permission{
Decision: agenthooks.PermissionAllow,
InputOverride: json.RawMessage(`{"query":"public documentation"}`),
}}, nil
default:
return agenthooks.Response{}, nil
}
},
PostToolUse: func(context.Context, agenthooks.Meta, agenthooks.PostToolUseData) (agenthooks.Response, error) {
recordHook(agenthooks.EventPostToolUse)
return agenthooks.Response{
ModelContext: "The approved search result is safe to use.",
UserMessage: "Search result approved by policy.",
}, nil
},
Stop: func(context.Context, agenthooks.Meta, agenthooks.StopData) (agenthooks.Response, error) {
recordHook(agenthooks.EventStop)
return agenthooks.Response{}, nil
},
})
t.Cleanup(consumer.Close)
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String(secret)
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(time.Second)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
user := coderdtest.CreateFirstUser(t, client.Client)
model := createChatModelConfigWithBaseURL(t, client, modelURL)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "Find the deployment documentation.",
}},
UnsafeDynamicTools: []codersdk.DynamicTool{
{
Name: "read_secret",
Description: "Read a secret file.",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
{
Name: "search_docs",
Description: "Search public documentation.",
InputSchema: json.RawMessage(`{"type":"object"}`),
},
},
})
require.NoError(t, err)
var stored database.Chat
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
stored, err = db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
return err == nil && stored.Status == database.ChatStatusRequiresAction
}, testutil.IntervalFast)
require.Equal(t, int32(2), modelCalls.Load())
require.Contains(t, string(testutil.RequireReceive(ctx, t, secondModelRequest)), "Reason: secret reads are blocked.")
messages, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var allowedCall, deniedCall *codersdk.ChatMessagePart
for _, message := range messages.Messages {
for i := range message.Content {
part := &message.Content[i]
if part.Type != codersdk.ChatMessagePartTypeToolCall {
continue
}
switch part.ToolCallID {
case allowedToolCallID:
allowedCall = part
case deniedToolCallID:
deniedCall = part
}
}
}
require.NotNil(t, allowedCall)
require.JSONEq(t, `{"query":"public documentation"}`, string(allowedCall.Args))
require.True(t, allowedCall.HookRewritten)
require.NotNil(t, deniedCall)
require.False(t, deniedCall.HookRewritten)
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: allowedToolCallID,
Output: json.RawMessage(`{"matches":["agent hooks"]}`),
}},
})
require.NoError(t, err)
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
stored, err = db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
return err == nil && stored.Status == database.ChatStatusWaiting
}, testutil.IntervalFast)
require.Contains(t, string(testutil.RequireReceive(ctx, t, thirdModelRequest)), "The approved search result is safe to use.")
require.Equal(t, int32(3), modelCalls.Load())
messages, err = client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var foundPostToolNotice bool
for _, message := range messages.Messages {
if message.Role != codersdk.ChatMessageRoleSystem {
continue
}
for _, part := range message.Content {
if part.Type == codersdk.ChatMessagePartTypeHookNotice && part.Text == "Search result approved by policy." {
foundPostToolNotice = true
}
}
}
require.True(t, foundPostToolNotice)
var seenEvents []agenthooks.EventType
for {
event := testutil.RequireReceive(ctx, t, hookEvents)
seenEvents = append(seenEvents, event)
if event == agenthooks.EventStop {
break
}
}
require.Contains(t, seenEvents, agenthooks.EventUserPromptSubmit)
require.Contains(t, seenEvents, agenthooks.EventSessionStart)
var preToolUseEvents int
for _, event := range seenEvents {
if event == agenthooks.EventPreToolUse {
preToolUseEvents++
}
}
require.GreaterOrEqual(t, preToolUseEvents, 2)
require.Contains(t, seenEvents, agenthooks.EventPostToolUse)
}
func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) {
t.Parallel()
const secret = "test-hook-secret-32-bytes-minimum!!"
ctx := testutil.Context(t, testutil.WaitLong)
modelURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...)
})
consumer := newHookConsumer(t, secret, agenthooks.Hooks{
UserPromptSubmit: func(_ context.Context, _ agenthooks.Meta, data agenthooks.UserPromptSubmitData) (agenthooks.Response, error) {
if strings.Contains(data.Prompt, "REDACTME") {
return agenthooks.Response{Permission: &agenthooks.Permission{
Decision: agenthooks.PermissionAllow,
InputOverride: json.RawMessage(`{"prompt":"redacted"}`),
}}, nil
}
return agenthooks.Response{}, nil
},
})
t.Cleanup(consumer.Close)
client, api := newChatClientWithAPI(t, func(opts *coderdtest.Options) {
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String(secret)
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(time.Second)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
user := coderdtest.CreateFirstUser(t, client.Client)
model := createChatModelConfigWithBaseURL(t, client, modelURL)
uploadFile := func(name string) uuid.UUID {
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 16)...)
resp, err := client.UploadChatFile(ctx, user.OrganizationID, "image/png", name, bytes.NewReader(pngData))
require.NoError(t, err)
return resp.ID
}
createFile := uploadFile("create.png")
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "REDACTME create"},
{Type: codersdk.ChatInputPartTypeFile, FileID: createFile},
},
})
require.NoError(t, err)
created, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, created.Files, 1, "an overridden create must keep linking its attachments")
require.Equal(t, createFile, created.Files[0].ID)
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
keptFile := uploadFile("kept.png")
sendResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "keep this"},
{Type: codersdk.ChatInputPartTypeFile, FileID: keptFile},
},
})
require.NoError(t, err)
require.False(t, sendResp.Queued)
afterSend, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, afterSend.Files, 2)
require.ElementsMatch(t, []uuid.UUID{createFile, keptFile}, []uuid.UUID{afterSend.Files[0].ID, afterSend.Files[1].ID})
coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
overriddenFile := uploadFile("overridden.png")
sendResp, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeFileReference, FileName: "main.go", StartLine: 1, EndLine: 3, Content: "package main"},
{Type: codersdk.ChatInputPartTypeText, Text: "REDACTME send"},
{Type: codersdk.ChatInputPartTypeFile, FileID: overriddenFile},
},
})
require.NoError(t, err)
require.False(t, sendResp.Queued)
afterOverride, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, afterOverride.Files, 3, "an overridden send must keep linking its attachments")
require.ElementsMatch(t, []uuid.UUID{createFile, keptFile, overriddenFile}, []uuid.UUID{
afterOverride.Files[0].ID,
afterOverride.Files[1].ID,
afterOverride.Files[2].ID,
})
require.NotNil(t, sendResp.Message)
require.Equal(t, codersdk.ChatMessageRoleUser, sendResp.Message.Role)
require.Equal(t, []codersdk.ChatMessagePart{
codersdk.ChatMessageFileReference("main.go", 1, 3, "package main"),
codersdk.ChatMessageText("redacted"),
codersdk.ChatMessageFile(overriddenFile, "image/png", "overridden.png"),
}, sendResp.Message.Content)
}
func TestChatHookNoticeMessagesInResponses(t *testing.T) {
t.Parallel()
const secret = "test-hook-secret-32-bytes-minimum!!"
ctx := testutil.Context(t, testutil.WaitLong)
modelURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...)
})
consumer := newHookConsumer(t, secret, agenthooks.Hooks{
SessionStart: func(context.Context, agenthooks.Meta, agenthooks.SessionStartData) (agenthooks.Response, error) {
return agenthooks.Response{UserMessage: "session notice"}, nil
},
UserPromptSubmit: func(_ context.Context, _ agenthooks.Meta, data agenthooks.UserPromptSubmitData) (agenthooks.Response, error) {
response := agenthooks.Response{UserMessage: "prompt notice"}
if data.Prompt == "edited prompt" {
response.ModelContext = "prompt context"
}
return response, nil
},
})
t.Cleanup(consumer.Close)
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
require.NoError(t, opts.DeploymentValues.AI.Chat.HookURL.Set(consumer.URL))
opts.DeploymentValues.AI.Chat.HookSecret = serpent.String(secret)
opts.DeploymentValues.AI.Chat.HookTimeout = serpent.Duration(time.Second)
opts.DeploymentValues.AI.Chat.HookEnabled = serpent.Bool(true)
})
user := coderdtest.CreateFirstUser(t, client.Client)
model := createChatModelConfigWithBaseURL(t, client, modelURL)
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
ModelConfigID: &model.ID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "initial prompt",
}},
})
require.NoError(t, err)
waitForWaiting := func() {
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
stored, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
return err == nil && stored.Status == database.ChatStatusWaiting
}, testutil.IntervalFast)
}
waitForWaiting()
assertPromptContent := func(message codersdk.ChatMessage, prompt string) {
t.Helper()
require.Equal(t, codersdk.ChatMessageRoleUser, message.Role)
require.Equal(t, []codersdk.ChatMessagePart{
codersdk.ChatMessageText(prompt),
{Type: codersdk.ChatMessagePartTypeHookNotice, Text: "prompt notice"},
}, message.Content)
}
initialMessages, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var initialPrompt *codersdk.ChatMessage
for i := range initialMessages.Messages {
message := &initialMessages.Messages[i]
if message.Role == codersdk.ChatMessageRoleUser && len(message.Content) > 0 && message.Content[0].Text == "initial prompt" {
initialPrompt = message
break
}
}
require.NotNil(t, initialPrompt)
assertPromptContent(*initialPrompt, "initial prompt")
sent, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "second prompt",
}},
})
require.NoError(t, err)
require.False(t, sent.Queued, "idle chat must insert directly")
require.NotNil(t, sent.Message)
require.NotEmpty(t, sent.Messages, "send response must carry the inserted batch")
last := sent.Messages[len(sent.Messages)-1]
require.Equal(t, sent.Message.ID, last.ID, "user message must be last in the batch")
assertPromptContent(last, "second prompt")
assertPromptContent(*sent.Message, "second prompt")
waitForWaiting()
edited, err := client.EditChatMessage(ctx, chat.ID, sent.Message.ID, codersdk.EditChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "edited prompt",
}},
})
require.NoError(t, err)
require.NotZero(t, edited.Message.ID, "successful edits must return the replacement message")
require.NotEmpty(t, edited.Messages, "edit response must carry the inserted batch")
var editedBatchMessage *codersdk.ChatMessage
for i := range edited.Messages {
if edited.Messages[i].ID == edited.Message.ID {
editedBatchMessage = &edited.Messages[i]
break
}
}
require.NotNil(t, editedBatchMessage)
assertPromptContent(*editedBatchMessage, "edited prompt")
assertPromptContent(edited.Message, "edited prompt")
allMessages, err := client.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
var sessionNoticeFound bool
for _, message := range allMessages.Messages {
if message.Role != codersdk.ChatMessageRoleSystem {
continue
}
for _, part := range message.Content {
if part.Type == codersdk.ChatMessagePartTypeHookNotice && part.Text == "session notice" {
sessionNoticeFound = true
}
}
}
require.True(t, sessionNoticeFound)
}
// newHookConsumer serves hooks with its own URL as the configured audience,
// which is the value Coder signs when it dispatches there. The listener is
// allocated first because httptest.NewServer builds its handler before the
// server has a URL.
func newHookConsumer(t *testing.T, secret string, hooks agenthooks.Hooks) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(nil)
server.Config.Handler = agenthooks.NewHTTPHandler([]byte(secret), "http://"+server.Listener.Addr().String(), hooks)
server.Start()
return server
}