From b04c74e7fe4a0f77d6661c3b7138fb9951a881da Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:33:30 +0000 Subject: [PATCH 01/86] fix(coderd/x/agenthooks/dispatch): reject userinfo URLs and unify the dispatch deadline --- coderd/x/agenthooks/dispatch/dispatcher.go | 20 ++++++++++++------- .../dispatch/dispatcher_internal_test.go | 1 + 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index 00113aff67b..3a75830b527 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -102,12 +102,15 @@ func validateHookURL(raw string) error { if err != nil { return xerrors.Errorf("parse hook URL: %w", err) } - // The raw URL is signed as the JWT audience, but HTTP clients never send - // fragments, so consumers reconstructing the audience from the request - // would reject every dispatch. + // The raw URL is signed as the JWT audience, but requests never carry + // fragments or userinfo, so consumers reconstructing the audience from + // the request would reject every dispatch. if parsed.Fragment != "" { return xerrors.New("chat hook URL must not contain a fragment") } + if parsed.User != nil { + return xerrors.New("chat hook URL must not contain userinfo") + } switch parsed.Scheme { case "https": if parsed.Hostname() == "" { @@ -182,6 +185,11 @@ func (d *Dispatcher) Dispatch(ctx context.Context, event Event) (agenthooks.Resp startedAt := time.Now() dispatchID := uuid.New() + // One deadline bounds capacity waiting and both post attempts so a + // saturated semaphore cannot extend the dispatch past the configured + // timeout. + ctx, cancel := context.WithTimeout(ctx, d.timeout) + defer cancel() wait := min(d.timeout, capacityWaitLimit) if wait < 0 { wait = 0 @@ -299,10 +307,8 @@ func (d *Dispatcher) post( body []byte, token string, ) (response agenthooks.Response, result Result, err error) { - // One deadline bounds both attempts so a retry cannot extend the - // dispatch past the configured timeout or the JWT lifetime. - ctx, cancel := context.WithTimeout(ctx, d.timeout) - defer cancel() + // The deadline set in Dispatch bounds both attempts so a retry cannot + // extend the dispatch past the configured timeout or the JWT lifetime. for attempt := range 2 { req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, d.hookURL, bytes.NewReader(body)) if reqErr != nil { diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index d6f5379b38e..344e8685362 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -89,6 +89,7 @@ func TestDispatcherRejectsCleartextURL(t *testing.T) { require.ErrorContains(t, validateHookURL("https:hooks.example.com"), "must include a host") require.ErrorContains(t, validateHookURL("http:///hooks"), "must include a host") require.ErrorContains(t, validateHookURL("https://hooks.example.com/coder#frag"), "must not contain a fragment") + require.ErrorContains(t, validateHookURL("https://user:pass@hooks.example.com/coder"), "must not contain userinfo") } func TestDispatcherDeny(t *testing.T) { From ee4b47b078316ecb68e595e6879429508dfce183 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:34:16 +0000 Subject: [PATCH 02/86] fix: require opt-in to trust forwarded headers in the hook handler --- codersdk/x/agenthooks/agenthooks_test.go | 19 +++++++++++-- codersdk/x/agenthooks/http.go | 35 +++++++++++++++++------- scripts/agenthooks-server/main.go | 10 +++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/codersdk/x/agenthooks/agenthooks_test.go b/codersdk/x/agenthooks/agenthooks_test.go index c6d0583306b..cc5aa5a1c9e 100644 --- a/codersdk/x/agenthooks/agenthooks_test.go +++ b/codersdk/x/agenthooks/agenthooks_test.go @@ -330,7 +330,7 @@ func TestHTTPHandlerAcceptsTrailingSlashAudience(t *testing.T) { func TestHTTPHandlerHonorsForwardedProto(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{}, agenthooks.WithTrustForwardedHeaders())) t.Cleanup(server.Close) httpsAudience := "https" + strings.TrimPrefix(server.URL, "http") response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { @@ -345,7 +345,7 @@ func TestHTTPHandlerHonorsForwardedProto(t *testing.T) { func TestHTTPHandlerHonorsForwardedHost(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{}, agenthooks.WithTrustForwardedHeaders())) t.Cleanup(server.Close) response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { claims.Audience = "https://hooks.example.com" @@ -357,6 +357,21 @@ func TestHTTPHandlerHonorsForwardedHost(t *testing.T) { require.Equal(t, http.StatusOK, response.StatusCode) } +func TestHTTPHandlerIgnoresForwardedHeadersByDefault(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + t.Cleanup(server.Close) + response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { + claims.Audience = "https://hooks.example.com" + }, func(r *http.Request) { + r.Header.Set("X-Forwarded-Proto", "https") + r.Header.Set("X-Forwarded-Host", "hooks.example.com") + }) + defer response.Body.Close() + require.Equal(t, http.StatusBadRequest, response.StatusCode) +} + func postEvent(t *testing.T, target string, eventType agenthooks.EventType, data any, updateRequest func(*agenthooks.Request), updateClaims func(*agenthooks.Claims), updateHTTPRequest ...func(*http.Request)) *http.Response { t.Helper() diff --git a/codersdk/x/agenthooks/http.go b/codersdk/x/agenthooks/http.go index 0c52ed92948..ae890319800 100644 --- a/codersdk/x/agenthooks/http.go +++ b/codersdk/x/agenthooks/http.go @@ -32,7 +32,8 @@ type Hooks struct { type HandlerOption func(*handlerOptions) type handlerOptions struct { - expectedIssuer string + expectedIssuer string + trustForwardedHeaders bool } // WithExpectedIssuer requires the verified iss claim to match issuer. @@ -44,6 +45,17 @@ func WithExpectedIssuer(issuer string) HandlerOption { } } +// WithTrustForwardedHeaders reconstructs the audience from +// X-Forwarded-Proto and X-Forwarded-Host. Enable it only behind a +// trusted proxy that strips client-supplied forwarding headers; +// otherwise a caller could spoof them to satisfy the audience check +// for a token signed for a different listener. +func WithTrustForwardedHeaders() HandlerOption { + return func(options *handlerOptions) { + options.trustForwardedHeaders = true + } +} + func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Handler { var options handlerOptions for _, opt := range opts { @@ -81,7 +93,7 @@ func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Hand http.Error(rw, "decode request body", http.StatusBadRequest) return } - if err := verifyBody(r, body, claims, request); err != nil { + if err := options.verifyBody(r, body, claims, request); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } @@ -98,12 +110,12 @@ func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Hand }) } -func verifyBody(r *http.Request, body []byte, claims Claims, request Request) error { +func (options handlerOptions) verifyBody(r *http.Request, body []byte, claims Claims, request Request) error { digest := sha256.Sum256(body) if claims.BodySHA256 != hex.EncodeToString(digest[:]) { return xerrors.New("request body does not match body_sha256 claim") } - if canonicalAudience(claims.Audience) != requestAudience(r) { + if canonicalAudience(claims.Audience) != options.requestAudience(r) { return xerrors.New("request URL does not match audience claim") } if request.Meta.SchemaVersion != SchemaVersion { @@ -125,22 +137,25 @@ func verifyBody(r *http.Request, body []byte, claims Claims, request Request) er return nil } -func requestAudience(r *http.Request) string { +func (options handlerOptions) requestAudience(r *http.Request) string { requestURL := *r.URL if requestURL.Scheme == "" { requestURL.Scheme = "http" if r.TLS != nil { requestURL.Scheme = "https" } - // Forwarded values reconstruct the signed audience when set by a trusted proxy. - if proto := forwardedProto(r); proto != "" { - requestURL.Scheme = proto + if options.trustForwardedHeaders { + if proto := forwardedProto(r); proto != "" { + requestURL.Scheme = proto + } } } if requestURL.Host == "" { requestURL.Host = r.Host - if host := forwardedHost(r); host != "" { - requestURL.Host = host + if options.trustForwardedHeaders { + if host := forwardedHost(r); host != "" { + requestURL.Host = host + } } } return canonicalAudience(requestURL.String()) diff --git a/scripts/agenthooks-server/main.go b/scripts/agenthooks-server/main.go index b8621b85ba5..8ae9a999f69 100644 --- a/scripts/agenthooks-server/main.go +++ b/scripts/agenthooks-server/main.go @@ -29,6 +29,7 @@ type config struct { tlsCert string tlsKey string logOnly bool + trustForwarded bool denyToolPattern string redactPrompt string } @@ -247,6 +248,9 @@ func run() error { if cfg.issuer != "" { handlerOpts = append(handlerOpts, agenthooks.WithExpectedIssuer(cfg.issuer)) } + if cfg.trustForwarded { + handlerOpts = append(handlerOpts, agenthooks.WithTrustForwardedHeaders()) + } handler := agenthooks.NewHTTPHandler([]byte(cfg.secret), consumerHooks, handlerOpts...) server := &http.Server{ Addr: cfg.listen, @@ -277,14 +281,20 @@ func parseFlags() (config, error) { if err != nil { return config{}, err } + trustForwarded, err := envBool("CODER_AGENTHOOKS_TRUST_FORWARDED_HEADERS", false) + if err != nil { + return config{}, err + } var cfg config cfg.logOnly = logOnly + cfg.trustForwarded = trustForwarded flag.StringVar(&cfg.listen, "listen", envOrDefault("CODER_AGENTHOOKS_LISTEN", "127.0.0.1:8081"), "Listen address (CODER_AGENTHOOKS_LISTEN)") flag.StringVar(&cfg.secret, "secret", os.Getenv("CODER_AGENTHOOKS_SECRET"), "Shared HS256 secret, required (CODER_AGENTHOOKS_SECRET)") flag.StringVar(&cfg.issuer, "issuer", os.Getenv("CODER_AGENTHOOKS_ISSUER"), "Expected iss claim, normally the Coder deployment ID (CODER_AGENTHOOKS_ISSUER)") flag.StringVar(&cfg.tlsCert, "tls-cert", os.Getenv("CODER_AGENTHOOKS_TLS_CERT"), "TLS certificate path (CODER_AGENTHOOKS_TLS_CERT)") flag.StringVar(&cfg.tlsKey, "tls-key", os.Getenv("CODER_AGENTHOOKS_TLS_KEY"), "TLS private key path (CODER_AGENTHOOKS_TLS_KEY)") flag.BoolVar(&cfg.logOnly, "log-only", cfg.logOnly, "Return an empty response for every event (CODER_AGENTHOOKS_LOG_ONLY)") + flag.BoolVar(&cfg.trustForwarded, "trust-forwarded-headers", cfg.trustForwarded, "Trust X-Forwarded-Proto/Host for the audience check; enable only behind a trusted proxy (CODER_AGENTHOOKS_TRUST_FORWARDED_HEADERS)") flag.StringVar(&cfg.denyToolPattern, "deny-tool-pattern", os.Getenv("CODER_AGENTHOOKS_DENY_TOOL_PATTERN"), "Example regexp for denied tool names (CODER_AGENTHOOKS_DENY_TOOL_PATTERN)") flag.StringVar(&cfg.redactPrompt, "redact-prompt-pattern", os.Getenv("CODER_AGENTHOOKS_REDACT_PROMPT_PATTERN"), "Example regexp to redact in prompts (CODER_AGENTHOOKS_REDACT_PROMPT_PATTERN)") flag.Parse() From 668a98a37fc7fa5b0e3bb98e31ac0a2c5ee1e6e8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:22:28 +0000 Subject: [PATCH 03/86] docs: correct the denied pre_tool_use note and document agenthooks exports --- codersdk/x/agenthooks/http.go | 5 +++++ codersdk/x/agenthooks/jwt.go | 5 +++++ codersdk/x/agenthooks/types.go | 18 +++++++++++++++++- site/src/api/typesGenerated.ts | 29 ++++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/codersdk/x/agenthooks/http.go b/codersdk/x/agenthooks/http.go index ae890319800..837ce9c1450 100644 --- a/codersdk/x/agenthooks/http.go +++ b/codersdk/x/agenthooks/http.go @@ -56,6 +56,11 @@ func WithTrustForwardedHeaders() HandlerOption { } } +// NewHTTPHandler serves hook requests by routing each event to the +// matching callback in hooks. It accepts POST only, verifies the bearer +// token with Verify, then binds it to the request by checking the body +// digest, audience, schema version, dispatch ID, event type, and chat ID +// against the claims. func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Handler { var options handlerOptions for _, opt := range opts { diff --git a/codersdk/x/agenthooks/jwt.go b/codersdk/x/agenthooks/jwt.go index 1e283b5a8ff..208612146a7 100644 --- a/codersdk/x/agenthooks/jwt.go +++ b/codersdk/x/agenthooks/jwt.go @@ -46,6 +46,11 @@ func SignClaims(secret []byte, claims Claims) (string, error) { return token, nil } +// Verify checks the HS256 bearer token in an Authorization header against +// the shared secret and returns its claims. It validates the signature, +// the JWT type header, the required claims, and the validity window. +// Binding a token to a specific request body and URL is the caller's job: +// see NewHTTPHandler. func Verify(authzHeader string, secret []byte) (Claims, error) { if len(secret) < MinSecretLen { return Claims{}, xerrors.Errorf("secret must be at least %d bytes", MinSecretLen) diff --git a/codersdk/x/agenthooks/types.go b/codersdk/x/agenthooks/types.go index 71c62d87ff1..52d8db99019 100644 --- a/codersdk/x/agenthooks/types.go +++ b/codersdk/x/agenthooks/types.go @@ -18,6 +18,7 @@ import ( // SchemaVersion is the current lifecycle hook request schema version. const SchemaVersion = 1 +// EventType names a lifecycle event carried by a hook request. type EventType string const ( @@ -37,6 +38,8 @@ type Request struct { Data json.RawMessage `json:"data"` } +// Meta carries the dispatch identity and chat reference sent with every +// event. type Meta struct { DispatchID uuid.UUID `json:"dispatch_id"` SchemaVersion int `json:"schema_version"` @@ -54,6 +57,8 @@ type ChatRef struct { RootChatID *uuid.UUID `json:"root_chat_id,omitempty"` } +// SessionStartData reports why a chat session started. Source is +// "startup", "resume", or "clear". type SessionStartData struct { Source string `json:"source"` } @@ -65,12 +70,16 @@ type UserPromptSubmitData struct { Parts json.RawMessage `json:"parts,omitempty"` } +// PreToolUseData describes the tool call Coder is about to run. +// ToolInput holds the tool's JSON arguments. type PreToolUseData struct { ToolUseID string `json:"tool_use_id"` ToolName string `json:"tool_name"` ToolInput json.RawMessage `json:"tool_input"` } +// PostToolUseData reports a finished tool call. ToolResponse carries the +// tool output, or ToolError the failure message. type PostToolUseData struct { ToolUseID string `json:"tool_use_id"` ToolName string `json:"tool_name"` @@ -78,16 +87,21 @@ type PostToolUseData struct { ToolError string `json:"tool_error,omitempty"` } +// PreCompactData is empty; Meta identifies the chat being compacted. type PreCompactData struct{} +// PostCompactData is empty; Meta identifies the compacted chat. type PostCompactData struct{} +// StopData is empty; Meta identifies the chat that stopped. type StopData struct{} // Response carries a consumer's decision and optional injected content. // Permission is honored for user_prompt_submit and pre_tool_use only. // user_prompt_submit folds injected content into the submitted message. -// A denied pre_tool_use folds ModelContext into its synthetic tool result. +// A denied pre_tool_use yields a synthetic tool result carrying only the +// policy text and any Reason; ModelContext persists separately as +// model-only transcript content that never reaches clients. type Response struct { Permission *Permission `json:"permission,omitempty"` ModelContext string `json:"model_context,omitempty"` @@ -101,6 +115,7 @@ type Permission struct { InputOverride json.RawMessage `json:"input_override,omitempty"` } +// PermissionDecision is a consumer's verdict on mutable hook input. type PermissionDecision string const ( @@ -121,6 +136,7 @@ type Claims struct { BodySHA256 string `json:"body_sha256"` } +// ChatID returns the chat ID encoded in the "coder:chat:" subject. func (c Claims) ChatID() (uuid.UUID, error) { value, ok := strings.CutPrefix(c.Subject, "coder:chat:") if !ok { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 60aa5730beb..5efe3dc5525 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1297,6 +1297,10 @@ export interface AgentHookHooks { export const AgentHookMaxRequestBodyBytes = 10485760; // 10 MiB // From agenthooks/types.go +/** + * Meta carries the dispatch identity and chat reference sent with every + * event. + */ export interface AgentHookMeta extends AgentHookChatRef { readonly dispatch_id: string; readonly schema_version: number; @@ -1329,9 +1333,16 @@ export const AgentHookPermissionDecisions: AgentHookPermissionDecision[] = [ ]; // From agenthooks/types.go +/** + * PostCompactData is empty; Meta identifies the compacted chat. + */ export interface AgentHookPostCompactData {} // From agenthooks/types.go +/** + * PostToolUseData reports a finished tool call. ToolResponse carries the + * tool output, or ToolError the failure message. + */ export interface AgentHookPostToolUseData { readonly tool_use_id: string; readonly tool_name: string; @@ -1340,9 +1351,16 @@ export interface AgentHookPostToolUseData { } // From agenthooks/types.go +/** + * PreCompactData is empty; Meta identifies the chat being compacted. + */ export interface AgentHookPreCompactData {} // From agenthooks/types.go +/** + * PreToolUseData describes the tool call Coder is about to run. + * ToolInput holds the tool's JSON arguments. + */ export interface AgentHookPreToolUseData { readonly tool_use_id: string; readonly tool_name: string; @@ -1364,7 +1382,9 @@ export interface AgentHookRequest { * Response carries a consumer's decision and optional injected content. * Permission is honored for user_prompt_submit and pre_tool_use only. * user_prompt_submit folds injected content into the submitted message. - * A denied pre_tool_use folds ModelContext into its synthetic tool result. + * A denied pre_tool_use yields a synthetic tool result carrying only the + * policy text and any Reason; ModelContext persists separately as + * model-only transcript content that never reaches clients. */ export interface AgentHookResponse { readonly permission?: AgentHookPermission; @@ -1379,11 +1399,18 @@ export interface AgentHookResponse { export const AgentHookSchemaVersion = 1; // From agenthooks/types.go +/** + * SessionStartData reports why a chat session started. Source is + * "startup", "resume", or "clear". + */ export interface AgentHookSessionStartData { readonly source: string; } // From agenthooks/types.go +/** + * StopData is empty; Meta identifies the chat that stopped. + */ export interface AgentHookStopData {} // From agenthooks/types.go From 60175a37fa478e0d3768e26012b0d0db9e543223 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:27:25 +0000 Subject: [PATCH 04/86] fix(coderd/x/agenthooks/dispatch): decide over-capacity with a dedicated timer --- coderd/x/agenthooks/dispatch/dispatcher.go | 15 ++++++++----- .../dispatch/dispatcher_internal_test.go | 21 +++++++++++++++++++ codersdk/x/agenthooks/http.go | 7 ++----- codersdk/x/agenthooks/jwt.go | 8 +++---- codersdk/x/agenthooks/types.go | 10 ++++----- site/src/api/typesGenerated.ts | 10 ++++----- 6 files changed, 44 insertions(+), 27 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index 3a75830b527..f9496c44e39 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -169,6 +169,8 @@ func New( } } +// Enabled reports whether a hook URL is configured. A nil dispatcher reads +// as disabled so callers can hold one unconditionally. func (d *Dispatcher) Enabled() bool { return d != nil && d.hookURL != "" } @@ -185,11 +187,6 @@ func (d *Dispatcher) Dispatch(ctx context.Context, event Event) (agenthooks.Resp startedAt := time.Now() dispatchID := uuid.New() - // One deadline bounds capacity waiting and both post attempts so a - // saturated semaphore cannot extend the dispatch past the configured - // timeout. - ctx, cancel := context.WithTimeout(ctx, d.timeout) - defer cancel() wait := min(d.timeout, capacityWaitLimit) if wait < 0 { wait = 0 @@ -197,6 +194,9 @@ func (d *Dispatcher) Dispatch(ctx context.Context, event Event) (agenthooks.Resp capacityTimer := time.NewTimer(wait) defer capacityTimer.Stop() + // The capacity wait runs against its own timer rather than a dispatch + // deadline, so a timeout shorter than capacityWaitLimit cannot make the + // over-capacity and caller-cancellation cases race. select { case d.semaphore <- struct{}{}: defer func() { <-d.semaphore }() @@ -208,6 +208,11 @@ func (d *Dispatcher) Dispatch(ctx context.Context, event Event) (agenthooks.Resp return agenthooks.Response{}, dispatchID, d.finish(ctx, event, dispatchID, startedAt, outcome) } + // Both post attempts share whatever remains of the configured timeout so + // that waiting for capacity cannot extend the dispatch past it. + ctx, cancel := context.WithTimeout(ctx, d.timeout-time.Since(startedAt)) + defer cancel() + response, outcome := d.prepareAndPost(ctx, event, dispatchID) if err := d.finish(ctx, event, dispatchID, startedAt, outcome); err != nil { return agenthooks.Response{}, dispatchID, err diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 344e8685362..3332b3d368c 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -450,6 +450,27 @@ func TestDispatcherOverCapacity(t *testing.T) { assertDispatchErrorClass(t, err, ResultOverCapacity) } +func TestDispatcherCanceledContextAtCapacityReturnsTimeout(t *testing.T) { + t.Parallel() + + event := newTestEvent(t, agenthooks.EventStop, agenthooks.StopData{}) + dispatcher := newTestDispatcher(t, nil, "https://unused.test", testutil.WaitLong) + for range maxConcurrentDispatches { + dispatcher.semaphore <- struct{}{} + } + defer func() { + for range maxConcurrentDispatches { + <-dispatcher.semaphore + } + }() + + ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitLong)) + cancel() + _, _, err := dispatcher.Dispatch(ctx, event) + require.ErrorIs(t, err, context.Canceled) + assertDispatchErrorClass(t, err, ResultTimeout) +} + func TestDispatcherCanceledContextReturnsTimeout(t *testing.T) { t.Parallel() diff --git a/codersdk/x/agenthooks/http.go b/codersdk/x/agenthooks/http.go index 837ce9c1450..602b6c048f5 100644 --- a/codersdk/x/agenthooks/http.go +++ b/codersdk/x/agenthooks/http.go @@ -56,11 +56,8 @@ func WithTrustForwardedHeaders() HandlerOption { } } -// NewHTTPHandler serves hook requests by routing each event to the -// matching callback in hooks. It accepts POST only, verifies the bearer -// token with Verify, then binds it to the request by checking the body -// digest, audience, schema version, dispatch ID, event type, and chat ID -// against the claims. +// NewHTTPHandler verifies hook POSTs, binds their claims to each request, +// and routes events to their configured callbacks. func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Handler { var options handlerOptions for _, opt := range opts { diff --git a/codersdk/x/agenthooks/jwt.go b/codersdk/x/agenthooks/jwt.go index 208612146a7..71d32a2a4f9 100644 --- a/codersdk/x/agenthooks/jwt.go +++ b/codersdk/x/agenthooks/jwt.go @@ -46,11 +46,9 @@ func SignClaims(secret []byte, claims Claims) (string, error) { return token, nil } -// Verify checks the HS256 bearer token in an Authorization header against -// the shared secret and returns its claims. It validates the signature, -// the JWT type header, the required claims, and the validity window. -// Binding a token to a specific request body and URL is the caller's job: -// see NewHTTPHandler. +// Verify authenticates an HS256 bearer token and validates its JWT header, +// required claims, and validity window. Request binding remains the caller's +// responsibility; see NewHTTPHandler. func Verify(authzHeader string, secret []byte) (Claims, error) { if len(secret) < MinSecretLen { return Claims{}, xerrors.Errorf("secret must be at least %d bytes", MinSecretLen) diff --git a/codersdk/x/agenthooks/types.go b/codersdk/x/agenthooks/types.go index 52d8db99019..99fa4fc6028 100644 --- a/codersdk/x/agenthooks/types.go +++ b/codersdk/x/agenthooks/types.go @@ -38,8 +38,7 @@ type Request struct { Data json.RawMessage `json:"data"` } -// Meta carries the dispatch identity and chat reference sent with every -// event. +// Meta identifies a hook dispatch and its chat. type Meta struct { DispatchID uuid.UUID `json:"dispatch_id"` SchemaVersion int `json:"schema_version"` @@ -70,16 +69,15 @@ type UserPromptSubmitData struct { Parts json.RawMessage `json:"parts,omitempty"` } -// PreToolUseData describes the tool call Coder is about to run. -// ToolInput holds the tool's JSON arguments. +// PreToolUseData describes a tool call before execution. type PreToolUseData struct { ToolUseID string `json:"tool_use_id"` ToolName string `json:"tool_name"` ToolInput json.RawMessage `json:"tool_input"` } -// PostToolUseData reports a finished tool call. ToolResponse carries the -// tool output, or ToolError the failure message. +// PostToolUseData describes a completed tool call, carrying either +// ToolResponse or ToolError. type PostToolUseData struct { ToolUseID string `json:"tool_use_id"` ToolName string `json:"tool_name"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5efe3dc5525..20c47283fea 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1298,8 +1298,7 @@ export const AgentHookMaxRequestBodyBytes = 10485760; // 10 MiB // From agenthooks/types.go /** - * Meta carries the dispatch identity and chat reference sent with every - * event. + * Meta identifies a hook dispatch and its chat. */ export interface AgentHookMeta extends AgentHookChatRef { readonly dispatch_id: string; @@ -1340,8 +1339,8 @@ export interface AgentHookPostCompactData {} // From agenthooks/types.go /** - * PostToolUseData reports a finished tool call. ToolResponse carries the - * tool output, or ToolError the failure message. + * PostToolUseData describes a completed tool call, carrying either + * ToolResponse or ToolError. */ export interface AgentHookPostToolUseData { readonly tool_use_id: string; @@ -1358,8 +1357,7 @@ export interface AgentHookPreCompactData {} // From agenthooks/types.go /** - * PreToolUseData describes the tool call Coder is about to run. - * ToolInput holds the tool's JSON arguments. + * PreToolUseData describes a tool call before execution. */ export interface AgentHookPreToolUseData { readonly tool_use_id: string; From fece7589b066709974dcf058d9860ab5c1066b73 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:24:23 +0000 Subject: [PATCH 05/86] fix(coderd/x/agenthooks): keep rejected hook responses out of decision metrics --- coderd/x/agenthooks/dispatch/dispatcher.go | 3 ++ .../dispatch/dispatcher_internal_test.go | 34 +++++++++++++++++++ codersdk/x/agenthooks/types.go | 6 ++-- scripts/agenthooks-server/main.go | 5 ++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index f9496c44e39..05f8d57505d 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -297,6 +297,9 @@ func (d *Dispatcher) prepareAndPost(ctx context.Context, event Event, dispatchID return agenthooks.Response{}, outcome } if err := validateResponse(event.Type, response); err != nil { + // Drop the rejected response so its decision, override, and context + // values are not observed as if they had been applied. + outcome.response = agenthooks.Response{} outcome.result = ResultProtocolError outcome.err = err return agenthooks.Response{}, outcome diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 3332b3d368c..7c6bea77b21 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -450,6 +450,40 @@ func TestDispatcherOverCapacity(t *testing.T) { assertDispatchErrorClass(t, err, ResultOverCapacity) } +func TestDispatcherRejectedResponseIsNotObserved(t *testing.T) { + t.Parallel() + + event := newTestEvent(t, agenthooks.EventPreToolUse, agenthooks.PreToolUseData{ + ToolUseID: "call_" + uuid.NewString(), + ToolName: "execute", + ToolInput: json.RawMessage(`{"cmd":"ls"}`), + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{"permission":{"decision":"deny","input_override":{"cmd":"rm"}},"model_context":"ctx"}`)) + assert.NoError(t, err) + })) + t.Cleanup(server.Close) + + registry := prometheus.NewRegistry() + dispatcher := New( + testutil.Logger(t), server.Client(), server.URL, testSecret, time.Second, + testDeploymentID, testVersion, registry, + ) + _, _, err := dispatcher.Dispatch(testutil.Context(t, testutil.WaitLong), event) + assertDispatchErrorClass(t, err, ResultProtocolError) + + families, err := registry.Gather() + require.NoError(t, err) + for _, family := range families { + switch family.GetName() { + case "coderd_chatd_hook_decisions_total", + "coderd_chatd_hook_input_overrides_total", + "coderd_chatd_hook_context_size_bytes": + require.Empty(t, family.GetMetric(), "rejected response observed in %s", family.GetName()) + } + } +} + func TestDispatcherCanceledContextAtCapacityReturnsTimeout(t *testing.T) { t.Parallel() diff --git a/codersdk/x/agenthooks/types.go b/codersdk/x/agenthooks/types.go index 99fa4fc6028..0e6277403d2 100644 --- a/codersdk/x/agenthooks/types.go +++ b/codersdk/x/agenthooks/types.go @@ -3,8 +3,10 @@ // backward-compatibility guarantee. // // Delivery is at-least-once because Coder persists no hook dispatch state. -// Consumers must deduplicate side effects by stable identifiers such as -// chat_id, event type, and tool_use_id. +// A retried HTTP attempt reuses its Meta.DispatchID, so consumers deduplicate +// transport retries by that ID. A repeated logical event gets a new ID, so +// keep side effects keyed on the event's own identifiers, such as tool_use_id, +// or make them safe to repeat. package agenthooks import ( diff --git a/scripts/agenthooks-server/main.go b/scripts/agenthooks-server/main.go index 8ae9a999f69..797eea8fe4f 100644 --- a/scripts/agenthooks-server/main.go +++ b/scripts/agenthooks-server/main.go @@ -59,7 +59,7 @@ type consumerState struct { // deliveries while they remain cached. preToolDecisions map[string]agenthooks.Response // blockedTools records tool names this consumer denied per chat, so - // the policy outlives any single dispatch. + // the policy outlives any single dispatch. Evicted with preToolDecisions. blockedTools map[string]map[string]struct{} } @@ -83,7 +83,10 @@ func (s *consumerState) rememberDecision(chatID, toolUseID string, response agen s.mu.Lock() defer s.mu.Unlock() if len(s.preToolDecisions) >= maxRememberedDecisions { + // Both maps grow per chat, so evict them together to keep a + // long-running consumer bounded. s.preToolDecisions = make(map[string]agenthooks.Response) + s.blockedTools = make(map[string]map[string]struct{}) } s.preToolDecisions[chatID+"\x00"+toolUseID] = response if deniedTool == "" { From dafce78afdf1acafee6cb2064401d05a583233fa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:52:17 +0000 Subject: [PATCH 06/86] docs(codersdk/x/agenthooks): describe hook delivery as best-effort --- coderd/x/agenthooks/dispatch/dispatcher.go | 4 ++-- codersdk/x/agenthooks/types.go | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index 05f8d57505d..6a647e00f7e 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -76,8 +76,8 @@ func newError(class Result, dispatchID uuid.UUID, err error) error { return &Error{Class: class, DispatchID: dispatchID, Err: err} } -// Dispatcher delivers lifecycle hook attempts. It keeps no delivery decision -// state; delivery is at-least-once and consumers own decision state. +// Dispatcher delivers lifecycle hook attempts. It keeps no delivery or +// decision state, so delivery is best-effort and consumers own both. type Dispatcher struct { logger slog.Logger client *http.Client diff --git a/codersdk/x/agenthooks/types.go b/codersdk/x/agenthooks/types.go index 0e6277403d2..5277e15bca2 100644 --- a/codersdk/x/agenthooks/types.go +++ b/codersdk/x/agenthooks/types.go @@ -2,7 +2,11 @@ // lifecycle hooks. The protocol, including SchemaVersion 1, has no // backward-compatibility guarantee. // -// Delivery is at-least-once because Coder persists no hook dispatch state. +// Coder persists no hook dispatch state, so delivery is best-effort and may +// duplicate. A failed dispatch is never queued for redelivery; hooks are fail +// closed, so the operation that raised the event fails instead. Consumers must +// therefore tolerate duplicates without assuming every event arrives. +// // A retried HTTP attempt reuses its Meta.DispatchID, so consumers deduplicate // transport retries by that ID. A repeated logical event gets a new ID, so // keep side effects keyed on the event's own identifiers, such as tool_use_id, From cb7a158d444e238f491cd3c6d180e52bd73cda47 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:03:42 +0000 Subject: [PATCH 07/86] docs(scripts/agenthooks-server): describe hook delivery as best-effort --- scripts/agenthooks-server/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/agenthooks-server/main.go b/scripts/agenthooks-server/main.go index 797eea8fe4f..db61977144d 100644 --- a/scripts/agenthooks-server/main.go +++ b/scripts/agenthooks-server/main.go @@ -50,9 +50,9 @@ type eventLog struct { } // consumerState demonstrates consumer-owned hook state. Coder persists no -// hook decisions and delivery is at-least-once, so consumers that need -// memory keep it themselves, keyed by the stable payload identifiers: -// chat_id, the event type, and tool_use_id. +// hook decisions and delivery is best-effort, so consumers that need memory +// keep it themselves, keyed by the stable payload identifiers: chat_id, the +// event type, and tool_use_id. type consumerState struct { mu sync.Mutex // preToolDecisions reuses responses for duplicate (chat_id, tool_use_id) From a5e9c7c4ca0457e70f7f7c70c0fdc2ef50074f4e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:55:53 +0000 Subject: [PATCH 08/86] fix(codersdk/x/agenthooks): fail closed when a hook response cannot be encoded --- codersdk/x/agenthooks/agenthooks_test.go | 22 ++++++++++++++++++++++ codersdk/x/agenthooks/http.go | 10 +++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/codersdk/x/agenthooks/agenthooks_test.go b/codersdk/x/agenthooks/agenthooks_test.go index cc5aa5a1c9e..9f4eaf19338 100644 --- a/codersdk/x/agenthooks/agenthooks_test.go +++ b/codersdk/x/agenthooks/agenthooks_test.go @@ -227,6 +227,28 @@ func TestHTTPHandlerRoutesEvents(t *testing.T) { } } +func TestHTTPHandlerUnencodableResponseFailsClosed(t *testing.T) { + t.Parallel() + + // An empty 200 reads as allow, so a response that cannot be marshaled + // must not reach the dispatcher as one. + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{ + Stop: func(context.Context, agenthooks.Meta, agenthooks.StopData) (agenthooks.Response, error) { + return agenthooks.Response{ + Permission: &agenthooks.Permission{ + Decision: agenthooks.PermissionDeny, + InputOverride: json.RawMessage(`{invalid`), + }, + }, nil + }, + })) + t.Cleanup(server.Close) + + response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, nil) + defer response.Body.Close() + require.Equal(t, http.StatusInternalServerError, response.StatusCode) +} + func TestHTTPHandlerNoOpHookDoesNotDecodeData(t *testing.T) { t.Parallel() diff --git a/codersdk/x/agenthooks/http.go b/codersdk/x/agenthooks/http.go index 602b6c048f5..cace5a531cb 100644 --- a/codersdk/x/agenthooks/http.go +++ b/codersdk/x/agenthooks/http.go @@ -105,8 +105,16 @@ func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Hand return } + // Marshal before writing: streaming the encode would emit a 200 + // with a truncated body on failure, and the dispatcher reads an + // empty 200 as allow, so a malformed deny would fail open. + encoded, err := json.Marshal(response) + if err != nil { + http.Error(rw, "encode response", http.StatusInternalServerError) + return + } rw.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(rw).Encode(response); err != nil { + if _, err := rw.Write(encoded); err != nil { return } }) From 7eb3d6a2f6362349b248906f1bc0e8c8f6d1966e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:09:54 +0000 Subject: [PATCH 09/86] fix(coderd/x/agenthooks/dispatch): reject case-folded duplicate response keys --- coderd/x/agenthooks/dispatch/dispatcher.go | 10 +++++++--- .../x/agenthooks/dispatch/dispatcher_internal_test.go | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index 6a647e00f7e..fc6dbcc338f 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -12,6 +12,7 @@ import ( "net" "net/http" "net/url" + "strings" "time" "github.com/google/uuid" @@ -419,7 +420,9 @@ const maxResponseJSONDepth = 128 // rejectDuplicateKeys consumes one JSON value and fails on duplicate object // keys at any depth, including inside input_override, because a duplicated // key such as {"permission":{"decision":"deny"},"permission":null} would -// otherwise drop the decision the consumer intended. +// otherwise drop the decision the consumer intended. Keys are compared +// case-insensitively because encoding/json matches struct fields that way, +// so "Permission" would silently override "permission". func rejectDuplicateKeys(decoder *json.Decoder, depth int) error { if depth > maxResponseJSONDepth { return xerrors.New("response JSON exceeds supported nesting depth") @@ -441,10 +444,11 @@ func rejectDuplicateKeys(decoder *json.Decoder, depth int) error { return err } key, _ := keyToken.(string) - if _, dup := seen[key]; dup { + folded := strings.ToLower(key) + if _, dup := seen[folded]; dup { return xerrors.Errorf("duplicate key %q", key) } - seen[key] = struct{}{} + seen[folded] = struct{}{} if err := rejectDuplicateKeys(decoder, depth+1); err != nil { return err } diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 7c6bea77b21..9f803dd5e4c 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -330,6 +330,14 @@ func TestDispatcherProtocolErrors(t *testing.T) { data: agenthooks.PreToolUseData{ToolUseID: "call_typo", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, responseBody: []byte(`{"permision":{"decision":"deny"}}`), }, + { + name: "case-folded duplicate permission key", + eventType: agenthooks.EventPreToolUse, + data: agenthooks.PreToolUseData{ToolUseID: "call_fold", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, + // encoding/json matches fields case-insensitively and keeps the + // last value, so this would otherwise decode as an empty allow. + responseBody: []byte(`{"permission":{"decision":"deny"},"Permission":null}`), + }, { name: "duplicate permission key", eventType: agenthooks.EventPreToolUse, From c10f8118d7bdfce41eab992d243b791456970bed Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:31:03 +0000 Subject: [PATCH 10/86] fix(coderd/x/agenthooks/dispatch): fold duplicate keys like encoding/json --- coderd/x/agenthooks/dispatch/dispatcher.go | 33 +++++++++++++++++-- .../dispatch/dispatcher_internal_test.go | 7 ++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index fc6dbcc338f..8fe757b312a 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -12,8 +12,9 @@ import ( "net" "net/http" "net/url" - "strings" "time" + "unicode" + "unicode/utf8" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" @@ -417,6 +418,34 @@ func decodeResponse(trimmed []byte, response *agenthooks.Response) error { const maxResponseJSONDepth = 128 +// foldJSONName canonicalizes an object key the way encoding/json matches +// struct fields: ASCII is case-folded, and other runes collapse to the +// smallest member of their Unicode simple-fold orbit. strings.ToLower is +// not equivalent, so "permi\u017f\u017fion" would otherwise slip past the +// duplicate check and overwrite "permission". +func foldJSONName(name string) string { + folded := make([]byte, 0, len(name)) + for _, r := range name { + if r < utf8.RuneSelf { + if 'a' <= r && r <= 'z' { + r -= 'a' - 'A' + } + folded = append(folded, byte(r)) + continue + } + for { + next := unicode.SimpleFold(r) + if next <= r { + r = next + break + } + r = next + } + folded = utf8.AppendRune(folded, r) + } + return string(folded) +} + // rejectDuplicateKeys consumes one JSON value and fails on duplicate object // keys at any depth, including inside input_override, because a duplicated // key such as {"permission":{"decision":"deny"},"permission":null} would @@ -444,7 +473,7 @@ func rejectDuplicateKeys(decoder *json.Decoder, depth int) error { return err } key, _ := keyToken.(string) - folded := strings.ToLower(key) + folded := foldJSONName(key) if _, dup := seen[folded]; dup { return xerrors.Errorf("duplicate key %q", key) } diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 9f803dd5e4c..904e676e476 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -330,6 +330,13 @@ func TestDispatcherProtocolErrors(t *testing.T) { data: agenthooks.PreToolUseData{ToolUseID: "call_typo", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, responseBody: []byte(`{"permision":{"decision":"deny"}}`), }, + { + name: "unicode-folded duplicate permission key", + eventType: agenthooks.EventPreToolUse, + data: agenthooks.PreToolUseData{ToolUseID: "call_unicode", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, + // encoding/json folds U+017F to "s", so this aliases "permission". + responseBody: []byte(`{"permission":{"decision":"deny"},"permiſſion":null}`), + }, { name: "case-folded duplicate permission key", eventType: agenthooks.EventPreToolUse, From a934133faea50a46420f9aa9ffd03afbc859fe69 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:52:31 +0000 Subject: [PATCH 11/86] fix(coderd/x/agenthooks/dispatch): keep opaque override keys case-sensitive --- coderd/x/agenthooks/dispatch/dispatcher.go | 33 +++++++++++++++---- .../dispatch/dispatcher_internal_test.go | 31 +++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index 8fe757b312a..b4e845ec4db 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -402,7 +402,7 @@ func (d *Dispatcher) post( // silently misread as allow: unknown fields (misspelled keys), duplicate // object keys (Go keeps the last value), and trailing JSON values. func decodeResponse(trimmed []byte, response *agenthooks.Response) error { - if err := rejectDuplicateKeys(json.NewDecoder(bytes.NewReader(trimmed)), 0); err != nil { + if err := rejectDuplicateKeys(json.NewDecoder(bytes.NewReader(trimmed)), 0, keyMatchingFolded); err != nil { return err } decoder := json.NewDecoder(bytes.NewReader(trimmed)) @@ -452,7 +452,19 @@ func foldJSONName(name string) string { // otherwise drop the decision the consumer intended. Keys are compared // case-insensitively because encoding/json matches struct fields that way, // so "Permission" would silently override "permission". -func rejectDuplicateKeys(decoder *json.Decoder, depth int) error { +// keyMatching selects how object keys are compared for duplicates. +type keyMatching int + +const ( + // keyMatchingFolded mirrors encoding/json struct-field matching, used + // for the typed response envelope. + keyMatchingFolded keyMatching = iota + // keyMatchingExact is used inside input_override, which is opaque data + // for a case-sensitive tool schema. + keyMatchingExact +) + +func rejectDuplicateKeys(decoder *json.Decoder, depth int, matching keyMatching) error { if depth > maxResponseJSONDepth { return xerrors.New("response JSON exceeds supported nesting depth") } @@ -473,12 +485,19 @@ func rejectDuplicateKeys(decoder *json.Decoder, depth int) error { return err } key, _ := keyToken.(string) - folded := foldJSONName(key) - if _, dup := seen[folded]; dup { + canonical := key + if matching == keyMatchingFolded { + canonical = foldJSONName(key) + } + if _, dup := seen[canonical]; dup { return xerrors.Errorf("duplicate key %q", key) } - seen[folded] = struct{}{} - if err := rejectDuplicateKeys(decoder, depth+1); err != nil { + seen[canonical] = struct{}{} + nested := matching + if key == "input_override" { + nested = keyMatchingExact + } + if err := rejectDuplicateKeys(decoder, depth+1, nested); err != nil { return err } } @@ -486,7 +505,7 @@ func rejectDuplicateKeys(decoder *json.Decoder, depth int) error { return err case '[': for decoder.More() { - if err := rejectDuplicateKeys(decoder, depth+1); err != nil { + if err := rejectDuplicateKeys(decoder, depth+1, matching); err != nil { return err } } diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 904e676e476..78e3584fb35 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -159,6 +159,30 @@ func TestDispatcherAllowInputOverride(t *testing.T) { require.JSONEq(t, `{"path":"after"}`, string(response.Permission.InputOverride)) } +func TestDispatcherAllowsCaseDistinctOverrideKeys(t *testing.T) { + t.Parallel() + + // Tool schemas are case-sensitive, so "URL" and "url" are distinct + // properties even though the response envelope folds its own keys. + event := newTestEvent(t, agenthooks.EventPreToolUse, agenthooks.PreToolUseData{ + ToolUseID: "call_" + uuid.NewString(), + ToolName: "fetch", + ToolInput: json.RawMessage(`{"url":"before"}`), + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"URL":"upper","url":"lower"}}}`)) + assert.NoError(t, err) + })) + t.Cleanup(server.Close) + + response, _, err := newTestDispatcher(t, server.Client(), server.URL, time.Second).Dispatch( + testutil.Context(t, testutil.WaitLong), event, + ) + require.NoError(t, err) + require.NotNil(t, response.Permission) + require.JSONEq(t, `{"URL":"upper","url":"lower"}`, string(response.Permission.InputOverride)) +} + func TestDispatcherTimeoutNoRetry(t *testing.T) { t.Parallel() @@ -330,6 +354,13 @@ func TestDispatcherProtocolErrors(t *testing.T) { data: agenthooks.PreToolUseData{ToolUseID: "call_typo", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, responseBody: []byte(`{"permision":{"decision":"deny"}}`), }, + { + name: "duplicate key inside input_override", + eventType: agenthooks.EventPreToolUse, + data: agenthooks.PreToolUseData{ToolUseID: "call_dup_override", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, + // Exact duplicates stay rejected at any depth. + responseBody: []byte(`{"permission":{"decision":"allow","input_override":{"url":"a","url":"b"}}}`), + }, { name: "unicode-folded duplicate permission key", eventType: agenthooks.EventPreToolUse, From 99df0aafa3107675598907f7f1bd4513d3b748dd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:06:40 +0000 Subject: [PATCH 12/86] docs(coderd/x/agenthooks/dispatch): reattach the duplicate-key godoc --- coderd/x/agenthooks/dispatch/dispatcher.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index b4e845ec4db..5b8cfb0769f 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -446,12 +446,6 @@ func foldJSONName(name string) string { return string(folded) } -// rejectDuplicateKeys consumes one JSON value and fails on duplicate object -// keys at any depth, including inside input_override, because a duplicated -// key such as {"permission":{"decision":"deny"},"permission":null} would -// otherwise drop the decision the consumer intended. Keys are compared -// case-insensitively because encoding/json matches struct fields that way, -// so "Permission" would silently override "permission". // keyMatching selects how object keys are compared for duplicates. type keyMatching int @@ -464,6 +458,12 @@ const ( keyMatchingExact ) +// rejectDuplicateKeys consumes one JSON value and fails on duplicate object +// keys at any depth, including inside input_override, because a duplicated +// key such as {"permission":{"decision":"deny"},"permission":null} would +// otherwise drop the decision the consumer intended. Envelope keys also +// collide when they differ only by folding, matching how encoding/json +// resolves struct fields, so "Permission" cannot override "permission". func rejectDuplicateKeys(decoder *json.Decoder, depth int, matching keyMatching) error { if depth > maxResponseJSONDepth { return xerrors.New("response JSON exceeds supported nesting depth") From 29e417edc16bce65904ef86ac0bf3fe26ef8b023 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:24:27 +0000 Subject: [PATCH 13/86] fix(coderd/x/agenthooks/dispatch): align override key matching with the decoder --- coderd/x/agenthooks/dispatch/dispatcher.go | 13 ++++++++++++- .../agenthooks/dispatch/dispatcher_internal_test.go | 11 ++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index 5b8cfb0769f..d6defb0f240 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -446,6 +446,10 @@ func foldJSONName(name string) string { return string(folded) } +// foldedInputOverride is the canonical form of the one envelope field whose +// contents are opaque pass-through data. +var foldedInputOverride = foldJSONName("input_override") + // keyMatching selects how object keys are compared for duplicates. type keyMatching int @@ -494,7 +498,9 @@ func rejectDuplicateKeys(decoder *json.Decoder, depth int, matching keyMatching) } seen[canonical] = struct{}{} nested := matching - if key == "input_override" { + // The decoder binds this field case-insensitively, so the + // boundary must be detected the same way. + if matching == keyMatchingFolded && canonical == foldedInputOverride { nested = keyMatchingExact } if err := rejectDuplicateKeys(decoder, depth+1, nested); err != nil { @@ -551,6 +557,11 @@ func validateResponse(eventType agenthooks.EventType, response agenthooks.Respon } func validateUserPromptSubmitOverride(input json.RawMessage) error { + // This override is decoded into a struct rather than passed through, so + // it needs the same folded duplicate check as the response envelope. + if err := rejectDuplicateKeys(json.NewDecoder(bytes.NewReader(input)), 0, keyMatchingFolded); err != nil { + return xerrors.Errorf("user_prompt_submit input_override: %w", err) + } var override struct { Prompt *string `json:"prompt"` } diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 78e3584fb35..78527720a75 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -169,8 +169,10 @@ func TestDispatcherAllowsCaseDistinctOverrideKeys(t *testing.T) { ToolName: "fetch", ToolInput: json.RawMessage(`{"url":"before"}`), }) + // The envelope key is folded by the decoder, so the boundary detection + // must fold too or the case-distinct override below is wrongly rejected. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"URL":"upper","url":"lower"}}}`)) + _, err := w.Write([]byte(`{"permission":{"decision":"allow","INPUT_OVERRIDE":{"URL":"upper","url":"lower"}}}`)) assert.NoError(t, err) })) t.Cleanup(server.Close) @@ -354,6 +356,13 @@ func TestDispatcherProtocolErrors(t *testing.T) { data: agenthooks.PreToolUseData{ToolUseID: "call_typo", ToolName: "run_command", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, responseBody: []byte(`{"permision":{"decision":"deny"}}`), }, + { + name: "folded duplicate in prompt override", + eventType: agenthooks.EventUserPromptSubmit, + data: agenthooks.UserPromptSubmitData{Prompt: "original"}, + // This override is struct-decoded, so folding applies to it. + responseBody: []byte(`{"permission":{"decision":"allow","input_override":{"prompt":"approved","Prompt":"different"}}}`), + }, { name: "duplicate key inside input_override", eventType: agenthooks.EventPreToolUse, From 474027d1ff17677facb1746817d3f20623e20c7c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:21:31 +0000 Subject: [PATCH 14/86] feat(coderd): add chat suffix messages, idle failure, and content update support Add generic chat state and query capabilities used by lifecycle hook integration: - chatstate: EditMessage accepts caller-provided suffix messages that insert after the replacement in the same transaction, transitions can carry a typed error kind, and FailIdle moves an idle chat to the error state. - database: UpdateChatMessageContentByID rewrites a message's content while preserving the search_tsv backfill marker, and InsertChat accepts an optional caller-provided ID. --- coderd/database/dbauthz/dbauthz.go | 15 ++ coderd/database/dbauthz/dbauthz_test.go | 11 ++ coderd/database/dbgen/dbgen.go | 1 + coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 14 ++ coderd/database/querier.go | 3 + coderd/database/querier_test.go | 103 ++++++++++++++ coderd/database/queries.sql.go | 46 ++++-- coderd/database/queries/chats.sql | 15 ++ coderd/x/chatd/chatstate/transition.go | 3 + coderd/x/chatd/chatstate/transitions.go | 133 ++++++++++++++---- .../chatstate/transitions_matrix_test.go | 33 +++++ coderd/x/chatd/chatstate/transitions_test.go | 79 +++++++++++ 13 files changed, 431 insertions(+), 33 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3af34d6b365..ed3ac1df506 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7397,6 +7397,21 @@ func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.Updat return q.db.UpdateChatMCPServerIDs(ctx, arg) } +func (q *querier) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) error { + message, err := q.db.GetChatMessageByID(ctx, arg.ID) + if err != nil { + return err + } + chat, err := q.db.GetChatByID(ctx, message.ChatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.UpdateChatMessageContentByID(ctx, arg) +} + func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.ChatModelConfig{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8f48b870bef..2dd4989bd73 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1279,6 +1279,17 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().InsertChatMessages(gomock.Any(), arg).Return(msgs, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(msgs) })) + + s.Run("UpdateChatMessageContentByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + message := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) + arg := testutil.Fake(s.T(), faker, database.UpdateChatMessageContentByIDParams{ID: message.ID}) + dbm.EXPECT().GetChatMessageByID(gomock.Any(), message.ID).Return(message, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatMessageContentByID(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + })) + s.Run("InsertChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := testutil.Fake(s.T(), faker, database.InsertChatQueuedMessageParams{ChatID: chat.ID}) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9cdad7e8e82..f81c7d1849b 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -93,6 +93,7 @@ func Chat(t testing.TB, db database.Store, seed database.Chat) database.Chat { } chat, err := db.InsertChat(genCtx, database.InsertChatParams{ + ID: uuid.NullUUID{UUID: seed.ID, Valid: seed.ID != uuid.Nil}, OrganizationID: takeFirst(seed.OrganizationID, uuid.New()), OwnerID: takeFirst(seed.OwnerID, uuid.New()), WorkspaceID: seed.WorkspaceID, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 16bfc7c80f1..8e938056a97 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5265,6 +5265,14 @@ func (m queryMetricsStore) UpdateChatMCPServerIDs(ctx context.Context, arg datab return r0, r1 } +func (m queryMetricsStore) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) error { + start := time.Now() + r0 := m.s.UpdateChatMessageContentByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatMessageContentByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatMessageContentByID").Inc() + return r0 +} + func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { start := time.Now() r0, r1 := m.s.UpdateChatModelConfig(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 453a8798dbb..9972ba77b12 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9922,6 +9922,20 @@ func (mr *MockStoreMockRecorder) UpdateChatMCPServerIDs(ctx, arg any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMCPServerIDs", reflect.TypeOf((*MockStore)(nil).UpdateChatMCPServerIDs), ctx, arg) } +// UpdateChatMessageContentByID mocks base method. +func (m *MockStore) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatMessageContentByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChatMessageContentByID indicates an expected call of UpdateChatMessageContentByID. +func (mr *MockStoreMockRecorder) UpdateChatMessageContentByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMessageContentByID", reflect.TypeOf((*MockStore)(nil).UpdateChatMessageContentByID), ctx, arg) +} + // UpdateChatModelConfig mocks base method. func (m *MockStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8e9b909de43..e879a613594 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1419,6 +1419,9 @@ type sqlcQuerier interface { // Two summary workers using the same freshness marker are last-write-wins. UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) + // Preserve NULL as the backfill marker; otherwise refresh search_tsv + // from the new content. + UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) error UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index d0467a86b3e..6399ea63bf4 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16856,6 +16856,109 @@ func TestGetChatsSearch(t *testing.T) { } } +func TestUpdateChatMessageContentByIDRefreshesSearchTsv(t *testing.T) { + t.Parallel() + + store, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + ctx := context.Background() + + org := dbgen.Organization(t, store, database.Organization{}) + user := dbgen.User(t, store, database.User{}) + dbgen.OrganizationMember(t, store, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID}) + + provider := dbgen.AIProviderWithOptionalKey(t, store, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + }, "test-key") + + modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + Model: "test-model-" + uuid.NewString(), + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := store.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: user.ID, + LastModelConfigID: modelCfg.ID, + Title: "content rewrite", + }) + require.NoError(t, err) + + insertMsg := func(text string) database.ChatMessage { + t.Helper() + msgs, err := store.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedBy: []uuid.UUID{user.ID}, + ModelConfigID: []uuid.UUID{modelCfg.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, + Content: []string{`[{"type":"text","text":` + strconv.Quote(text) + `}]`}, + ContentVersion: []int16{1}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + }) + require.NoError(t, err) + require.Len(t, msgs, 1) + return msgs[0] + } + + searchTsv := func(id int64) sql.NullString { + t.Helper() + var tsv sql.NullString + err := sqlDB.QueryRowContext(ctx, `SELECT search_tsv::text FROM chat_messages WHERE id = $1`, id).Scan(&tsv) + require.NoError(t, err) + return tsv + } + + indexedMsg := insertMsg("original secret phrase") + _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) + require.NoError(t, err) + require.True(t, searchTsv(indexedMsg.ID).Valid) + + err = store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ + ID: indexedMsg.ID, + Content: json.RawMessage(`[{"type":"text","text":"replacement override phrase"}]`), + }) + require.NoError(t, err) + + tsv := searchTsv(indexedMsg.ID) + require.True(t, tsv.Valid) + require.Contains(t, tsv.String, "replacement") + require.NotContains(t, tsv.String, "original") + + pendingMsg := insertMsg("pending secret phrase") + err = store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ + ID: pendingMsg.ID, + Content: json.RawMessage(`[{"type":"text","text":"pending replacement phrase"}]`), + }) + require.NoError(t, err) + require.False(t, searchTsv(pendingMsg.ID).Valid) + + _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) + require.NoError(t, err) + tsv = searchTsv(pendingMsg.ID) + require.True(t, tsv.Valid) + require.Contains(t, tsv.String, "replacement") +} + func TestChatHasUnread(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 76168e90363..4e8ccbe197c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9891,6 +9891,7 @@ func (q *sqlQuerier) InsertAgentContextResourcesIntoChat(ctx context.Context, ar const insertChat = `-- name: InsertChat :one WITH inserted_chat AS ( INSERT INTO chats ( + id, organization_id, owner_id, workspace_id, @@ -9908,7 +9909,7 @@ INSERT INTO chats ( dynamic_tools, client_type ) VALUES ( - $1::uuid, + COALESCE($1::uuid, gen_random_uuid()), $2::uuid, $3::uuid, $4::uuid, @@ -9916,14 +9917,15 @@ INSERT INTO chats ( $6::uuid, $7::uuid, $8::uuid, - $9::text, - $10::chat_mode, - $11::chat_plan_mode, - $12::chat_status, - COALESCE($13::uuid[], '{}'::uuid[]), - COALESCE($14::jsonb, '{}'::jsonb), - $15::jsonb, - $16::chat_client_type + $9::uuid, + $10::text, + $11::chat_mode, + $12::chat_plan_mode, + $13::chat_status, + COALESCE($14::uuid[], '{}'::uuid[]), + COALESCE($15::jsonb, '{}'::jsonb), + $16::jsonb, + $17::chat_client_type ) RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort, compaction_requested_at ), @@ -9984,6 +9986,7 @@ FROM chats_expanded ` type InsertChatParams struct { + ID uuid.NullUUID `db:"id" json:"id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` @@ -10004,6 +10007,7 @@ type InsertChatParams struct { func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) { row := q.db.QueryRowContext(ctx, insertChat, + arg.ID, arg.OrganizationID, arg.OwnerID, arg.WorkspaceID, @@ -12076,6 +12080,30 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM return i, err } +const updateChatMessageContentByID = `-- name: UpdateChatMessageContentByID :exec +UPDATE chat_messages +SET content = $1::jsonb, + search_tsv = CASE + WHEN search_tsv IS NULL THEN NULL + ELSE COALESCE( + to_tsvector('simple', chat_message_search_text($1::jsonb)), + ''::tsvector) + END +WHERE id = $2::bigint +` + +type UpdateChatMessageContentByIDParams struct { + Content json.RawMessage `db:"content" json:"content"` + ID int64 `db:"id" json:"id"` +} + +// Preserve NULL as the backfill marker; otherwise refresh search_tsv +// from the new content. +func (q *sqlQuerier) UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) error { + _, err := q.db.ExecContext(ctx, updateChatMessageContentByID, arg.Content, arg.ID) + return err +} + const updateChatPinOrder = `-- name: UpdateChatPinOrder :exec WITH target_chat AS ( SELECT diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index d1a796c54c8..672d799e8f2 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -381,6 +381,19 @@ WHERE id = @id::bigint AND deleted = false; +-- name: UpdateChatMessageContentByID :exec +-- Preserve NULL as the backfill marker; otherwise refresh search_tsv +-- from the new content. +UPDATE chat_messages +SET content = @content::jsonb, + search_tsv = CASE + WHEN search_tsv IS NULL THEN NULL + ELSE COALESCE( + to_tsvector('simple', chat_message_search_text(@content::jsonb)), + ''::tsvector) + END +WHERE id = @id::bigint; + -- name: GetChatMessagesByChatID :many SELECT * @@ -770,6 +783,7 @@ ORDER BY -- name: InsertChat :one WITH inserted_chat AS ( INSERT INTO chats ( + id, organization_id, owner_id, workspace_id, @@ -787,6 +801,7 @@ INSERT INTO chats ( dynamic_tools, client_type ) VALUES ( + COALESCE(sqlc.narg('id')::uuid, gen_random_uuid()), @organization_id::uuid, @owner_id::uuid, sqlc.narg('workspace_id')::uuid, diff --git a/coderd/x/chatd/chatstate/transition.go b/coderd/x/chatd/chatstate/transition.go index f7b6c3634c5..e7168deef03 100644 --- a/coderd/x/chatd/chatstate/transition.go +++ b/coderd/x/chatd/chatstate/transition.go @@ -28,6 +28,7 @@ const ( TransitionFinishInterruption Transition = "FinishInterruption" TransitionFinishTurn Transition = "FinishTurn" TransitionFinishError Transition = "FinishError" + TransitionFailIdle Transition = "FailIdle" TransitionCancelRequiresAction Transition = "CancelRequiresAction" TransitionReconcileInvalidState Transition = "ReconcileInvalidState" ) @@ -57,6 +58,7 @@ var AllExecutionTransitions = []Transition{ TransitionFinishInterruption, TransitionFinishTurn, TransitionFinishError, + TransitionFailIdle, TransitionCancelRequiresAction, TransitionReconcileInvalidState, } @@ -81,6 +83,7 @@ var transitionMatrix = map[ExecutionState]map[Transition][]ExecutionState{ TransitionSendMessage: {StateR0}, TransitionEditMessage: {StateR0}, TransitionRequestCompaction: {StateR0}, + TransitionFailIdle: {StateE0}, }, StateE0: { TransitionSetArchived: {StateXE0}, diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 6b8593eff83..aa123e4e0e6 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -60,6 +60,32 @@ func CreateChat( store database.Store, publisher Publisher, input CreateChatInput, +) (CreateChatResult, error) { + return insertChat(ctx, store, publisher, uuid.NullUUID{}, input) +} + +// CreateChatWithID creates a chat using a caller-minted ID so +// admission-time work (such as lifecycle hook dispatch) can reference +// the chat before it exists. +func CreateChatWithID( + ctx context.Context, + store database.Store, + publisher Publisher, + chatID uuid.UUID, + input CreateChatInput, +) (CreateChatResult, error) { + if chatID == uuid.Nil { + return CreateChatResult{}, xerrors.New("chatstate: CreateChatWithID called with nil chat ID") + } + return insertChat(ctx, store, publisher, uuid.NullUUID{UUID: chatID, Valid: true}, input) +} + +func insertChat( + ctx context.Context, + store database.Store, + publisher Publisher, + chatID uuid.NullUUID, + input CreateChatInput, ) (CreateChatResult, error) { if store == nil { return CreateChatResult{}, xerrors.New("chatstate: CreateChat called with nil store") @@ -78,6 +104,7 @@ func CreateChat( defer buffer.Discard() err := store.InTx(func(store database.Store) error { chat, err := store.InsertChat(ctx, database.InsertChatParams{ + ID: chatID, OrganizationID: input.OrganizationID, OwnerID: input.OwnerID, WorkspaceID: input.WorkspaceID, @@ -355,48 +382,48 @@ func (tx *Tx) SendMessage(input SendMessageInput) (SendMessageResult, error) { // Idle / empty-queue error: insert directly into history, clear // last_error, leave queue alone. case StateW, StateE0: - return tx.sendMessageDirect(chat, input.Message) + return tx.sendMessageDirect(chat, input) // Error-with-queue: append to tail, promote previous head into // history, clear last_error. case StateE1: - return tx.sendMessageE1(chat, input.Message) + return tx.sendMessageE1(chat, input) // Running with no queue. case StateR0: if input.BusyBehavior == BusyBehaviorInterrupt { - return tx.sendMessageQueueAndSetStatus(chat, input.Message, database.ChatStatusInterrupting, chat.LastError, chat.RequiresActionDeadlineAt) + return tx.sendMessageQueueAndSetStatus(chat, input, database.ChatStatusInterrupting, chat.LastError, chat.RequiresActionDeadlineAt) } - return tx.sendMessageQueueAndSetStatus(chat, input.Message, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) + return tx.sendMessageQueueAndSetStatus(chat, input, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) // Running with queue. case StateR1: if input.BusyBehavior == BusyBehaviorInterrupt { - return tx.sendMessageQueueAndSetStatus(chat, input.Message, database.ChatStatusInterrupting, chat.LastError, chat.RequiresActionDeadlineAt) + return tx.sendMessageQueueAndSetStatus(chat, input, database.ChatStatusInterrupting, chat.LastError, chat.RequiresActionDeadlineAt) } - return tx.sendMessageQueueAndSetStatus(chat, input.Message, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) + return tx.sendMessageQueueAndSetStatus(chat, input, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) // Interrupting: queue regardless of busy behavior. case StateI0, StateI1: - return tx.sendMessageQueueAndSetStatus(chat, input.Message, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) + return tx.sendMessageQueueAndSetStatus(chat, input, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) // Requires-action: queue keeps A*; interrupt cancels pending // dynamic calls and resumes in running. case StateA0, StateA1: if input.BusyBehavior == BusyBehaviorInterrupt { - return tx.sendMessageInterruptRequiresAction(chat, input.Message) + return tx.sendMessageInterruptRequiresAction(chat, input) } - return tx.sendMessageQueueAndSetStatus(chat, input.Message, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) + return tx.sendMessageQueueAndSetStatus(chat, input, chat.Status, chat.LastError, chat.RequiresActionDeadlineAt) } return SendMessageResult{}, newTransitionError(TransitionSendMessage, from, "unhandled state in SendMessage") } -func (tx *Tx) sendMessageDirect(chat database.Chat, m Message) (SendMessageResult, error) { +func (tx *Tx) sendMessageDirect(chat database.Chat, input SendMessageInput) (SendMessageResult, error) { cancels, err := synthesizePendingToolCancellations(tx.ctx, tx.store, chat, "Tool execution interrupted by new user message", false) if err != nil { return SendMessageResult{}, err } - inserted, err := tx.insertMessages(append(cancels, m)) + inserted, err := tx.insertMessages(append(cancels, input.Message)) if err != nil { return SendMessageResult{}, xerrors.Errorf("insert direct user message: %w", err) } @@ -415,8 +442,8 @@ func (tx *Tx) sendMessageDirect(chat database.Chat, m Message) (SendMessageResul }, nil } -func (tx *Tx) sendMessageE1(chat database.Chat, m Message) (SendMessageResult, error) { - queued, err := tx.insertQueuedMessage(chat.OwnerID, m) +func (tx *Tx) sendMessageE1(chat database.Chat, input SendMessageInput) (SendMessageResult, error) { + queued, err := tx.insertQueuedMessage(chat.OwnerID, input.Message) if err != nil { return SendMessageResult{}, xerrors.Errorf("insert queued: %w", err) } @@ -457,12 +484,12 @@ func (tx *Tx) sendMessageE1(chat database.Chat, m Message) (SendMessageResult, e func (tx *Tx) sendMessageQueueAndSetStatus( chat database.Chat, - m Message, + input SendMessageInput, status database.ChatStatus, lastError pqtype.NullRawMessage, deadline sql.NullTime, ) (SendMessageResult, error) { - queued, err := tx.insertQueuedMessage(chat.OwnerID, m) + queued, err := tx.insertQueuedMessage(chat.OwnerID, input.Message) if err != nil { return SendMessageResult{}, xerrors.Errorf("insert queued: %w", err) } @@ -485,20 +512,32 @@ func (tx *Tx) sendMessageQueueAndSetStatus( }, nil } -func (tx *Tx) sendMessageInterruptRequiresAction(chat database.Chat, m Message) (SendMessageResult, error) { +func (tx *Tx) sendMessageInterruptRequiresAction(chat database.Chat, input SendMessageInput) (SendMessageResult, error) { cancels, err := synthesizePendingToolCancellations(tx.ctx, tx.store, chat, "Tool execution interrupted by user message", true) if err != nil { return SendMessageResult{}, err } - if _, err := tx.insertMessages(cancels); err != nil { + inserted, err := tx.insertMessages(cancels) + if err != nil { return SendMessageResult{}, xerrors.Errorf("insert requires-action cancellations: %w", err) } - return tx.sendMessageQueueAndSetStatus(chat, m, database.ChatStatusRunning, chat.LastError, sql.NullTime{}) + result, err := tx.sendMessageQueueAndSetStatus(chat, input, database.ChatStatusRunning, chat.LastError, sql.NullTime{}) + if err != nil { + return SendMessageResult{}, err + } + // Report the cancellation rows so API clients receive every + // user-visible message this send inserted. + result.InsertedMessages = inserted + return result, nil } // EditMessageInput configures [Tx.EditMessage]. type EditMessageInput struct { - MessageID int64 + MessageID int64 + // SuffixMessages are inserted after the replacement message in the + // same transaction, so a later edit's suffix truncation cleans them + // up together with the rest of the discarded turn. + SuffixMessages []Message CreatedBy uuid.UUID Content pqtype.NullRawMessage ModelConfigIDOverride uuid.NullUUID @@ -511,6 +550,7 @@ type EditMessageResult struct { DeletedMessageIDs []int64 DeletedQueuedMessageIDs []int64 CancellationMessages []database.ChatMessage + SuffixMessages []database.ChatMessage } // EditMessage replaces an earlier user message and discards the @@ -564,7 +604,6 @@ func (tx *Tx) EditMessage(input EditMessageInput) (EditMessageResult, error) { }); err != nil { return EditMessageResult{}, xerrors.Errorf("soft-delete suffix: %w", err) } - cancels, err := synthesizePendingToolCancellations(tx.ctx, tx.store, chat, "Tool execution interrupted by message edit", false) if err != nil { return EditMessageResult{}, err @@ -599,6 +638,10 @@ func (tx *Tx) EditMessage(input EditMessageInput) (EditMessageResult, error) { if len(insertedReplacement) == 1 { replacementRow = insertedReplacement[0] } + insertedSuffix, err := tx.insertMessages(input.SuffixMessages) + if err != nil { + return EditMessageResult{}, xerrors.Errorf("insert edit suffix messages: %w", err) + } deletedQueuedIDs, err := tx.clearQueue() if err != nil { @@ -620,6 +663,7 @@ func (tx *Tx) EditMessage(input EditMessageInput) (EditMessageResult, error) { DeletedMessageIDs: deletedIDs, DeletedQueuedMessageIDs: deletedQueuedIDs, CancellationMessages: cancellationMessages, + SuffixMessages: insertedSuffix, }, nil } @@ -877,9 +921,10 @@ type ToolResultInput struct { // CompleteRequiresActionInput configures [Tx.CompleteRequiresAction]. type CompleteRequiresActionInput struct { - CreatedBy uuid.UUID - ModelConfigID uuid.UUID - Results []ToolResultInput + CreatedBy uuid.UUID + ModelConfigID uuid.UUID + Results []ToolResultInput + SuffixMessages []Message } // CompleteRequiresActionResult is returned by [Tx.CompleteRequiresAction]. @@ -957,7 +1002,7 @@ func (tx *Tx) CompleteRequiresAction(input CompleteRequiresActionInput) (Complet ContentVersion: chatprompt.CurrentContentVersion, }) } - inserted, err := tx.insertMessages(messages) + inserted, err := tx.insertMessages(append(messages, input.SuffixMessages...)) if err != nil { return CompleteRequiresActionResult{}, xerrors.Errorf("insert tool results: %w", err) } @@ -1404,6 +1449,46 @@ func (tx *Tx) FinishError(input FinishErrorInput) (FinishErrorResult, error) { return FinishErrorResult{}, nil } +// FailIdleInput configures [Tx.FailIdle]. +type FailIdleInput struct { + LastError string + // Kind classifies the persisted error; empty means generic. + Kind codersdk.ChatErrorKind +} + +// FailIdleResult is returned by [Tx.FailIdle]. +type FailIdleResult struct{} + +// FailIdle moves a waiting chat to error without requiring runner ownership. +func (tx *Tx) FailIdle(input FailIdleInput) (FailIdleResult, error) { + chat, _, err := tx.requireFromAllowed(TransitionFailIdle) + if err != nil { + return FailIdleResult{}, err + } + kind := input.Kind + if kind == "" { + kind = codersdk.ChatErrorKindGeneric + } + lastError, err := json.Marshal(codersdk.ChatError{ + Message: input.LastError, + Kind: kind, + }) + if err != nil { + return FailIdleResult{}, xerrors.Errorf("encode last error: %w", err) + } + if _, err := tx.applyExecutionState(executionStateUpdate{ + Status: database.ChatStatusError, + Archived: false, + WorkerID: chat.WorkerID, + RunnerID: chat.RunnerID, + LastError: pqtype.NullRawMessage{RawMessage: lastError, Valid: true}, + RequiresActionDeadlineAt: sql.NullTime{}, + }); err != nil { + return FailIdleResult{}, xerrors.Errorf("set error: %w", err) + } + return FailIdleResult{}, nil +} + // CancelRequiresActionInput configures [Tx.CancelRequiresAction]. type CancelRequiresActionInput struct { Reason string diff --git a/coderd/x/chatd/chatstate/transitions_matrix_test.go b/coderd/x/chatd/chatstate/transitions_matrix_test.go index 1f1597f498b..eab9e5ff714 100644 --- a/coderd/x/chatd/chatstate/transitions_matrix_test.go +++ b/coderd/x/chatd/chatstate/transitions_matrix_test.go @@ -262,6 +262,13 @@ func applyFinishError(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededCh return err } +func applyFailIdle(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error { + t.Helper() + var err error + result.failIdle, err = tx.FailIdle(chatstate.FailIdleInput{LastError: "hook dispatch failed"}) + return err +} + func applyCancelRequiresAction(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error { t.Helper() var err error @@ -313,6 +320,8 @@ func defaultApplier(tr chatstate.Transition) applierFn { return applyFinishTurn case chatstate.TransitionFinishError: return applyFinishError + case chatstate.TransitionFailIdle: + return applyFailIdle case chatstate.TransitionCancelRequiresAction: return applyCancelRequiresAction case chatstate.TransitionReconcileInvalidState: @@ -354,6 +363,7 @@ type transitionCaseResult struct { finishInterruption chatstate.FinishInterruptionResult finishTurn chatstate.FinishTurnResult finishError chatstate.FinishErrorResult + failIdle chatstate.FailIdleResult cancelRequiresAction chatstate.CancelRequiresActionResult reconcileInvalidState chatstate.ReconcileInvalidStateResult } @@ -869,6 +879,8 @@ func matrixCases() []transitionCaseSpec { finishErrorCase(chatstate.StateR0, chatstate.StateE0), finishErrorCase(chatstate.StateR1, chatstate.StateE1), + failIdleCase(), + // ReconcileInvalidState cases: Invalid with empty queue // lands in E0; Invalid with non-empty queue lands in E1. reconcileInvalidStateCase(chatstate.StateE0, queueShapeDefault), @@ -1846,6 +1858,27 @@ func finishErrorCase(from, want chatstate.ExecutionState) transitionCaseSpec { } } +func failIdleCase() transitionCaseSpec { + return transitionCaseSpec{ + transition: chatstate.TransitionFailIdle, + from: chatstate.StateW, + want: chatstate.StateE0, + apply: applyFailIdle, + assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) { + _ = result + after, err := f.DB.GetChatByID(ctx, seeded.chatID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusError, after.Status) + require.True(t, after.LastError.Valid) + require.JSONEq(t, `{"message":"hook dispatch failed","kind":"generic","retryable":false}`, string(after.LastError.RawMessage)) + require.Equal(t, base.chat.WorkerID, after.WorkerID) + require.Equal(t, base.chat.RunnerID, after.RunnerID) + require.Equal(t, base.historyVersion, after.HistoryVersion) + require.Equal(t, base.queueVersion, after.QueueVersion) + }, + } +} + func reconcileInvalidStateCase(want chatstate.ExecutionState, shape queueShape) transitionCaseSpec { spec := transitionCaseSpec{ transition: chatstate.TransitionReconcileInvalidState, diff --git a/coderd/x/chatd/chatstate/transitions_test.go b/coderd/x/chatd/chatstate/transitions_test.go index c4df476d7ba..e5a6beaf5c6 100644 --- a/coderd/x/chatd/chatstate/transitions_test.go +++ b/coderd/x/chatd/chatstate/transitions_test.go @@ -413,6 +413,36 @@ func TestSendMessageQueueCapRejectsQueueAppend(t *testing.T) { "failed queue append must not bump queue_version") } +func TestSendMessageInterruptRequiresActionReturnsCancellations(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + seeded := seedAOrA1(t, f, 0, "interrupt_cancels") + require.Equal(t, chatstate.StateA0, f.classify(ctx, t, seeded.chatID)) + + m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) + var send chatstate.SendMessageResult + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + var err error + send, err = tx.SendMessage(chatstate.SendMessageInput{ + Message: userTextMessage("interrupt", f.User.ID, f.Model.ID), + BusyBehavior: chatstate.BusyBehaviorInterrupt, + }) + return err + })) + + require.NotNil(t, send.QueuedMessage, "interrupt from A0 queues the user message") + require.Len(t, send.InsertedMessages, 1, + "the synthetic tool cancellation must be reported to callers") + cancel := send.InsertedMessages[0] + require.Equal(t, database.ChatMessageRoleTool, cancel.Role) + require.Equal(t, database.ChatMessageVisibilityBoth, cancel.Visibility) + parts, err := chatprompt.ParseContent(cancel) + require.NoError(t, err) + require.Len(t, parts, 1) + require.Equal(t, seeded.pendingToolCallID, parts[0].ToolCallID) +} + // TestEditMessageNonUserReturnsSentinel asserts that editing a // non-user message returns chatstate.ErrEditedMessageNotUser via // the TransitionError cause chain, and still matches the generic @@ -461,6 +491,55 @@ func TestEditMessageNonUserReturnsSentinel(t *testing.T) { "ErrEditedMessageNotUser still matches the generic transition sentinel") } +func TestEditMessageInsertsSuffixMessages(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + created := createTestChat(t, f) + m := chatstate.NewChatMachine(f.DB, f.Pub, created.Chat.ID) + + target := userTextMessage("original prompt", f.User.ID, f.Model.ID) + + var targetID int64 + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + step, err := tx.CommitStep(chatstate.CommitStepInput{ + Messages: []chatstate.Message{target}, + }) + if err != nil { + return err + } + require.Len(t, step.InsertedMessages, 1) + targetID = step.InsertedMessages[0].ID + return nil + })) + + suffix := userTextMessage("session notice", f.User.ID, f.Model.ID) + suffix.Role = database.ChatMessageRoleSystem + rawContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("edited prompt"), + }) + require.NoError(t, err) + + var result chatstate.EditMessageResult + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + result, err = tx.EditMessage(chatstate.EditMessageInput{ + MessageID: targetID, + SuffixMessages: []chatstate.Message{suffix}, + CreatedBy: f.User.ID, + Content: rawContent, + }) + return err + })) + + require.Contains(t, result.DeletedMessageIDs, targetID) + require.Len(t, result.SuffixMessages, 1) + assertChatMessageText(t, result.SuffixMessages[0], "session notice") + require.Greater(t, result.SuffixMessages[0].ID, result.ReplacementMessage.ID, + "the suffix message must follow the replacement") + _, err = f.DB.GetChatMessageByID(ctx, result.ReplacementMessage.ID) + require.NoError(t, err, "the replacement message must stay active") +} + // TestTransitionAbandon_RejectsUnowned verifies that calling Abandon // on a chat the runner does not own returns ErrTransitionNotAllowed // wrapped in a TransitionError that records the loaded from-state, From ca70bf7707f287f278dcd0cd050b31d03f5c4044 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:57:20 +0000 Subject: [PATCH 15/86] docs(coderd/x/chatd): document the FailIdle transition --- coderd/x/chatd/ARCHITECTURE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index c144316bb8c..62259794914 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -117,6 +117,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. - `RequestCompaction` records a manual compaction request on an idle chat by setting `compaction_requested_at` and landing in `running` without inserting any message. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). +- `FailIdle(err)` moves a waiting chat to `error` and persists `last_error = err` without requiring runner ownership. Used when admission-time work on an idle chat fails in a way that must surface on the chat itself. ### Transitions used by the chat worker @@ -149,6 +150,7 @@ stateDiagram-v2 W --> R0: SendMessage W --> R0: EditMessage W --> R0: RequestCompaction + W --> E0: FailIdle W --> XW: SetArchived(true) E0 --> R0: SendMessage From 2e1f608780a4dfa3dab14d03204b069a0c0eac3e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:15:44 +0000 Subject: [PATCH 16/86] refactor(coderd/x/chatd/chatstate): fold FailIdle into FinishError Allow FinishError from a waiting chat instead of adding a separate transition. Callers encode the error payload, matching the existing FinishError contract. --- coderd/x/chatd/ARCHITECTURE.md | 5 +-- coderd/x/chatd/chatstate/transition.go | 4 +- coderd/x/chatd/chatstate/transitions.go | 42 +------------------ .../chatstate/transitions_matrix_test.go | 34 +-------------- 4 files changed, 6 insertions(+), 79 deletions(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 62259794914..33360846fa9 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -117,7 +117,6 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. - `RequestCompaction` records a manual compaction request on an idle chat by setting `compaction_requested_at` and landing in `running` without inserting any message. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). -- `FailIdle(err)` moves a waiting chat to `error` and persists `last_error = err` without requiring runner ownership. Used when admission-time work on an idle chat fails in a way that must surface on the chat itself. ### Transitions used by the chat worker @@ -129,7 +128,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `RecordGenerationAttempt` verifies the chat is still `running`, increments `generation_attempt`, and returns the updated chat snapshot. - `RecordRetryState(payload)` verifies the chat is still `running`, stores the retry payload sent to clients as `retry_state`, and returns the updated chat snapshot. - `FinishTurn` completes the current generation turn atomically. If the queue is empty, it lands in `waiting`. If the queue is non-empty, it removes the queue head, inserts it into history as a user turn, and lands in `running`. -- `FinishError(err)` ends a running chat in `error` and persists `last_error = err`, overwriting any prior stored error. +- `FinishError(err)` parks the chat in `error` and persists `last_error = err`, overwriting any prior stored error. It is also allowed from a waiting chat, without runner ownership, when admission-time work fails in a way that must surface on the chat itself. - `CancelRequiresAction(reason)` closes pending dynamic tool calls with synthetic cancellation tool results, satisfies the pending-action projection, clears `requires_action_deadline_at`, and lands in `running`. - `ReconcileInvalidState` reconciles a chat in an invalid state by setting it to a valid state. Defined in the [Invalid states](#invalid-states) section. @@ -150,7 +149,7 @@ stateDiagram-v2 W --> R0: SendMessage W --> R0: EditMessage W --> R0: RequestCompaction - W --> E0: FailIdle + W --> E0: FinishError W --> XW: SetArchived(true) E0 --> R0: SendMessage diff --git a/coderd/x/chatd/chatstate/transition.go b/coderd/x/chatd/chatstate/transition.go index e7168deef03..d6f4a03af90 100644 --- a/coderd/x/chatd/chatstate/transition.go +++ b/coderd/x/chatd/chatstate/transition.go @@ -28,7 +28,6 @@ const ( TransitionFinishInterruption Transition = "FinishInterruption" TransitionFinishTurn Transition = "FinishTurn" TransitionFinishError Transition = "FinishError" - TransitionFailIdle Transition = "FailIdle" TransitionCancelRequiresAction Transition = "CancelRequiresAction" TransitionReconcileInvalidState Transition = "ReconcileInvalidState" ) @@ -58,7 +57,6 @@ var AllExecutionTransitions = []Transition{ TransitionFinishInterruption, TransitionFinishTurn, TransitionFinishError, - TransitionFailIdle, TransitionCancelRequiresAction, TransitionReconcileInvalidState, } @@ -83,7 +81,7 @@ var transitionMatrix = map[ExecutionState]map[Transition][]ExecutionState{ TransitionSendMessage: {StateR0}, TransitionEditMessage: {StateR0}, TransitionRequestCompaction: {StateR0}, - TransitionFailIdle: {StateE0}, + TransitionFinishError: {StateE0}, }, StateE0: { TransitionSetArchived: {StateXE0}, diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index aa123e4e0e6..ad1406e34ef 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -1431,6 +1431,8 @@ type FinishErrorInput struct { type FinishErrorResult struct{} // FinishError parks the chat in error with the supplied last_error. +// Allowed from running chats by the chat worker and from waiting chats +// when admission-time work fails; it does not require runner ownership. func (tx *Tx) FinishError(input FinishErrorInput) (FinishErrorResult, error) { chat, _, err := tx.requireFromAllowed(TransitionFinishError) if err != nil { @@ -1449,46 +1451,6 @@ func (tx *Tx) FinishError(input FinishErrorInput) (FinishErrorResult, error) { return FinishErrorResult{}, nil } -// FailIdleInput configures [Tx.FailIdle]. -type FailIdleInput struct { - LastError string - // Kind classifies the persisted error; empty means generic. - Kind codersdk.ChatErrorKind -} - -// FailIdleResult is returned by [Tx.FailIdle]. -type FailIdleResult struct{} - -// FailIdle moves a waiting chat to error without requiring runner ownership. -func (tx *Tx) FailIdle(input FailIdleInput) (FailIdleResult, error) { - chat, _, err := tx.requireFromAllowed(TransitionFailIdle) - if err != nil { - return FailIdleResult{}, err - } - kind := input.Kind - if kind == "" { - kind = codersdk.ChatErrorKindGeneric - } - lastError, err := json.Marshal(codersdk.ChatError{ - Message: input.LastError, - Kind: kind, - }) - if err != nil { - return FailIdleResult{}, xerrors.Errorf("encode last error: %w", err) - } - if _, err := tx.applyExecutionState(executionStateUpdate{ - Status: database.ChatStatusError, - Archived: false, - WorkerID: chat.WorkerID, - RunnerID: chat.RunnerID, - LastError: pqtype.NullRawMessage{RawMessage: lastError, Valid: true}, - RequiresActionDeadlineAt: sql.NullTime{}, - }); err != nil { - return FailIdleResult{}, xerrors.Errorf("set error: %w", err) - } - return FailIdleResult{}, nil -} - // CancelRequiresActionInput configures [Tx.CancelRequiresAction]. type CancelRequiresActionInput struct { Reason string diff --git a/coderd/x/chatd/chatstate/transitions_matrix_test.go b/coderd/x/chatd/chatstate/transitions_matrix_test.go index eab9e5ff714..15258c53c26 100644 --- a/coderd/x/chatd/chatstate/transitions_matrix_test.go +++ b/coderd/x/chatd/chatstate/transitions_matrix_test.go @@ -262,13 +262,6 @@ func applyFinishError(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededCh return err } -func applyFailIdle(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error { - t.Helper() - var err error - result.failIdle, err = tx.FailIdle(chatstate.FailIdleInput{LastError: "hook dispatch failed"}) - return err -} - func applyCancelRequiresAction(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error { t.Helper() var err error @@ -320,8 +313,6 @@ func defaultApplier(tr chatstate.Transition) applierFn { return applyFinishTurn case chatstate.TransitionFinishError: return applyFinishError - case chatstate.TransitionFailIdle: - return applyFailIdle case chatstate.TransitionCancelRequiresAction: return applyCancelRequiresAction case chatstate.TransitionReconcileInvalidState: @@ -363,7 +354,6 @@ type transitionCaseResult struct { finishInterruption chatstate.FinishInterruptionResult finishTurn chatstate.FinishTurnResult finishError chatstate.FinishErrorResult - failIdle chatstate.FailIdleResult cancelRequiresAction chatstate.CancelRequiresActionResult reconcileInvalidState chatstate.ReconcileInvalidStateResult } @@ -878,8 +868,7 @@ func matrixCases() []transitionCaseSpec { // FinishError cases. finishErrorCase(chatstate.StateR0, chatstate.StateE0), finishErrorCase(chatstate.StateR1, chatstate.StateE1), - - failIdleCase(), + finishErrorCase(chatstate.StateW, chatstate.StateE0), // ReconcileInvalidState cases: Invalid with empty queue // lands in E0; Invalid with non-empty queue lands in E1. @@ -1858,27 +1847,6 @@ func finishErrorCase(from, want chatstate.ExecutionState) transitionCaseSpec { } } -func failIdleCase() transitionCaseSpec { - return transitionCaseSpec{ - transition: chatstate.TransitionFailIdle, - from: chatstate.StateW, - want: chatstate.StateE0, - apply: applyFailIdle, - assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) { - _ = result - after, err := f.DB.GetChatByID(ctx, seeded.chatID) - require.NoError(t, err) - require.Equal(t, database.ChatStatusError, after.Status) - require.True(t, after.LastError.Valid) - require.JSONEq(t, `{"message":"hook dispatch failed","kind":"generic","retryable":false}`, string(after.LastError.RawMessage)) - require.Equal(t, base.chat.WorkerID, after.WorkerID) - require.Equal(t, base.chat.RunnerID, after.RunnerID) - require.Equal(t, base.historyVersion, after.HistoryVersion) - require.Equal(t, base.queueVersion, after.QueueVersion) - }, - } -} - func reconcileInvalidStateCase(want chatstate.ExecutionState, shape queueShape) transitionCaseSpec { spec := transitionCaseSpec{ transition: chatstate.TransitionReconcileInvalidState, From 40c3ad2b5cc15c08bba5fbf3171b1e0205c1052b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:29:23 +0000 Subject: [PATCH 17/86] refactor(coderd): route message content rewrites through the chat machine Add Tx.UpdateMessageContent so content rewrites happen inside a ChatMachine.Update transaction, where the revision trigger stamps the allocated snapshot version, and document the constraint on the query. --- coderd/database/querier.go | 4 ++++ coderd/database/queries.sql.go | 4 ++++ coderd/database/queries/chats.sql | 4 ++++ coderd/x/chatd/chatstate/transitions.go | 16 ++++++++++++++++ 4 files changed, 28 insertions(+) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e879a613594..d20a4a8d3ef 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1419,6 +1419,10 @@ type sqlcQuerier interface { // Two summary workers using the same freshness marker are last-write-wins. UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) + // Must run inside a chatstate.ChatMachine.Update transaction (use + // Tx.UpdateMessageContent) so the revision trigger stamps the + // allocated snapshot version; an out-of-band call breaks the + // generation fence. // Preserve NULL as the backfill marker; otherwise refresh search_tsv // from the new content. UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4e8ccbe197c..c6c27afc93e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12097,6 +12097,10 @@ type UpdateChatMessageContentByIDParams struct { ID int64 `db:"id" json:"id"` } +// Must run inside a chatstate.ChatMachine.Update transaction (use +// Tx.UpdateMessageContent) so the revision trigger stamps the +// allocated snapshot version; an out-of-band call breaks the +// generation fence. // Preserve NULL as the backfill marker; otherwise refresh search_tsv // from the new content. func (q *sqlQuerier) UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) error { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 672d799e8f2..2510da9ca83 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -382,6 +382,10 @@ WHERE AND deleted = false; -- name: UpdateChatMessageContentByID :exec +-- Must run inside a chatstate.ChatMachine.Update transaction (use +-- Tx.UpdateMessageContent) so the revision trigger stamps the +-- allocated snapshot version; an out-of-band call breaks the +-- generation fence. -- Preserve NULL as the backfill marker; otherwise refresh search_tsv -- from the new content. UPDATE chat_messages diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index ad1406e34ef..8f6725bfc21 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -212,6 +212,22 @@ func (tx *Tx) insertMessages(messages []Message) ([]database.ChatMessage, error) return inserted, nil } +// UpdateMessageContent rewrites one message's content inside the +// transaction so the revision trigger stamps the snapshot version the +// surrounding [ChatMachine.Update] allocated and the enclosing +// transition's publish delivers the rewrite. Calling the store query +// outside a machine transaction would stamp a stale revision and break +// the generation fence. +func (tx *Tx) UpdateMessageContent(messageID int64, content json.RawMessage) error { + if err := tx.store.UpdateChatMessageContentByID(tx.ctx, database.UpdateChatMessageContentByIDParams{ + Content: content, + ID: messageID, + }); err != nil { + return xerrors.Errorf("update message content: %w", err) + } + return nil +} + // clearQueue deletes all queued messages on the chat and returns the // IDs that were deleted in queue order. func (tx *Tx) clearQueue() ([]int64, error) { From 1241b5fec958e029dece42c110732eba4cae6b5e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:14:14 +0000 Subject: [PATCH 18/86] fix(coderd): scope chat message content rewrites to the locked chat UpdateChatMessageContentByID now filters by chat_id and deleted, and returns the affected row count so Tx.UpdateMessageContent fails on stale, deleted, or cross-chat message IDs instead of silently rewriting another chat's history. --- coderd/database/dbauthz/dbauthz.go | 8 ++++---- coderd/database/dbauthz/dbauthz_test.go | 6 +++--- coderd/database/dbmetrics/querymetrics.go | 6 +++--- coderd/database/dbmock/dbmock.go | 7 ++++--- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 17 +++++++++++++++-- coderd/database/queries.sql.go | 14 ++++++++++---- coderd/database/queries/chats.sql | 6 ++++-- coderd/x/chatd/chatstate/transitions.go | 13 ++++++++++--- 9 files changed, 54 insertions(+), 25 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ed3ac1df506..8d8d1dcc8ab 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7397,17 +7397,17 @@ func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.Updat return q.db.UpdateChatMCPServerIDs(ctx, arg) } -func (q *querier) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) error { +func (q *querier) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) (int64, error) { message, err := q.db.GetChatMessageByID(ctx, arg.ID) if err != nil { - return err + return 0, err } chat, err := q.db.GetChatByID(ctx, message.ChatID) if err != nil { - return err + return 0, err } if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return err + return 0, err } return q.db.UpdateChatMessageContentByID(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 2dd4989bd73..dbc697017c4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1283,11 +1283,11 @@ func (s *MethodTestSuite) TestChats() { s.Run("UpdateChatMessageContentByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) message := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) - arg := testutil.Fake(s.T(), faker, database.UpdateChatMessageContentByIDParams{ID: message.ID}) + arg := testutil.Fake(s.T(), faker, database.UpdateChatMessageContentByIDParams{ID: message.ID, ChatID: chat.ID}) dbm.EXPECT().GetChatMessageByID(gomock.Any(), message.ID).Return(message, nil).AnyTimes() dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().UpdateChatMessageContentByID(gomock.Any(), arg).Return(nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() + dbm.EXPECT().UpdateChatMessageContentByID(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) })) s.Run("InsertChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 8e938056a97..75a804af678 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5265,12 +5265,12 @@ func (m queryMetricsStore) UpdateChatMCPServerIDs(ctx context.Context, arg datab return r0, r1 } -func (m queryMetricsStore) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) error { +func (m queryMetricsStore) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) (int64, error) { start := time.Now() - r0 := m.s.UpdateChatMessageContentByID(ctx, arg) + r0, r1 := m.s.UpdateChatMessageContentByID(ctx, arg) m.queryLatencies.WithLabelValues("UpdateChatMessageContentByID").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatMessageContentByID").Inc() - return r0 + return r0, r1 } func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9972ba77b12..9f1d2331bb6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9923,11 +9923,12 @@ func (mr *MockStoreMockRecorder) UpdateChatMCPServerIDs(ctx, arg any) *gomock.Ca } // UpdateChatMessageContentByID mocks base method. -func (m *MockStore) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) error { +func (m *MockStore) UpdateChatMessageContentByID(ctx context.Context, arg database.UpdateChatMessageContentByIDParams) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateChatMessageContentByID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 } // UpdateChatMessageContentByID indicates an expected call of UpdateChatMessageContentByID. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d20a4a8d3ef..c266d1e286f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1425,7 +1425,7 @@ type sqlcQuerier interface { // generation fence. // Preserve NULL as the backfill marker; otherwise refresh search_tsv // from the new content. - UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) error + UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) (int64, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 6399ea63bf4..5403f49831e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -16933,23 +16933,36 @@ func TestUpdateChatMessageContentByIDRefreshesSearchTsv(t *testing.T) { require.NoError(t, err) require.True(t, searchTsv(indexedMsg.ID).Valid) - err = store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ + rows, err := store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ ID: indexedMsg.ID, + ChatID: chat.ID, Content: json.RawMessage(`[{"type":"text","text":"replacement override phrase"}]`), }) require.NoError(t, err) + require.EqualValues(t, 1, rows) tsv := searchTsv(indexedMsg.ID) require.True(t, tsv.Valid) require.Contains(t, tsv.String, "replacement") require.NotContains(t, tsv.String, "original") + rows, err = store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ + ID: indexedMsg.ID, + ChatID: uuid.New(), + Content: json.RawMessage(`[{"type":"text","text":"wrong chat phrase"}]`), + }) + require.NoError(t, err) + require.Zero(t, rows) + require.Contains(t, searchTsv(indexedMsg.ID).String, "replacement") + pendingMsg := insertMsg("pending secret phrase") - err = store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ + rows, err = store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ ID: pendingMsg.ID, + ChatID: chat.ID, Content: json.RawMessage(`[{"type":"text","text":"pending replacement phrase"}]`), }) require.NoError(t, err) + require.EqualValues(t, 1, rows) require.False(t, searchTsv(pendingMsg.ID).Valid) _, err = store.BackfillChatMessagesSearchTsv(ctx, 1000) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c6c27afc93e..4104c516b0d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12080,7 +12080,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM return i, err } -const updateChatMessageContentByID = `-- name: UpdateChatMessageContentByID :exec +const updateChatMessageContentByID = `-- name: UpdateChatMessageContentByID :execrows UPDATE chat_messages SET content = $1::jsonb, search_tsv = CASE @@ -12090,11 +12090,14 @@ SET content = $1::jsonb, ''::tsvector) END WHERE id = $2::bigint + AND chat_id = $3::uuid + AND deleted = false ` type UpdateChatMessageContentByIDParams struct { Content json.RawMessage `db:"content" json:"content"` ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` } // Must run inside a chatstate.ChatMachine.Update transaction (use @@ -12103,9 +12106,12 @@ type UpdateChatMessageContentByIDParams struct { // generation fence. // Preserve NULL as the backfill marker; otherwise refresh search_tsv // from the new content. -func (q *sqlQuerier) UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) error { - _, err := q.db.ExecContext(ctx, updateChatMessageContentByID, arg.Content, arg.ID) - return err +func (q *sqlQuerier) UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateChatMessageContentByID, arg.Content, arg.ID, arg.ChatID) + if err != nil { + return 0, err + } + return result.RowsAffected() } const updateChatPinOrder = `-- name: UpdateChatPinOrder :exec diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 2510da9ca83..b374eff4190 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -381,7 +381,7 @@ WHERE id = @id::bigint AND deleted = false; --- name: UpdateChatMessageContentByID :exec +-- name: UpdateChatMessageContentByID :execrows -- Must run inside a chatstate.ChatMachine.Update transaction (use -- Tx.UpdateMessageContent) so the revision trigger stamps the -- allocated snapshot version; an out-of-band call breaks the @@ -396,7 +396,9 @@ SET content = @content::jsonb, to_tsvector('simple', chat_message_search_text(@content::jsonb)), ''::tsvector) END -WHERE id = @id::bigint; +WHERE id = @id::bigint + AND chat_id = @chat_id::uuid + AND deleted = false; -- name: GetChatMessagesByChatID :many SELECT diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 8f6725bfc21..dfd07fe4c72 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -217,14 +217,21 @@ func (tx *Tx) insertMessages(messages []Message) ([]database.ChatMessage, error) // surrounding [ChatMachine.Update] allocated and the enclosing // transition's publish delivers the rewrite. Calling the store query // outside a machine transaction would stamp a stale revision and break -// the generation fence. +// the generation fence. The rewrite is scoped to the locked chat and +// fails when the message is missing, deleted, or owned by another +// chat. func (tx *Tx) UpdateMessageContent(messageID int64, content json.RawMessage) error { - if err := tx.store.UpdateChatMessageContentByID(tx.ctx, database.UpdateChatMessageContentByIDParams{ + rows, err := tx.store.UpdateChatMessageContentByID(tx.ctx, database.UpdateChatMessageContentByIDParams{ Content: content, ID: messageID, - }); err != nil { + ChatID: tx.chatID, + }) + if err != nil { return xerrors.Errorf("update message content: %w", err) } + if rows != 1 { + return xerrors.Errorf("update message content: message %d not found in chat %s", messageID, tx.chatID) + } return nil } From 05a9b25b622b2a242795effc6db8a58a8100c5dd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:18:48 +0000 Subject: [PATCH 19/86] docs(coderd/x/chatd): document the message content rewrite primitive --- coderd/x/chatd/ARCHITECTURE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 33360846fa9..ae4a3a05b4e 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -274,6 +274,8 @@ Each row in `chat_messages` has a `revision` column. It stores the `chats.snapsh Message revision triggers depend on the transition invariant that `snapshot_version` is allocated immediately after the chat row is locked and before any message mutation happens. Runtime code must not assign `chat_messages.revision` directly, and every `chat_messages` insert or update must go through a state machine transition: the triggers advance `history_version` on any write, so an out-of-band write (even of a hidden or soft-deleted row) moves `history_version` without a matching `snapshot_version` bump and breaks the fence of an in-flight generation task. +Rewriting an existing message's content follows the same rule through `Tx.UpdateMessageContent`. It is not a named transition; it is a primitive available only inside an open machine transaction, alongside the named transitions that commit with it, so the revision trigger stamps the snapshot version that transaction allocated and the transaction's publish makes the rewrite observable to streams and workers through the normal `history_version` fence. The rewrite is scoped to the locked chat and fails when the target message is missing, deleted, or belongs to another chat. Callers must never issue the underlying store query outside a machine transaction: it would stamp a stale revision and break the generation fence. + A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: ```sql From 1adbbe66b4c1f7002606d9348a317b9588fcb2d4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:25:09 +0000 Subject: [PATCH 20/86] style(coderd): shorten verbose comments --- coderd/database/querier.go | 9 +++------ coderd/database/queries.sql.go | 9 +++------ coderd/database/queries/chats.sql | 9 +++------ coderd/x/chatd/chatstate/transitions.go | 11 +++-------- 4 files changed, 12 insertions(+), 26 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c266d1e286f..e119b8bdba3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1419,12 +1419,9 @@ type sqlcQuerier interface { // Two summary workers using the same freshness marker are last-write-wins. UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) - // Must run inside a chatstate.ChatMachine.Update transaction (use - // Tx.UpdateMessageContent) so the revision trigger stamps the - // allocated snapshot version; an out-of-band call breaks the - // generation fence. - // Preserve NULL as the backfill marker; otherwise refresh search_tsv - // from the new content. + // Use Tx.UpdateMessageContent so the revision trigger stamps the allocated + // snapshot version and preserves the generation fence. Preserve a NULL + // search_tsv backfill marker; otherwise refresh it from the new content. UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) (int64, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4104c516b0d..19366324dfd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12100,12 +12100,9 @@ type UpdateChatMessageContentByIDParams struct { ChatID uuid.UUID `db:"chat_id" json:"chat_id"` } -// Must run inside a chatstate.ChatMachine.Update transaction (use -// Tx.UpdateMessageContent) so the revision trigger stamps the -// allocated snapshot version; an out-of-band call breaks the -// generation fence. -// Preserve NULL as the backfill marker; otherwise refresh search_tsv -// from the new content. +// Use Tx.UpdateMessageContent so the revision trigger stamps the allocated +// snapshot version and preserves the generation fence. Preserve a NULL +// search_tsv backfill marker; otherwise refresh it from the new content. func (q *sqlQuerier) UpdateChatMessageContentByID(ctx context.Context, arg UpdateChatMessageContentByIDParams) (int64, error) { result, err := q.db.ExecContext(ctx, updateChatMessageContentByID, arg.Content, arg.ID, arg.ChatID) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index b374eff4190..18b0e66c9e0 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -382,12 +382,9 @@ WHERE AND deleted = false; -- name: UpdateChatMessageContentByID :execrows --- Must run inside a chatstate.ChatMachine.Update transaction (use --- Tx.UpdateMessageContent) so the revision trigger stamps the --- allocated snapshot version; an out-of-band call breaks the --- generation fence. --- Preserve NULL as the backfill marker; otherwise refresh search_tsv --- from the new content. +-- Use Tx.UpdateMessageContent so the revision trigger stamps the allocated +-- snapshot version and preserves the generation fence. Preserve a NULL +-- search_tsv backfill marker; otherwise refresh it from the new content. UPDATE chat_messages SET content = @content::jsonb, search_tsv = CASE diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index dfd07fe4c72..e7390ffd6b8 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -212,14 +212,9 @@ func (tx *Tx) insertMessages(messages []Message) ([]database.ChatMessage, error) return inserted, nil } -// UpdateMessageContent rewrites one message's content inside the -// transaction so the revision trigger stamps the snapshot version the -// surrounding [ChatMachine.Update] allocated and the enclosing -// transition's publish delivers the rewrite. Calling the store query -// outside a machine transaction would stamp a stale revision and break -// the generation fence. The rewrite is scoped to the locked chat and -// fails when the message is missing, deleted, or owned by another -// chat. +// UpdateMessageContent rewrites one message in the current chat. It relies on +// [ChatMachine.Update] to stamp the allocated snapshot version and publish +// the rewrite through the normal generation fence. func (tx *Tx) UpdateMessageContent(messageID int64, content json.RawMessage) error { rows, err := tx.store.UpdateChatMessageContentByID(tx.ctx, database.UpdateChatMessageContentByIDParams{ Content: content, From 6a04447f67c8d8234a91e1cdf30b1ac72fc99451 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:29:00 +0000 Subject: [PATCH 21/86] docs(coderd/x/chatd): keep the message-rewrite contract high level --- coderd/x/chatd/ARCHITECTURE.md | 2 +- coderd/x/chatd/chatstate/transitions.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index ae4a3a05b4e..46edb4cd28f 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -274,7 +274,7 @@ Each row in `chat_messages` has a `revision` column. It stores the `chats.snapsh Message revision triggers depend on the transition invariant that `snapshot_version` is allocated immediately after the chat row is locked and before any message mutation happens. Runtime code must not assign `chat_messages.revision` directly, and every `chat_messages` insert or update must go through a state machine transition: the triggers advance `history_version` on any write, so an out-of-band write (even of a hidden or soft-deleted row) moves `history_version` without a matching `snapshot_version` bump and breaks the fence of an in-flight generation task. -Rewriting an existing message's content follows the same rule through `Tx.UpdateMessageContent`. It is not a named transition; it is a primitive available only inside an open machine transaction, alongside the named transitions that commit with it, so the revision trigger stamps the snapshot version that transaction allocated and the transaction's publish makes the rewrite observable to streams and workers through the normal `history_version` fence. The rewrite is scoped to the locked chat and fails when the target message is missing, deleted, or belongs to another chat. Callers must never issue the underlying store query outside a machine transaction: it would stamp a stale revision and break the generation fence. +Rewriting an existing message's content follows the same rule. It is not a named transition but a primitive available only inside an open machine transaction, so it commits under the snapshot version that transaction allocated, together with the named transitions around it. A rewrite is scoped to the locked chat and fails when the target message is missing, deleted, or belongs to another chat. Rewriting content outside a machine transaction stamps a stale revision and breaks the generation fence. A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index e7390ffd6b8..63a44fffe4b 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -65,8 +65,7 @@ func CreateChat( } // CreateChatWithID creates a chat using a caller-minted ID so -// admission-time work (such as lifecycle hook dispatch) can reference -// the chat before it exists. +// admission-time work can reference the chat before it exists. func CreateChatWithID( ctx context.Context, store database.Store, From fb07535423ceae77fa4f5a2094d996be623bda3d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:36:23 +0000 Subject: [PATCH 22/86] refactor(coderd/x/chatd/chatstate): extract shared tool-result validation --- coderd/x/chatd/chatstate/toolresults.go | 29 +++++++ coderd/x/chatd/chatstate/toolresults_test.go | 85 ++++++++++++++++++++ coderd/x/chatd/chatstate/transitions.go | 55 +++++-------- 3 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 coderd/x/chatd/chatstate/toolresults.go create mode 100644 coderd/x/chatd/chatstate/toolresults_test.go diff --git a/coderd/x/chatd/chatstate/toolresults.go b/coderd/x/chatd/chatstate/toolresults.go new file mode 100644 index 00000000000..ab543abd7e6 --- /dev/null +++ b/coderd/x/chatd/chatstate/toolresults.go @@ -0,0 +1,29 @@ +package chatstate + +import "encoding/json" + +// ValidateToolResults returns the first validation error for results +// submitted against pending dynamic tool calls. +func ValidateToolResults(results []ToolResultInput, pending map[string]string) *ToolResultValidationError { + submitted := make(map[string]struct{}, len(results)) + for _, result := range results { + if _, dup := submitted[result.ToolCallID]; dup { + return &ToolResultValidationError{Cause: ErrToolResultDuplicate, ToolCallID: result.ToolCallID} + } + if !json.Valid(result.Output) { + return &ToolResultValidationError{Cause: ErrToolResultInvalidJSON, ToolCallID: result.ToolCallID} + } + submitted[result.ToolCallID] = struct{}{} + } + for toolCallID := range pending { + if _, ok := submitted[toolCallID]; !ok { + return &ToolResultValidationError{Cause: ErrToolResultMissing, ToolCallID: toolCallID} + } + } + for toolCallID := range submitted { + if _, ok := pending[toolCallID]; !ok { + return &ToolResultValidationError{Cause: ErrToolResultUnexpected, ToolCallID: toolCallID} + } + } + return nil +} diff --git a/coderd/x/chatd/chatstate/toolresults_test.go b/coderd/x/chatd/chatstate/toolresults_test.go new file mode 100644 index 00000000000..ded7ecbed38 --- /dev/null +++ b/coderd/x/chatd/chatstate/toolresults_test.go @@ -0,0 +1,85 @@ +package chatstate_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" +) + +func TestValidateToolResults(t *testing.T) { + t.Parallel() + + // Validation order is part of the contract because callers surface only + // the first violation. + pending := map[string]string{"call_a": "execute", "call_b": "read_file"} + resultA := chatstate.ToolResultInput{ToolCallID: "call_a", Output: json.RawMessage(`{"ok":true}`)} + resultB := chatstate.ToolResultInput{ToolCallID: "call_b", Output: json.RawMessage(`"done"`)} + badJSON := chatstate.ToolResultInput{ToolCallID: "call_b", Output: json.RawMessage(`{`)} + resultC := chatstate.ToolResultInput{ToolCallID: "call_c", Output: json.RawMessage(`{}`)} + + cases := []struct { + name string + results []chatstate.ToolResultInput + wantCause error + wantToolCallID string + }{ + { + name: "Complete", + results: []chatstate.ToolResultInput{resultA, resultB}, + }, + { + name: "Duplicate", + results: []chatstate.ToolResultInput{resultA, resultB, resultA}, + wantCause: chatstate.ErrToolResultDuplicate, + wantToolCallID: "call_a", + }, + { + name: "InvalidJSON", + results: []chatstate.ToolResultInput{resultA, badJSON}, + wantCause: chatstate.ErrToolResultInvalidJSON, + wantToolCallID: "call_b", + }, + { + name: "Missing", + results: []chatstate.ToolResultInput{resultA}, + wantCause: chatstate.ErrToolResultMissing, + wantToolCallID: "call_b", + }, + { + name: "Unexpected", + results: []chatstate.ToolResultInput{resultA, resultB, resultC}, + wantCause: chatstate.ErrToolResultUnexpected, + wantToolCallID: "call_c", + }, + { + name: "PerResultRulesOutrankSweeps", + results: []chatstate.ToolResultInput{resultC, badJSON}, + wantCause: chatstate.ErrToolResultInvalidJSON, + wantToolCallID: "call_b", + }, + { + name: "MissingOutranksUnexpected", + results: []chatstate.ToolResultInput{resultA, resultC}, + wantCause: chatstate.ErrToolResultMissing, + wantToolCallID: "call_b", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + invalid := chatstate.ValidateToolResults(test.results, pending) + if test.wantCause == nil { + require.Nil(t, invalid) + return + } + require.NotNil(t, invalid) + require.ErrorIs(t, invalid, test.wantCause) + require.Equal(t, test.wantToolCallID, invalid.ToolCallID) + }) + } +} diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 63a44fffe4b..4333af0a18c 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -961,41 +961,11 @@ func (tx *Tx) CompleteRequiresAction(input CompleteRequiresActionInput) (Complet if err != nil { return CompleteRequiresActionResult{}, err } - submitted := make(map[string]ToolResultInput, len(input.Results)) - for _, r := range input.Results { - if _, dup := submitted[r.ToolCallID]; dup { - return CompleteRequiresActionResult{}, newTransitionErrorWithCause( - TransitionCompleteRequiresAction, from, - &ToolResultValidationError{Cause: ErrToolResultDuplicate, ToolCallID: r.ToolCallID}, - "duplicate tool_call_id submitted", - ) - } - if !json.Valid(r.Output) { - return CompleteRequiresActionResult{}, newTransitionErrorWithCause( - TransitionCompleteRequiresAction, from, - &ToolResultValidationError{Cause: ErrToolResultInvalidJSON, ToolCallID: r.ToolCallID}, - "tool result output is not valid JSON", - ) - } - submitted[r.ToolCallID] = r - } - for id := range pending { - if _, ok := submitted[id]; !ok { - return CompleteRequiresActionResult{}, newTransitionErrorWithCause( - TransitionCompleteRequiresAction, from, - &ToolResultValidationError{Cause: ErrToolResultMissing, ToolCallID: id}, - "submitted tool results do not match pending tool calls", - ) - } - } - for id := range submitted { - if _, ok := pending[id]; !ok { - return CompleteRequiresActionResult{}, newTransitionErrorWithCause( - TransitionCompleteRequiresAction, from, - &ToolResultValidationError{Cause: ErrToolResultUnexpected, ToolCallID: id}, - "submitted tool_call_id does not match a pending dynamic tool call", - ) - } + if invalid := ValidateToolResults(input.Results, pending); invalid != nil { + return CompleteRequiresActionResult{}, newTransitionErrorWithCause( + TransitionCompleteRequiresAction, from, invalid, + toolResultTransitionMessage(invalid.Cause), + ) } messages := make([]Message, 0, len(input.Results)) for _, r := range input.Results { @@ -1038,6 +1008,21 @@ func (tx *Tx) CompleteRequiresAction(input CompleteRequiresActionInput) (Complet }, nil } +func toolResultTransitionMessage(cause error) string { + switch { + case errors.Is(cause, ErrToolResultDuplicate): + return "duplicate tool_call_id submitted" + case errors.Is(cause, ErrToolResultInvalidJSON): + return "tool result output is not valid JSON" + case errors.Is(cause, ErrToolResultMissing): + return "submitted tool results do not match pending tool calls" + case errors.Is(cause, ErrToolResultUnexpected): + return "submitted tool_call_id does not match a pending dynamic tool call" + default: + return "submitted tool results are invalid" + } +} + // AcquireInput configures [Tx.Acquire]. type AcquireInput struct { WorkerID uuid.UUID From d3b92fb56014e1952b324f3589a1ecb931be6208 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:08:23 +0000 Subject: [PATCH 23/86] docs(coderd/x/chatd): note suffix messages in the CompleteRequiresAction contract --- coderd/x/chatd/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 46edb4cd28f..2b0fbafa81c 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -115,7 +115,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `DeleteQueuedMessage(qid)` removes one queued message without changing the active history. - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. -- `CompleteRequiresAction(results)` inserts submitted tool-result messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. +- `CompleteRequiresAction(results)` inserts submitted tool-result messages followed by any caller-provided suffix messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. - `RequestCompaction` records a manual compaction request on an idle chat by setting `compaction_requested_at` and landing in `running` without inserting any message. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). ### Transitions used by the chat worker From 26c19540f411c55f34cc23c04a30336fe51c524b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:34:15 +0000 Subject: [PATCH 24/86] feat: wire chat lifecycle hooks into chatd Dispatch lifecycle events from chatd to a configured consumer and apply the responses, gated by the agent-lifecycle-hooks experiment: - Events: session_start, user_prompt_submit, pre_tool_use, post_tool_use, pre_compact, post_compact, and stop. - user_prompt_submit dispatches once per submission at admission and folds its effects into the stored prompt as typed message parts: user parts, then model-only hook context, then a user-visible hook notice. Hook context is stripped from client-facing conversions and hook notices are excluded from model prompts. - pre_tool_use allow can override tool input; deny produces a synthetic denied tool result carrying returned model context. - Dispatch is fail-closed: failures reject the triggering request or move the chat to the error state in the same transaction as the affected step. - Successful pre_tool_use decisions are cached in process memory so same-process recovery can reuse them; correctness never depends on the cache. - Add chat-hook-url, chat-hook-secret, chat-hook-timeout, and chat-hook-enabled deployment options with startup validation. --- cli/testdata/coder_server_--help.golden | 14 + cli/testdata/server-config.yaml.golden | 11 + coderd/apidoc/docs.go | 57 +- coderd/apidoc/swagger.json | 57 +- coderd/coderd.go | 23 + coderd/database/db2sdk/db2sdk.go | 10 +- coderd/exp_chats.go | 171 +++- coderd/exp_chats_hooks_test.go | 574 ++++++++++++ coderd/exp_chats_test.go | 5 +- coderd/x/chatd/ARCHITECTURE.md | 12 +- coderd/x/chatd/chatd.go | 423 +++++++-- coderd/x/chatd/chatloop/chatloop.go | 1 + coderd/x/chatd/chatloop/compaction.go | 12 +- coderd/x/chatd/chatprompt/chatprompt.go | 10 + coderd/x/chatd/chattest/openai.go | 40 + coderd/x/chatd/compaction_hooks_test.go | 240 +++++ coderd/x/chatd/create_hooks_test.go | 238 +++++ coderd/x/chatd/generation.go | 542 +++++++++-- coderd/x/chatd/generation_internal_test.go | 66 ++ coderd/x/chatd/hookreplay.go | 163 ++++ coderd/x/chatd/hooks.go | 745 +++++++++++++++ coderd/x/chatd/hooks_internal_test.go | 274 ++++++ coderd/x/chatd/hooks_test.go | 657 +++++++++++++ coderd/x/chatd/message_conversion.go | 32 +- coderd/x/chatd/message_conversion_test.go | 32 + coderd/x/chatd/options.go | 117 ++- coderd/x/chatd/post_tool_use_test.go | 520 +++++++++++ coderd/x/chatd/pre_tool_use_test.go | 865 ++++++++++++++++++ coderd/x/chatd/runner.go | 4 + coderd/x/chatd/runner_test.go | 1 + coderd/x/chatd/stop_test.go | 181 ++++ coderd/x/chatd/subagent.go | 48 +- coderd/x/chatd/subagent_internal_test.go | 150 +++ coderd/x/chatd/tasks.go | 1 + codersdk/chats.go | 43 +- codersdk/chats_test.go | 14 +- codersdk/deployment.go | 85 +- codersdk/deployment_test.go | 113 +++ docs/admin/setup/chat-lifecycle-hooks.md | 196 ++++ docs/manifest.json | 6 + docs/reference/api/chats.md | 181 +++- docs/reference/api/general.md | 18 +- docs/reference/api/schemas.md | 294 +++++- docs/reference/cli/server.md | 41 + .../cli/testdata/coder_server_--help.golden | 14 + site/src/api/typesGenerated.ts | 51 +- .../ConversationTimeline.stories.tsx | 2 + .../ChatConversation/messageHelpers.test.ts | 1 + .../ChatConversation/messageHelpers.ts | 1 + .../ChatConversation/messageParsing.ts | 7 + .../ChatConversation/streamState.ts | 3 + .../components/ChatConversation/types.ts | 1 + 52 files changed, 7134 insertions(+), 233 deletions(-) create mode 100644 coderd/exp_chats_hooks_test.go create mode 100644 coderd/x/chatd/compaction_hooks_test.go create mode 100644 coderd/x/chatd/create_hooks_test.go create mode 100644 coderd/x/chatd/hookreplay.go create mode 100644 coderd/x/chatd/hooks.go create mode 100644 coderd/x/chatd/hooks_internal_test.go create mode 100644 coderd/x/chatd/hooks_test.go create mode 100644 coderd/x/chatd/post_tool_use_test.go create mode 100644 coderd/x/chatd/pre_tool_use_test.go create mode 100644 coderd/x/chatd/stop_test.go create mode 100644 docs/admin/setup/chat-lifecycle-hooks.md diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 8abc40867e2..9da5b1df7bd 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -281,6 +281,20 @@ Configure the background chat processing daemon. Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. + --chat-hook-enabled bool, $CODER_CHAT_HOOK_ENABLED (default: true) + Whether to dispatch chat agent lifecycle hooks when a hook URL is + configured. Requires the agent-lifecycle-hooks experiment. + + --chat-hook-secret string, $CODER_CHAT_HOOK_SECRET + Shared secret used to sign chat agent lifecycle hook JWTs. + + --chat-hook-timeout duration, $CODER_CHAT_HOOK_TIMEOUT (default: 1.5s) + Maximum time to wait for a chat agent lifecycle hook response. + + --chat-hook-url url, $CODER_CHAT_HOOK_URL + HTTPS URL to receive chat agent lifecycle hook events. Hooks are + disabled when unset. Requires the agent-lifecycle-hooks experiment. + CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index a8f3d90eb32..18d3a89cdc1 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -803,6 +803,17 @@ chat: # opt-in settings. # (default: false, type: bool) debugLoggingEnabled: false + # HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when + # unset. Requires the agent-lifecycle-hooks experiment. + # (default: , type: url) + hookURL: + # Maximum time to wait for a chat agent lifecycle hook response. + # (default: 1.5s, type: duration) + hookTimeout: 1.5s + # Whether to dispatch chat agent lifecycle hooks when a hook URL is configured. + # Requires the agent-lifecycle-hooks experiment. + # (default: true, type: bool) + hookEnabled: true # Deprecated: AI Gateway routing is now the only routing path. Setting this value # has no effect. This option will be removed in a future release. # (default: true, type: bool) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 69411ed4491..be2196ed091 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17078,6 +17078,18 @@ const docTemplate = `{ }, "debug_logging_enabled": { "type": "boolean" + }, + "hook_enabled": { + "type": "boolean" + }, + "hook_secret": { + "type": "string" + }, + "hook_timeout": { + "type": "integer" + }, + "hook_url": { + "$ref": "#/definitions/serpent.URL" } } }, @@ -17328,7 +17340,8 @@ const docTemplate = `{ "usage_limit", "missing_key", "provider_disabled", - "content_filter" + "content_filter", + "hook_dispatch_failed" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -17341,7 +17354,8 @@ const docTemplate = `{ "ChatErrorKindUsageLimit", "ChatErrorKindMissingKey", "ChatErrorKindProviderDisabled", - "ChatErrorKindContentFilter" + "ChatErrorKindContentFilter", + "ChatErrorKindHookDispatchFailed" ] }, "codersdk.ChatFileMetadata": { @@ -17688,7 +17702,9 @@ const docTemplate = `{ "file", "file-reference", "context-file", - "skill" + "skill", + "hook-context", + "hook-notice" ], "x-enum-varnames": [ "ChatMessagePartTypeText", @@ -17699,7 +17715,9 @@ const docTemplate = `{ "ChatMessagePartTypeFile", "ChatMessagePartTypeFileReference", "ChatMessagePartTypeContextFile", - "ChatMessagePartTypeSkill" + "ChatMessagePartTypeSkill", + "ChatMessagePartTypeHookContext", + "ChatMessagePartTypeHookNotice" ] }, "codersdk.ChatMessageRole": { @@ -18453,6 +18471,13 @@ const docTemplate = `{ "message": { "$ref": "#/definitions/codersdk.ChatMessage" }, + "messages": { + "description": "Messages contains all user-visible messages inserted by the send, in\ninsertion order. A queued send on an errored chat may promote the\nprevious queue head, so clients must upsert the full batch.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatMessage" + } + }, "queued": { "type": "boolean" }, @@ -19886,9 +19911,23 @@ const docTemplate = `{ "codersdk.EditChatMessageResponse": { "type": "object", "properties": { + "deleted_message_ids": { + "description": "DeletedMessageIDs holds the IDs of previously visible messages the\nedit removed, including stale hook notices from the edited turn.\nClients should drop them from local caches.", + "type": "array", + "items": { + "type": "integer" + } + }, "message": { "$ref": "#/definitions/codersdk.ChatMessage" }, + "messages": { + "description": "Messages holds every user-visible message inserted by the edit, in\ninsertion order. Hook-generated suffix messages may follow Message,\nso clients must upsert the full batch.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatMessage" + } + }, "warnings": { "type": "array", "items": { @@ -19960,10 +19999,12 @@ const docTemplate = `{ "minimum-implicit-member", "ai-gateway-cost-control", "chat-advisor", - "chat-virtual-desktop" + "chat-virtual-desktop", + "agent-lifecycle-hooks" ], "x-enum-comments": { "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", + "ExperimentAgentLifecycleHooks": "Enables chat lifecycle hook webhooks for agent chats.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentChatAdvisor": "Enables the advisor tool for root agent chats.", "ExperimentChatVirtualDesktop": "Enables virtual desktop and computer use provider for agents.", @@ -19988,7 +20029,8 @@ const docTemplate = `{ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Enables AI Gateway cost control functionality.", "Enables the advisor tool for root agent chats.", - "Enables virtual desktop and computer use provider for agents." + "Enables virtual desktop and computer use provider for agents.", + "Enables chat lifecycle hook webhooks for agent chats." ], "x-enum-varnames": [ "ExperimentExample", @@ -20002,7 +20044,8 @@ const docTemplate = `{ "ExperimentMinimumImplicitMember", "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", - "ExperimentChatVirtualDesktop" + "ExperimentChatVirtualDesktop", + "ExperimentAgentLifecycleHooks" ] }, "codersdk.ExternalAPIKeyScopes": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 57d188b92b2..2088758ceb6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15347,6 +15347,18 @@ }, "debug_logging_enabled": { "type": "boolean" + }, + "hook_enabled": { + "type": "boolean" + }, + "hook_secret": { + "type": "string" + }, + "hook_timeout": { + "type": "integer" + }, + "hook_url": { + "$ref": "#/definitions/serpent.URL" } } }, @@ -15586,7 +15598,8 @@ "usage_limit", "missing_key", "provider_disabled", - "content_filter" + "content_filter", + "hook_dispatch_failed" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -15599,7 +15612,8 @@ "ChatErrorKindUsageLimit", "ChatErrorKindMissingKey", "ChatErrorKindProviderDisabled", - "ChatErrorKindContentFilter" + "ChatErrorKindContentFilter", + "ChatErrorKindHookDispatchFailed" ] }, "codersdk.ChatFileMetadata": { @@ -15940,7 +15954,9 @@ "file", "file-reference", "context-file", - "skill" + "skill", + "hook-context", + "hook-notice" ], "x-enum-varnames": [ "ChatMessagePartTypeText", @@ -15951,7 +15967,9 @@ "ChatMessagePartTypeFile", "ChatMessagePartTypeFileReference", "ChatMessagePartTypeContextFile", - "ChatMessagePartTypeSkill" + "ChatMessagePartTypeSkill", + "ChatMessagePartTypeHookContext", + "ChatMessagePartTypeHookNotice" ] }, "codersdk.ChatMessageRole": { @@ -16673,6 +16691,13 @@ "message": { "$ref": "#/definitions/codersdk.ChatMessage" }, + "messages": { + "description": "Messages contains all user-visible messages inserted by the send, in\ninsertion order. A queued send on an errored chat may promote the\nprevious queue head, so clients must upsert the full batch.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatMessage" + } + }, "queued": { "type": "boolean" }, @@ -18056,9 +18081,23 @@ "codersdk.EditChatMessageResponse": { "type": "object", "properties": { + "deleted_message_ids": { + "description": "DeletedMessageIDs holds the IDs of previously visible messages the\nedit removed, including stale hook notices from the edited turn.\nClients should drop them from local caches.", + "type": "array", + "items": { + "type": "integer" + } + }, "message": { "$ref": "#/definitions/codersdk.ChatMessage" }, + "messages": { + "description": "Messages holds every user-visible message inserted by the edit, in\ninsertion order. Hook-generated suffix messages may follow Message,\nso clients must upsert the full batch.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatMessage" + } + }, "warnings": { "type": "array", "items": { @@ -18126,10 +18165,12 @@ "minimum-implicit-member", "ai-gateway-cost-control", "chat-advisor", - "chat-virtual-desktop" + "chat-virtual-desktop", + "agent-lifecycle-hooks" ], "x-enum-comments": { "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", + "ExperimentAgentLifecycleHooks": "Enables chat lifecycle hook webhooks for agent chats.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentChatAdvisor": "Enables the advisor tool for root agent chats.", "ExperimentChatVirtualDesktop": "Enables virtual desktop and computer use provider for agents.", @@ -18154,7 +18195,8 @@ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Enables AI Gateway cost control functionality.", "Enables the advisor tool for root agent chats.", - "Enables virtual desktop and computer use provider for agents." + "Enables virtual desktop and computer use provider for agents.", + "Enables chat lifecycle hook webhooks for agent chats." ], "x-enum-varnames": [ "ExperimentExample", @@ -18168,7 +18210,8 @@ "ExperimentMinimumImplicitMember", "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", - "ExperimentChatVirtualDesktop" + "ExperimentChatVirtualDesktop", + "ExperimentAgentLifecycleHooks" ] }, "codersdk.ExternalAPIKeyScopes": { diff --git a/coderd/coderd.go b/coderd/coderd.go index ab0332f821a..b53bd7298cd 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -100,6 +100,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" + "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/coderd/x/gitsync" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpcsdk" @@ -878,6 +879,27 @@ func New(options *Options) *API { // the chat daemon stays nil and chat HTTP handlers return a // service-unavailable error with a clear remediation message. if options.DeploymentValues.AI.BridgeConfig.Enabled.Value() { + var hookDispatcher *chathooks.Dispatcher + chatConfig := options.DeploymentValues.AI.Chat + hooksConfigured := chatConfig.HookURL.String() != "" && chatConfig.HookEnabled.Value() + hooksExperimentEnabled := experiments.Enabled(codersdk.ExperimentAgentLifecycleHooks) + if hooksConfigured && !hooksExperimentEnabled { + options.Logger.Warn(ctx, "chat lifecycle hooks are configured but inactive; enable the agent-lifecycle-hooks experiment to activate them", + slog.F("experiment", codersdk.ExperimentAgentLifecycleHooks), + ) + } + if hooksConfigured && hooksExperimentEnabled { + hookDispatcher = chathooks.New( + options.Logger, + nil, + chatConfig.HookURL.String(), + chatConfig.HookSecret.Value(), + chatConfig.HookTimeout.Value(), + api.DeploymentID, + buildinfo.Version(), + options.PrometheusRegistry, + ) + } api.chatDaemon = chatd.New(options.Pubsub, chatd.Config{ Logger: options.Logger.Named("chatd"), Database: options.Database, @@ -897,6 +919,7 @@ func New(options *Options) *API { StartWorkspace: api.chatStartWorkspace, StopWorkspace: api.chatStopWorkspace, WebpushDispatcher: options.WebPushDispatcher, + HookDispatcher: hookDispatcher, UsageTracker: options.WorkspaceUsageTracker, PrometheusRegistry: options.PrometheusRegistry, OIDCTokenSource: oidcMCPSrc, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index de2de7586c6..dddca62b56e 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1638,11 +1638,17 @@ func chatMessageParts(m database.ChatMessage) ([]codersdk.ChatMessagePart, error if err != nil { return nil, err } - // Strip internal-only fields before API responses. + // Strip internal-only fields before API responses. Hook context + // parts are model-only and must never reach clients. + filtered := parts[:0] for i := range parts { + if parts[i].Type == codersdk.ChatMessagePartTypeHookContext { + continue + } parts[i].StripInternal() + filtered = append(filtered, parts[i]) } - return parts, nil + return filtered, nil } func nullUUIDPtr(v uuid.NullUUID) *uuid.UUID { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index aa95221609c..4e01e83ecd7 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -55,6 +55,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatfiles" + "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/coderd/x/gitsync" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" @@ -110,6 +111,17 @@ func writeChatUsageLimitExceeded( }) } +// Avoid returning raw dispatch errors, which may expose deployment internals. +func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, hookErr *chathooks.DispatchError) { + httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.ChatHookDispatchFailedResponse{ + Response: codersdk.Response{ + Message: "Chat lifecycle hook dispatch failed.", + Detail: fmt.Sprintf("Lifecycle hook dispatch %s failed (%s).", hookErr.DispatchID, hookErr.Class), + }, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) +} + func maybeWriteLimitErr(ctx context.Context, rw http.ResponseWriter, err error) bool { var limitErr *chatd.UsageLimitExceededError if errors.As(err, &limitErr) { @@ -1227,8 +1239,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } - // Cap the raw request body to prevent excessive memory use - // from large dynamic tool schemas. + // Limit memory used to decode dynamic tool schemas. r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes)) var req codersdk.CreateChatRequest @@ -1424,23 +1435,38 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { } chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: req.OrganizationID, - OwnerID: apiKey.UserID, - WorkspaceID: workspaceSelection.WorkspaceID, - Title: title, - ModelConfigID: modelConfigID, - ReasoningEffort: reasoningEffort, - PlanMode: planModeToNullChatPlanMode(req.PlanMode), - ClientType: clientType, - SystemPrompt: req.SystemPrompt, - InitialUserContent: contentBlocks, - MCPServerIDs: mcpServerIDs, - Labels: labels, - DynamicTools: dynamicToolsJSON, + OrganizationID: req.OrganizationID, + OwnerID: apiKey.UserID, + WorkspaceID: workspaceSelection.WorkspaceID, + Title: title, + TitleDerivedFromContent: true, + ModelConfigID: modelConfigID, + ReasoningEffort: reasoningEffort, + PlanMode: planModeToNullChatPlanMode(req.PlanMode), + ClientType: clientType, + SystemPrompt: req.SystemPrompt, + InitialUserContent: contentBlocks, + MCPServerIDs: mcpServerIDs, + Labels: labels, + DynamicTools: dynamicToolsJSON, // IMPORTANT: users can only create root chats at the time of writing. ParentChatID: uuid.NullUUID{}, }) if err != nil { + var denied *chatd.UserPromptDeniedError + if errors.As(err, &denied) { + message := denied.UserMessage + if message == "" { + message = "Chat creation denied by lifecycle hook." + } + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) + return + } + var hookErr *chathooks.DispatchError + if errors.As(err, &hookErr) { + writeChatHookDispatchFailed(ctx, rw, hookErr) + return + } if maybeWriteLimitErr(ctx, rw, err) { return } @@ -1483,10 +1509,22 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { return } - // Link any user-uploaded files referenced in the initial - // message to this newly created chat (best-effort; cap - // enforced in SQL). - unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, fileIDs) + linkFileIDs := fileIDs + if len(fileIDs) > 0 { + initialUser, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ + ChatID: chat.ID, + Role: database.ChatMessageRoleUser, + }) + if err != nil { + api.Logger.Warn(ctx, "load initial message for file linking", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + } else { + linkFileIDs = api.linkedFileIDsFromContent(ctx, initialUser, fileIDs) + } + } + unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, linkFileIDs) // Re-read the chat so the response reflects the authoritative // database state (file links are deduped in the join table). @@ -3381,6 +3419,20 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { }, ) if sendErr != nil { + var denied *chatd.UserPromptDeniedError + if errors.As(sendErr, &denied) { + message := denied.UserMessage + if message == "" { + message = "Chat message denied by lifecycle hook." + } + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) + return + } + var hookErr *chathooks.DispatchError + if errors.As(sendErr, &hookErr) { + writeChatHookDispatchFailed(ctx, rw, hookErr) + return + } if maybeWriteLimitErr(ctx, rw, sendErr) { return } @@ -3435,9 +3487,19 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } - // Link any user-uploaded files referenced in this message - // to the chat (best-effort; cap enforced in SQL). - unlinked, capExceeded := api.linkFilesToChat(ctx, chatID, fileIDs) + linkFileIDs := fileIDs + if sendResult.Queued { + if sendResult.QueuedMessage != nil { + linkFileIDs = api.linkedFileIDsFromContent(ctx, database.ChatMessage{ + Role: database.ChatMessageRoleUser, + ContentVersion: chatprompt.CurrentContentVersion, + Content: pqtype.NullRawMessage{RawMessage: sendResult.QueuedMessage.Content, Valid: true}, + }, fileIDs) + } + } else { + linkFileIDs = api.linkedFileIDsFromContent(ctx, sendResult.Message, fileIDs) + } + unlinked, capExceeded := api.linkFilesToChat(ctx, chatID, linkFileIDs) response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued} if sendResult.Queued { if sendResult.QueuedMessage != nil { @@ -3447,6 +3509,14 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { message := convertChatMessage(sendResult.Message) response.Message = &message } + // Return the full user-visible inserted batch. A queued send on an errored + // chat can promote the previous queue head, which clients must cache. + for _, inserted := range sendResult.InsertedMessages { + if inserted.Visibility == database.ChatMessageVisibilityModel { + continue + } + response.Messages = append(response.Messages, convertChatMessage(inserted)) + } if len(unlinked) > 0 { if capExceeded { response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked))) @@ -3550,6 +3620,20 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { ReasoningEffort: editReasoningEffort, }) if editErr != nil { + var denied *chatd.UserPromptDeniedError + if errors.As(editErr, &denied) { + message := denied.UserMessage + if message == "" { + message = "Chat message denied by lifecycle hook." + } + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) + return + } + var hookErr *chathooks.DispatchError + if errors.As(editErr, &hookErr) { + writeChatHookDispatchFailed(ctx, rw, hookErr) + return + } if maybeWriteLimitErr(ctx, rw, editErr) { return } @@ -3594,12 +3678,19 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { return } - // Link any user-uploaded files referenced in the edited - // message to the chat (best-effort; cap enforced in SQL). - unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, fileIDs) - response := codersdk.EditChatMessageResponse{ - Message: convertChatMessage(editResult.Message), + unlinked, capExceeded := api.linkFilesToChat(ctx, chat.ID, api.linkedFileIDsFromContent(ctx, editResult.Message, fileIDs)) + response := codersdk.EditChatMessageResponse{Message: convertChatMessage(editResult.Message)} + // Synthetic cancellations precede the replacement with lower IDs; + // clients that seed their transcript cache from this response need + // all user-visible inserted rows, or a stream reconnect with after_id set to the + // replacement would skip the earlier ones. + for _, inserted := range editResult.InsertedMessages { + if inserted.Visibility == database.ChatMessageVisibilityModel { + continue + } + response.Messages = append(response.Messages, convertChatMessage(inserted)) } + response.DeletedMessageIDs = editResult.DeletedMessageIDs if len(unlinked) > 0 { if capExceeded { response.Warnings = append(response.Warnings, fileLinkCapWarning(len(unlinked))) @@ -6818,6 +6909,29 @@ func createChatInputFromParts( return content, pasteData, fileIDs, nil } +// A prompt override may remove file parts, so derive links from persisted +// content. Fall back to request IDs if parsing fails. +func (api *API) linkedFileIDsFromContent(ctx context.Context, msg database.ChatMessage, requestFileIDs []uuid.UUID) []uuid.UUID { + if len(requestFileIDs) == 0 { + return nil + } + parts, err := chatprompt.ParseContent(msg) + if err != nil { + api.Logger.Warn(ctx, "parse persisted message for file linking", + slog.F("message_id", msg.ID), + slog.Error(err), + ) + return requestFileIDs + } + var ids []uuid.UUID + for _, part := range parts { + if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid { + ids = append(ids, part.FileID.UUID) + } + } + return ids +} + // linkFilesToChat inserts file-link rows into the chat_file_links // join table. Cap enforcement and dedup are handled atomically in // SQL. On success returns (nil, false). On failure returns the full @@ -8157,7 +8271,10 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) { if err != nil { var validationErr *chatd.ToolResultValidationError var conflictErr *chatd.ToolResultStatusConflictError + var hookErr *chathooks.DispatchError switch { + case errors.As(err, &hookErr): + writeChatHookDispatchFailed(ctx, rw, hookErr) case xerrors.Is(err, chatd.ErrChatArchived): httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Cannot submit tool results to an archived chat.", diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go new file mode 100644 index 00000000000..2828569e53e --- /dev/null +++ b/coderd/exp_chats_hooks_test.go @@ -0,0 +1,574 @@ +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/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 + }{ + { + name: "deny", + statusCode: http.StatusOK, + response: `{"permission":{"decision":"deny"},"user_message":"blocked by policy"}`, + wantStatus: http.StatusForbidden, + wantMessage: "blocked by policy", + }, + { + name: "dispatch failure", + statusCode: http.StatusInternalServerError, + wantStatus: http.StatusBadGateway, + }, + } + 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) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: user.OrganizationID, + ModelConfigID: &model.ID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "blocked prompt", + }}, + }) + sdkErr := coderdtest.SDKError(t, err) + require.Equal(t, test.wantStatus, sdkErr.StatusCode()) + if test.wantMessage != "" { + require.Equal(t, test.wantMessage, sdkErr.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 := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(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 := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(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)), "DENIED: secret reads are blocked") + + messages, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + var allowedCall *codersdk.ChatMessagePart + for _, message := range messages.Messages { + for i := range message.Content { + part := &message.Content[i] + if part.Type == codersdk.ChatMessagePartTypeToolCall && part.ToolCallID == allowedToolCallID { + allowedCall = part + } + } + } + require.NotNil(t, allowedCall) + require.JSONEq(t, `{"query":"public documentation"}`, string(allowedCall.Args)) + + 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.ChatMessagePartTypeText && 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 := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(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 + } + + redactedFile := uploadFile("redacted.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: redactedFile}, + }, + }) + require.NoError(t, err) + created, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.Empty(t, created.Files, "overridden create must not link dropped attachments") + + 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, 1) + require.Equal(t, keptFile, afterSend.Files[0].ID) + + coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) + + droppedFile := uploadFile("dropped.png") + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + {Type: codersdk.ChatInputPartTypeText, Text: "REDACTME send"}, + {Type: codersdk.ChatInputPartTypeFile, FileID: droppedFile}, + }, + }) + require.NoError(t, err) + afterOverride, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, afterOverride.Files, 1, "overridden send must not link dropped attachments") + require.Equal(t, keptFile, afterOverride.Files[0].ID) +} + +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 := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(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.ChatMessagePartTypeText && part.Text == "session notice" { + sessionNoticeFound = true + } + } + } + require.True(t, sessionNoticeFound) +} diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 09d934b341a..ceeaf6a3215 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -75,6 +75,7 @@ func newChatTestOptions( values.Experiments = serpent.StringArray{ string(codersdk.ExperimentChatAdvisor), string(codersdk.ExperimentChatVirtualDesktop), + string(codersdk.ExperimentAgentLifecycleHooks), } } @@ -7275,7 +7276,6 @@ func TestSendMessageWithModelOverrideUpdatesLastModelConfigID(t *testing.T) { }) require.NoError(t, err) require.False(t, resp.Queued) - require.NotNil(t, resp.Message) require.NotNil(t, resp.Message.ModelConfigID) require.Equal(t, modelConfigB.ID, *resp.Message.ModelConfigID) @@ -7556,7 +7556,6 @@ func TestSubsequentSendWithoutOverrideUsesPersistedModel(t *testing.T) { }) require.NoError(t, err) require.False(t, resp.Queued) - require.NotNil(t, resp.Message) require.NotNil(t, resp.Message.ModelConfigID) require.Equal(t, modelConfigB.ID, *resp.Message.ModelConfigID) @@ -8119,7 +8118,6 @@ func TestChatMessageWithFiles(t *testing.T) { if resp.Queued { require.NotNil(t, resp.QueuedMessage) } else { - require.NotNil(t, resp.Message) require.Equal(t, codersdk.ChatMessageRoleUser, resp.Message.Role) } }) @@ -8167,7 +8165,6 @@ func TestChatMessageWithFiles(t *testing.T) { if resp.Queued { require.NotNil(t, resp.QueuedMessage) } else { - require.NotNil(t, resp.Message) require.Equal(t, codersdk.ChatMessageRoleUser, resp.Message.Role) } diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 2b0fbafa81c..13bd4b71730 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -111,7 +111,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `Create(initialMessages)` creates a new chat, initializes `snapshot_version` to 1, inserts its initial history, and lands in `running`. The inserted initial history sets `history_version` to 1. Since the queue has not changed, `queue_version` remains 0. This transition is a special case: since the chat does not exist at the time it's run, the chat row cannot be locked before the transition is applied. - `SetArchived(archived)` sets or clears the archived marker for one chat. - `SendMessage(m, busy_behavior)` inserts a user message directly when the chat is idle, or queues it when the chat is busy. `busy_behavior` must be either `queue` or `interrupt`. With `busy_behavior=interrupt`, it also requests interruption or cancels a pending dynamic-tool action as needed. -- `EditMessage(k, replacement)` clears queued messages, cancels or obsoletes active work, marks the truncated active-history suffix as deleted, inserts the replacement turn, and lands in `running`. +- `EditMessage(k, replacement)` clears queued messages, cancels or obsoletes active work, marks the truncated active-history suffix as deleted, inserts the replacement turn followed by any caller-provided suffix messages, and lands in `running`. - `DeleteQueuedMessage(qid)` removes one queued message without changing the active history. - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. @@ -909,6 +909,16 @@ Users can also request a compaction on demand via `POST /api/experimental/chats/ The `compaction_requested_at` marker is one-shot: transitions that keep an active turn alive (`Acquire`, `Abandon`, `SetArchived`, queueing a message on a busy chat) carry it forward, while every other transition that rewrites the execution state (`FinishTurn`, `FinishError`, `Interrupt`, `EditMessage`, `PromoteQueuedMessage`, `CancelRequiresAction`, `ReconcileInvalidState`, and so on) clears it by construction, so a stale request can never replay on a later turn. +# Lifecycle hooks + +When the `agent-lifecycle-hooks` experiment is enabled and a hook URL is configured, chatd sends events to an external consumer at key points in a conversation: session start, prompt submission, tool use, compaction, and turn completion. + +The consumer can observe activity, add model-only or user-visible context, replace supported prompt or tool input, and deny prompts or tool calls. Prompt submission is evaluated once when the submission is accepted, including queued messages and subagent prompts. Returned context becomes part of the conversation for its intended audience. + +Lifecycle hooks fail closed. If the consumer cannot be reached or returns an invalid response, Coder stops the triggering operation rather than continuing without the consumer's decision. Affected chats can enter an error state until the consumer recovers or hooks are disabled. + +Coder stores no hook-specific dispatch or decision state. Delivery is at least once, so the consumer owns durable policy state, audit records, and deduplication based on stable event identifiers. + # Stream loop The stream loop powers the `GET /api/experimental/chats/{chat}/stream` endpoint. It is scoped to one chat and one client WebSocket. It's responsible for delivering a stream of chat updates to the client, including: diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b906cdaa45a..420c6b55e8a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -51,8 +51,10 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" + "github.com/coder/coder/v2/coderd/x/chathooks" skillspkg "github.com/coder/coder/v2/coderd/x/skills" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/quartz" ) @@ -176,6 +178,8 @@ type Server struct { stopWorkspaceFn chattool.StopWorkspaceFn pubsub pubsub.Pubsub webpushDispatcher webpush.Dispatcher + hookDispatcher *chathooks.Dispatcher + hookDecisions *hookDecisionCache providerAPIKeys chatprovider.ProviderAPIKeys allowBYOK bool oidcTokenSource mcpclient.UserOIDCTokenSource @@ -1164,24 +1168,25 @@ func (e *UsageLimitExceededError) Error() string { // CreateOptions controls chat creation in the shared chat mutation path. type CreateOptions struct { - OrganizationID uuid.UUID - OwnerID uuid.UUID - WorkspaceID uuid.NullUUID - BuildID uuid.NullUUID - AgentID uuid.NullUUID - ParentChatID uuid.NullUUID - RootChatID uuid.NullUUID - Title string - ModelConfigID uuid.UUID - ReasoningEffort *string - ChatMode database.NullChatMode - PlanMode database.NullChatPlanMode - ClientType database.ChatClientType - SystemPrompt string - InitialUserContent []codersdk.ChatMessagePart - MCPServerIDs []uuid.UUID - Labels database.StringMap - DynamicTools json.RawMessage + OrganizationID uuid.UUID + OwnerID uuid.UUID + WorkspaceID uuid.NullUUID + BuildID uuid.NullUUID + AgentID uuid.NullUUID + ParentChatID uuid.NullUUID + RootChatID uuid.NullUUID + Title string + TitleDerivedFromContent bool + ModelConfigID uuid.UUID + ReasoningEffort *string + ChatMode database.NullChatMode + PlanMode database.NullChatPlanMode + ClientType database.ChatClientType + SystemPrompt string + InitialUserContent []codersdk.ChatMessagePart + MCPServerIDs []uuid.UUID + Labels database.StringMap + DynamicTools json.RawMessage } // SendMessageBusyBehavior controls what happens when a chat is already active. @@ -1214,7 +1219,11 @@ type SendMessageResult struct { Queued bool QueuedMessage *database.ChatQueuedMessage Message database.ChatMessage - Chat database.Chat + // InsertedMessages holds every message the send inserted, in + // insertion order. A queued send on an errored chat can still + // insert messages by promoting the previous queue head. + InsertedMessages []database.ChatMessage + Chat database.Chat } // EditMessageOptions controls user message edits via soft-delete and re-insert. @@ -1233,7 +1242,14 @@ type EditMessageOptions struct { // EditMessageResult contains the replacement user message and chat status. type EditMessageResult struct { Message database.ChatMessage - Chat database.Chat + // InsertedMessages holds every message the edit inserted, in + // insertion order: synthetic tool cancellations, the replacement + // user message, then hook suffix messages. + InsertedMessages []database.ChatMessage + // DeletedMessageIDs holds every previously visible message the + // edit soft-deleted. + DeletedMessageIDs []int64 + Chat database.Chat } // PromoteQueuedOptions controls queued-message promotion. @@ -1301,6 +1317,34 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C return database.Chat{}, xerrors.Errorf("marshal labels: %w", err) } + chatID := uuid.New() + contentParts := opts.InitialUserContent + var hookResponse agenthooks.Response + if p.hookDispatcher.Enabled() { + // Validate model admission before dispatch, matching the insert path. + if err := validateCreateModelConfigID(ctx, p.db, opts.ModelConfigID); err != nil { + return database.Chat{}, err + } + turnID := uuid.New() + hookChat := database.Chat{} + hookChat.ID = chatID + hookChat.OwnerID = opts.OwnerID + hookChat.WorkspaceID = opts.WorkspaceID + hookResponse, err = p.dispatchUserPromptSubmit(ctx, hookChat, turnID, contentParts) + if err != nil { + return database.Chat{}, err + } + composed, overridden, err := composeUserPromptContent(contentParts, hookResponse) + if err != nil { + return database.Chat{}, err + } + contentParts = composed + // Avoid deriving titles from the prompt that policy replaced. + if overridden && opts.TitleDerivedFromContent { + opts.Title = chatprompt.FallbackTitle(chatprompt.TitleText(contentParts, nil)) + } + } + userPrompt := SanitizePromptText(opts.SystemPrompt) workspaceAwareness := workspaceDetachedAwareness if opts.WorkspaceID.Valid { @@ -1312,7 +1356,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C if err != nil { return database.Chat{}, xerrors.Errorf("marshal workspace awareness: %w", err) } - userContent, err := chatprompt.MarshalParts(opts.InitialUserContent) + userContent, err := chatprompt.MarshalParts(contentParts) if err != nil { return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", err) } @@ -1339,7 +1383,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, opts.ModelConfigID)) initialMessages = append(initialMessages, userMessage(userContent, opts.ModelConfigID, opts.OwnerID, opts.ReasoningEffort)) - result, err := chatstate.CreateChat(ctx, p.db, p.pubsub, chatstate.CreateChatInput{ + result, err := chatstate.CreateChatWithID(ctx, p.db, p.pubsub, chatID, chatstate.CreateChatInput{ OrganizationID: opts.OrganizationID, OwnerID: opts.OwnerID, WorkspaceID: opts.WorkspaceID, @@ -1409,7 +1453,43 @@ func (p *Server) SendMessage( return SendMessageResult{}, xerrors.Errorf("invalid busy behavior %q", opts.BusyBehavior) } - content, err := chatprompt.MarshalParts(opts.Content) + contentParts := opts.Content + var hookResponse agenthooks.Response + if p.hookDispatcher.Enabled() { + turnID := uuid.New() + chat, err := p.db.GetChatByID(ctx, opts.ChatID) + if err != nil { + return SendMessageResult{}, xerrors.Errorf("load chat for user_prompt_submit: %w", err) + } + // Repeat these admission checks under the transaction lock. + if chat.Archived { + return SendMessageResult{}, ErrChatArchived + } + if err := p.checkUsageLimit(ctx, p.db, chat.OwnerID, uuid.NullUUID{UUID: chat.OrganizationID, Valid: true}); err != nil { + return SendMessageResult{}, err + } + if _, err := resolveSendMessageModelConfigID(ctx, p.db, chat, opts.ModelConfigID); err != nil { + return SendMessageResult{}, err + } + // Check queue capacity before dispatch; the transaction rechecks it under lock. + queuedCount, err := p.db.CountChatQueuedMessages(ctx, opts.ChatID) + if err != nil { + return SendMessageResult{}, xerrors.Errorf("count queued messages: %w", err) + } + if queuedCount >= chatstate.MaxQueueSize { + return SendMessageResult{}, &chatstate.MessageQueueFullError{Max: chatstate.MaxQueueSize} + } + hookResponse, err = p.dispatchUserPromptSubmit(ctx, chat, turnID, contentParts) + if err != nil { + return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, err) + } + contentParts, _, err = composeUserPromptContent(contentParts, hookResponse) + if err != nil { + return SendMessageResult{}, err + } + } + + content, err := chatprompt.MarshalParts(contentParts) if err != nil { return SendMessageResult{}, xerrors.Errorf("marshal message content: %w", err) } @@ -1480,8 +1560,9 @@ func (p *Server) SendMessage( // Queue capacity is enforced inside tx.SendMessage; this // wrapper only propagates the typed error. + message := userMessage(content, modelConfigID, messageCreatedBy, opts.ReasoningEffort) sendResult, err := tx.SendMessage(chatstate.SendMessageInput{ - Message: userMessage(content, modelConfigID, messageCreatedBy, opts.ReasoningEffort), + Message: message, BusyBehavior: busyBehaviorToChatState(busyBehavior), }) if err != nil { @@ -1497,6 +1578,10 @@ func (p *Server) SendMessage( // last in the inserted slice. result.Message = sendResult.InsertedMessages[len(sendResult.InsertedMessages)-1] } + // A queued send on an errored chat can also promote the + // previous queue head into history; report those inserts so + // clients can update their caches. + result.InsertedMessages = sendResult.InsertedMessages // Capture the post-transition chat inside the same // transaction so the returned chat and the watch event // reflect the snapshot bump and status change produced by @@ -1590,6 +1675,20 @@ func requireEnabledChatModelConfig( return nil } +func validateCreateModelConfigID(ctx context.Context, store database.Store, modelConfigID uuid.UUID) error { + if modelConfigID == uuid.Nil { + return xerrors.Errorf("%w: %s", ErrInvalidModelConfigID, modelConfigID) + } + chatdCtx := chatdModelConfigLookupContext(ctx) + if _, err := store.GetChatModelConfigByID(chatdCtx, modelConfigID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("%w: %s", ErrInvalidModelConfigID, modelConfigID) + } + return xerrors.Errorf("get requested model config %s: %w", modelConfigID, err) + } + return nil +} + func resolveFallbackModelConfigID( ctx context.Context, store database.Store, @@ -1633,6 +1732,37 @@ func resolveFallbackModelConfigID( return defaultConfig.ID, nil } +func validateModelConfigOverride( + ctx context.Context, + store database.Store, + requested uuid.UUID, +) (uuid.NullUUID, error) { + if requested == uuid.Nil { + return uuid.NullUUID{}, nil + } + if err := requireEnabledChatModelConfig(ctx, store, requested); err != nil { + return uuid.NullUUID{}, err + } + return uuid.NullUUID{UUID: requested, Valid: true}, nil +} + +func validateEditTarget(ctx context.Context, store database.Store, chatID uuid.UUID, messageID int64) error { + target, err := store.GetChatMessageByID(ctx, messageID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrEditedMessageNotFound + } + return xerrors.Errorf("get edited message: %w", err) + } + if target.ChatID != chatID || target.Deleted { + return ErrEditedMessageNotFound + } + if target.Role != database.ChatMessageRoleUser { + return ErrEditedMessageNotUser + } + return nil +} + // EditMessage replaces an earlier user message and discards the // active-history suffix through chatstate.EditMessage. Model-config // override validation and usage-limit admission run in the same @@ -1651,7 +1781,42 @@ func (p *Server) EditMessage( return EditMessageResult{}, xerrors.New("content is required") } - content, err := chatprompt.MarshalParts(opts.Content) + contentParts := opts.Content + var sessionStartResponse, hookResponse agenthooks.Response + if p.hookDispatcher.Enabled() { + turnID := uuid.New() + chat, err := p.db.GetChatByID(ctx, opts.ChatID) + if err != nil { + return EditMessageResult{}, xerrors.Errorf("load chat for edit hooks: %w", err) + } + // Repeat these admission checks under the transaction lock. + if chat.Archived { + return EditMessageResult{}, ErrChatArchived + } + if err := p.checkUsageLimit(ctx, p.db, chat.OwnerID, uuid.NullUUID{UUID: chat.OrganizationID, Valid: true}); err != nil { + return EditMessageResult{}, err + } + if err := validateEditTarget(ctx, p.db, opts.ChatID, opts.EditedMessageID); err != nil { + return EditMessageResult{}, err + } + if _, err := validateModelConfigOverride(ctx, p.db, opts.ModelConfigID); err != nil { + return EditMessageResult{}, err + } + sessionStartResponse, err = p.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSourceClear}) + if err != nil { + return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, agenthooks.EventSessionStart, err) + } + hookResponse, err = p.dispatchUserPromptSubmit(ctx, chat, turnID, contentParts) + if err != nil { + return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, err) + } + contentParts, _, err = composeUserPromptContent(contentParts, hookResponse) + if err != nil { + return EditMessageResult{}, err + } + } + + content, err := chatprompt.MarshalParts(contentParts) if err != nil { return EditMessageResult{}, xerrors.Errorf("marshal message content: %w", err) } @@ -1686,18 +1851,19 @@ func (p *Server) EditMessage( if target.ChatID != opts.ChatID { return ErrEditedMessageNotFound } + if target.Deleted { + return ErrEditedMessageNotFound + } + if target.Role != database.ChatMessageRoleUser { + return ErrEditedMessageNotUser + } editedMsg = target - // Validate the optional model-config override up front so - // the user sees ErrInvalidModelConfigID instead of a - // foreign-key error from the message-insert path. - var modelOverride uuid.NullUUID - if opts.ModelConfigID != uuid.Nil { - if err := requireEnabledChatModelConfig(ctx, store, opts.ModelConfigID); err != nil { - return err - } - modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} - } else { + modelOverride, err := validateModelConfigOverride(ctx, store, opts.ModelConfigID) + if err != nil { + return err + } + if !modelOverride.Valid { // Without an explicit override the transition preserves // the edited message's original model, which may have been // disabled since; resolve it like a normal message send. @@ -1714,6 +1880,19 @@ func (p *Server) EditMessage( } } + modelConfigID := target.ModelConfigID.UUID + if modelOverride.Valid { + modelConfigID = modelOverride.UUID + } + // The prompt response already rides in the replacement content; + // only the session_start(clear) response needs transcript rows. + // They insert after the replacement so a later edit's suffix + // truncation cleans them up. + suffixMessages, err := hookEventMessages(sessionStartResponse, modelConfigID) + if err != nil { + return err + } + var reasoningEffortOverride database.NullChatReasoningEffort if opts.ReasoningEffort != nil && *opts.ReasoningEffort != "" { reasoningEffortOverride = database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffort(*opts.ReasoningEffort), Valid: true} @@ -1721,6 +1900,7 @@ func (p *Server) EditMessage( editResult, err := tx.EditMessage(chatstate.EditMessageInput{ MessageID: opts.EditedMessageID, + SuffixMessages: suffixMessages, CreatedBy: opts.CreatedBy, Content: content, ModelConfigIDOverride: modelOverride, @@ -1733,6 +1913,12 @@ func (p *Server) EditMessage( return err } result.Message = editResult.ReplacementMessage + inserted := make([]database.ChatMessage, 0, len(editResult.CancellationMessages)+len(editResult.SuffixMessages)+1) + inserted = append(inserted, editResult.CancellationMessages...) + inserted = append(inserted, editResult.ReplacementMessage) + inserted = append(inserted, editResult.SuffixMessages...) + result.InsertedMessages = inserted + result.DeletedMessageIDs = editResult.DeletedMessageIDs // Capture the post-edit chat inside the same transaction so // the returned chat and the debug-cleanup cutoff use the // snapshot bump and updated_at stamped by the transition. @@ -1977,21 +2163,142 @@ func (e *ToolResultStatusConflictError) Error() string { ) } -// SubmitToolResults validates and persists client-provided tool -// results, returning the chat to running through the chatstate state -// machine. Validation runs inside the same transaction as the -// transition so the assistant message and pending tool calls cannot -// drift between reads. +type dynamicPostToolUseState struct { + chat database.Chat + modelConfigID uuid.UUID + toolNames map[string]string +} + +func loadDynamicPostToolUseState( + ctx context.Context, + machine *chatstate.ChatMachine, + opts SubmitToolResultsOptions, +) (dynamicPostToolUseState, error) { + var state dynamicPostToolUseState + err := machine.ReadLock(ctx, func(store database.Store) error { + chat, err := store.GetChatByID(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("load chat: %w", err) + } + if chat.Archived { + return ErrChatArchived + } + if chat.Status != database.ChatStatusRequiresAction { + return &ToolResultStatusConflictError{ActualStatus: chat.Status} + } + messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: opts.ChatID, + AfterID: 0, + }) + if err != nil { + return xerrors.Errorf("load chat messages: %w", err) + } + _, pending, err := unresolvedToolCallsFromHistory(messages, dynamicToolNamesFromChat(chat)) + if err != nil { + return xerrors.Errorf("load pending dynamic tool calls: %w", err) + } + toolNames := make(map[string]string, len(pending)) + for _, call := range pending { + toolNames[call.ToolCallID] = call.ToolName + } + if err := validateSubmittedToolResults(opts.Results, toolNames); err != nil { + return err + } + modelConfigID := opts.ModelConfigID + if modelConfigID == uuid.Nil { + modelConfigID = chat.LastModelConfigID + } + state = dynamicPostToolUseState{ + chat: chat, + modelConfigID: modelConfigID, + toolNames: toolNames, + } + return nil + }) + return state, err +} + +func validateSubmittedToolResults(results []codersdk.ToolResult, toolNames map[string]string) error { + submitted := make(map[string]struct{}, len(results)) + for _, result := range results { + if _, ok := submitted[result.ToolCallID]; ok { + return &ToolResultValidationError{ + Message: "Duplicate tool_call_id in results.", + Detail: fmt.Sprintf("Duplicate tool call ID %q.", result.ToolCallID), + } + } + if !json.Valid(result.Output) { + return &ToolResultValidationError{ + Message: "Tool result output must be valid JSON.", + Detail: fmt.Sprintf("Output for tool call %q is not valid JSON.", result.ToolCallID), + } + } + if _, ok := toolNames[result.ToolCallID]; !ok { + return &ToolResultValidationError{ + Message: "Unexpected tool result.", + Detail: fmt.Sprintf("No pending tool call with ID %q.", result.ToolCallID), + } + } + submitted[result.ToolCallID] = struct{}{} + } + for toolCallID := range toolNames { + if _, ok := submitted[toolCallID]; !ok { + return &ToolResultValidationError{ + Message: "Missing tool result.", + Detail: fmt.Sprintf("Missing result for tool call %q.", toolCallID), + } + } + } + return nil +} + +func dynamicPostToolUseData(result codersdk.ToolResult, toolName string) agenthooks.PostToolUseData { + data := agenthooks.PostToolUseData{ + ToolUseID: result.ToolCallID, + ToolName: toolName, + } + if result.IsError { + if err := json.Unmarshal(result.Output, &data.ToolError); err != nil { + data.ToolError = string(result.Output) + } + } else { + data.ToolResponse = append(json.RawMessage(nil), result.Output...) + } + return data +} + +// SubmitToolResults dispatches hooks before completing the +// requires_action transition. func (p *Server) SubmitToolResults( ctx context.Context, opts SubmitToolResultsOptions, ) error { + machine := p.newChatMachine(opts.ChatID) + var hookSuffix []chatstate.Message + if p.hookDispatcher.Enabled() { + state, err := loadDynamicPostToolUseState(ctx, machine, opts) + if err != nil { + return err + } + for _, result := range opts.Results { + response, err := p.dispatchPostToolUseData(ctx, state.chat, nil, dynamicPostToolUseData(result, state.toolNames[result.ToolCallID])) + if err != nil { + // Leave pending calls intact so the client can resubmit after recovery. + return generationHookDispatchError(agenthooks.EventPostToolUse, err) + } + responseMessages, err := hookEventMessages(response, state.modelConfigID) + if err != nil { + return err + } + hookSuffix = append(hookSuffix, responseMessages...) + } + } + var ( statusConflict *ToolResultStatusConflictError refreshChat database.Chat refreshedOK bool ) - machine := p.newChatMachine(opts.ChatID) updateErr := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { locked, err := store.GetChatByID(ctx, opts.ChatID) if err != nil { @@ -2002,11 +2309,11 @@ func (p *Server) SubmitToolResults( } toolResults := make([]chatstate.ToolResultInput, 0, len(opts.Results)) - for _, r := range opts.Results { + for _, result := range opts.Results { toolResults = append(toolResults, chatstate.ToolResultInput{ - ToolCallID: r.ToolCallID, - Output: r.Output, - IsError: r.IsError, + ToolCallID: result.ToolCallID, + Output: result.Output, + IsError: result.IsError, }) } modelConfigID := opts.ModelConfigID @@ -2014,9 +2321,10 @@ func (p *Server) SubmitToolResults( modelConfigID = locked.LastModelConfigID } if _, err := tx.CompleteRequiresAction(chatstate.CompleteRequiresActionInput{ - CreatedBy: opts.UserID, - ModelConfigID: modelConfigID, - Results: toolResults, + CreatedBy: opts.UserID, + ModelConfigID: modelConfigID, + Results: toolResults, + SuffixMessages: hookSuffix, }); err != nil { if !errors.Is(err, chatstate.ErrInvalidState) && locked.Status != database.ChatStatusRequiresAction && @@ -2028,9 +2336,6 @@ func (p *Server) SubmitToolResults( } return xerrors.Errorf("complete requires action: %w", err) } - // Capture the chat inside the transaction so the watch event - // uses the snapshot bump and status change produced by the - // transition itself. refreshed, err := store.GetChatByID(ctx, opts.ChatID) if err != nil { return xerrors.Errorf("reload chat after tool results: %w", err) @@ -2046,6 +2351,11 @@ func (p *Server) SubmitToolResults( return translateToolResultValidationError(updateErr) } + settled := make([]string, 0, len(opts.Results)) + for _, result := range opts.Results { + settled = append(settled, result.ToolCallID) + } + p.hookDecisions.evict(opts.ChatID, settled) if refreshedOK { p.publishChatPubsubEvent(refreshChat, codersdk.ChatWatchEventKindStatusChange, nil) } @@ -2894,6 +3204,7 @@ type Config struct { AllowBYOKSet bool AlwaysEnableDebugLogs bool WebpushDispatcher webpush.Dispatcher + HookDispatcher *chathooks.Dispatcher UsageTracker *workspacestats.UsageTracker Clock quartz.Clock AIBridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] @@ -2961,6 +3272,14 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { if cfg.AllowBYOKSet { allowBYOK = cfg.AllowBYOK } + + // Require the experiment even for injected dispatchers to + // preserve explicit opt-in. + hookDispatcher := cfg.HookDispatcher + if hookDispatcher != nil && !cfg.Experiments.Enabled(codersdk.ExperimentAgentLifecycleHooks) { + cfg.Logger.Warn(ctx, "ignoring chat lifecycle hook dispatcher; the agent-lifecycle-hooks experiment is not enabled") + hookDispatcher = nil + } p := &Server{ cancel: cancel, db: cfg.Database, @@ -2975,6 +3294,8 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { stopWorkspaceFn: cfg.StopWorkspace, pubsub: ps, webpushDispatcher: cfg.WebpushDispatcher, + hookDispatcher: hookDispatcher, + hookDecisions: newHookDecisionCache(), providerAPIKeys: cfg.ProviderAPIKeys, allowBYOK: allowBYOK, oidcTokenSource: cfg.OIDCTokenSource, diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index ff659f1021a..e9e84687f62 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -274,6 +274,7 @@ type GenerateCompactionOptions struct { ContextLimit int64 ContextLimitFallback int64 SummaryPrompt string + SummaryHint string SystemSummaryPrefix string StepUsage fantasy.Usage StepMetadata fantasy.ProviderMetadata diff --git a/coderd/x/chatd/chatloop/compaction.go b/coderd/x/chatd/chatloop/compaction.go index e0bcef5bb9c..2304cadd5ee 100644 --- a/coderd/x/chatd/chatloop/compaction.go +++ b/coderd/x/chatd/chatloop/compaction.go @@ -87,6 +87,7 @@ type CompactionOptions struct { ThresholdPercent int32 ContextLimit int64 SummaryPrompt string + SummaryHint string SystemSummaryPrefix string Persist func(context.Context, CompactionResult) error DebugSvc *chatdebug.Service @@ -213,6 +214,7 @@ func normalizedCompactionGenerateConfig(opts GenerateCompactionOptions) (Compact ThresholdPercent: opts.ThresholdPercent, ContextLimit: opts.ContextLimit, SummaryPrompt: opts.SummaryPrompt, + SummaryHint: opts.SummaryHint, SystemSummaryPrefix: opts.SystemSummaryPrefix, DebugSvc: opts.DebugSvc, ChatID: opts.ChatID, @@ -416,11 +418,13 @@ func generateCompactionSummary( ) (summary string, err error) { summaryPrompt := make([]fantasy.Message, 0, len(messages)+1) summaryPrompt = append(summaryPrompt, messages...) + summaryParts := []fantasy.MessagePart{fantasy.TextPart{Text: options.SummaryPrompt}} + if strings.TrimSpace(options.SummaryHint) != "" { + summaryParts = append(summaryParts, fantasy.TextPart{Text: options.SummaryHint}) + } summaryPrompt = append(summaryPrompt, fantasy.Message{ - Role: fantasy.MessageRoleUser, - Content: []fantasy.MessagePart{ - fantasy.TextPart{Text: options.SummaryPrompt}, - }, + Role: fantasy.MessageRoleUser, + Content: summaryParts, }) toolChoice := fantasy.ToolChoiceNone diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index ab112f6a8fb..fb8f168c6a4 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -1642,6 +1642,16 @@ func partsToMessageParts( _, _ = sb.WriteString(part.ContextFileContent) _, _ = sb.WriteString("\n") result = append(result, fantasy.TextPart{Text: sb.String()}) + case codersdk.ChatMessagePartTypeHookContext: + // Lifecycle hook model context rides inside the user + // message and is sent to the model as plain text. + if strings.TrimSpace(part.Text) == "" { + continue + } + result = append(result, fantasy.TextPart{Text: part.Text}) + case codersdk.ChatMessagePartTypeHookNotice: + // Client-only hook notice, never sent to the model. + continue case codersdk.ChatMessagePartTypeSource: // Source parts are metadata-only, not sent to LLM. continue diff --git a/coderd/x/chatd/chattest/openai.go b/coderd/x/chatd/chattest/openai.go index 74a2a91691c..45d9d3d8dc2 100644 --- a/coderd/x/chatd/chattest/openai.go +++ b/coderd/x/chatd/chattest/openai.go @@ -9,11 +9,13 @@ import ( "net/http" "net/http/httptest" "sort" + "strings" "sync" "testing" "time" "github.com/google/uuid" + "golang.org/x/xerrors" ) // OpenAIHandler handles OpenAI API requests and returns a response. @@ -87,6 +89,44 @@ type OpenAIMessage struct { Content string `json:"content"` } +// UnmarshalJSON accepts both string content and the structured +// content-part array the SDK emits for multi-part messages, +// concatenating the text items with newlines. +func (m *OpenAIMessage) UnmarshalJSON(data []byte) error { + var raw struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + m.Role = raw.Role + if len(raw.Content) == 0 { + m.Content = "" + return nil + } + var text string + if err := json.Unmarshal(raw.Content, &text); err == nil { + m.Content = text + return nil + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw.Content, &parts); err != nil { + return xerrors.Errorf("decode message content: %w", err) + } + var texts []string + for _, part := range parts { + if part.Type == "text" && part.Text != "" { + texts = append(texts, part.Text) + } + } + m.Content = strings.Join(texts, "\n") + return nil +} + // OpenAIToolFunction represents the function definition inside a tool. type OpenAIToolFunction struct { Name string `json:"name"` diff --git a/coderd/x/chatd/compaction_hooks_test.go b/coderd/x/chatd/compaction_hooks_test.go new file mode 100644 index 00000000000..6bf3764f919 --- /dev/null +++ b/coderd/x/chatd/compaction_hooks_test.go @@ -0,0 +1,240 @@ +package chatd_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/testutil" +) + +func TestCompactionHooksHintAndPostCommitResponses(t *testing.T) { + t.Parallel() + + var postSawCommitted atomic.Bool + fixture := startCompactionHookChat(t, + func(t *testing.T, db database.Store, request agenthooks.Request) (int, string) { + switch request.Type { + case agenthooks.EventPreCompact: + return http.StatusOK, `{"model_context":"preserve deployment constraints","user_message":"compaction starting"}` + case agenthooks.EventPostCompact: + postSawCommitted.Store(hasCompactionRows(t, db, request.Meta.ChatID)) + return http.StatusOK, `{"model_context":"post compact context","user_message":"compaction complete"}` + default: + return http.StatusOK, `{}` + } + }, + func(t *testing.T, body string) { + require.Contains(t, body, "preserve deployment constraints") + }, + ) + + waitCtx := testutil.Context(t, testutil.WaitLong) + testutil.Eventually(waitCtx, t, func(context.Context) bool { + updated, err := fixture.db.GetChatByID(waitCtx, fixture.chat.ID) + return err == nil && updated.Status == database.ChatStatusWaiting && !updated.Archived + }, testutil.IntervalFast) + // post_compact runs before its effects commit with the compaction step. + require.False(t, postSawCommitted.Load()) + require.Equal(t, int32(1), fixture.compactionCalls.Load()) + + userMessages := chatMessages(fixture.ctx, t, fixture.db, fixture.chat.ID) + promptMessages, err := fixture.db.GetChatMessagesForPromptByChatID(fixture.ctx, fixture.chat.ID) + require.NoError(t, err) + require.True(t, hasMessageText(t, userMessages, "compaction starting", database.ChatMessageVisibilityUser)) + require.True(t, hasMessageText(t, userMessages, "compaction complete", database.ChatMessageVisibilityUser)) + require.True(t, hasMessageText(t, promptMessages, "post compact context", database.ChatMessageVisibilityModel)) + require.False(t, hasMessageText(t, promptMessages, "preserve deployment constraints", database.ChatMessageVisibilityModel)) +} + +func TestPreCompactHookFailureAbortsCompaction(t *testing.T) { + t.Parallel() + + fixture := startCompactionHookChat(t, + func(_ *testing.T, _ database.Store, request agenthooks.Request) (int, string) { + if request.Type == agenthooks.EventPreCompact { + return http.StatusInternalServerError, "" + } + return http.StatusOK, `{}` + }, + func(t *testing.T, _ string) { + require.FailNow(t, "compaction model called after pre_compact failure") + }, + ) + waitCtx := testutil.Context(t, testutil.WaitLong) + failed := waitForChatStatus(waitCtx, t, fixture.db, fixture.chat.ID, database.ChatStatusError) + require.Equal(t, int32(0), fixture.compactionCalls.Load()) + require.False(t, hasCompactionRows(t, fixture.db, fixture.chat.ID)) + require.Equal(t, int32(1), fixture.preCompactCalls.Load()) + require.Zero(t, fixture.postCompactCalls.Load()) + lastError := chatLastErrorMessage(failed.LastError) + require.Contains(t, lastError, "hook dispatch failed: pre_compact: http_error") +} + +func TestPostCompactHookFailureKeepsCompaction(t *testing.T) { + t.Parallel() + + var postSawCommitted atomic.Bool + fixture := startCompactionHookChat(t, + func(t *testing.T, db database.Store, request agenthooks.Request) (int, string) { + if request.Type == agenthooks.EventPostCompact { + postSawCommitted.Store(hasCompactionRows(t, db, request.Meta.ChatID)) + return http.StatusInternalServerError, "" + } + return http.StatusOK, `{}` + }, + func(*testing.T, string) {}, + ) + waitCtx := testutil.Context(t, testutil.WaitLong) + failed := waitForChatStatus(waitCtx, t, fixture.db, fixture.chat.ID, database.ChatStatusError) + // The hook error commits atomically with the compaction step. + require.False(t, postSawCommitted.Load()) + require.Equal(t, int32(1), fixture.compactionCalls.Load()) + require.True(t, hasCompactionRows(t, fixture.db, fixture.chat.ID)) + require.Equal(t, int32(1), fixture.preCompactCalls.Load()) + require.Equal(t, int32(1), fixture.postCompactCalls.Load()) + lastError := chatLastErrorMessage(failed.LastError) + require.Contains(t, lastError, "hook dispatch failed: post_compact: http_error") +} + +type compactionHookFixture struct { + ctx context.Context + db database.Store + chat database.Chat + compactionCalls *atomic.Int32 + preCompactCalls *atomic.Int32 + postCompactCalls *atomic.Int32 +} + +func startCompactionHookChat( + t *testing.T, + hookResponse func(*testing.T, database.Store, agenthooks.Request) (int, string), + inspectCompaction func(*testing.T, string), +) compactionHookFixture { + t.Helper() + + const ( + contextLimit = int64(100) + thresholdPercent = int32(70) + ) + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var compactionCalls atomic.Int32 + var preCompactCalls atomic.Int32 + var postCompactCalls atomic.Int32 + var streamCalls atomic.Int32 + anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + body := anthropicRequestBody(t, *req) + if !req.Stream { + if strings.Contains(body, "You are performing a context compaction") { + compactionCalls.Add(1) + inspectCompaction(t, body) + return anthropicCompactionResponse("hook compaction summary") + } + return chattest.AnthropicNonStreamingResponse("title") + } + if streamCalls.Add(1) == 1 { + return highUsageReadFileResponse("/tmp/hook.txt") + } + return chattest.AnthropicStreamingResponse(chattest.AnthropicTextChunksWithCacheUsage(chattest.AnthropicUsage{ + InputTokens: 20, + OutputTokens: 5, + }, "continued after compaction")...) + }) + user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) + model = updateChatModelCompressionThreshold(t, db, model, contextLimit, thresholdPercent) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + 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)) + switch request.Type { + case agenthooks.EventPreCompact: + preCompactCalls.Add(1) + case agenthooks.EventPostCompact: + postCompactCalls.Add(1) + } + status, body := hookResponse(t, db, request) + w.WriteHeader(status) + if body != "" { + _, err := w.Write([]byte(body)) + require.NoError(t, err) + } + })) + t.Cleanup(consumer.Close) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/hook.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 12, TotalLines: 1, LinesRead: 1, Content: "1\tpackage main", + }, nil). + Times(1) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "compaction-hooks", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("trigger compaction hooks"), + }, + }) + require.NoError(t, err) + return compactionHookFixture{ + ctx: ctx, + db: db, + chat: chat, + compactionCalls: &compactionCalls, + preCompactCalls: &preCompactCalls, + postCompactCalls: &postCompactCalls, + } +} + +func hasCompactionRows(t *testing.T, db database.Store, chatID uuid.UUID) bool { + t.Helper() + userMessages := chatMessages(t.Context(), t, db, chatID) + promptMessages, err := db.GetChatMessagesForPromptByChatID(t.Context(), chatID) + require.NoError(t, err) + compressed := compressedChatSummarizedMessages(t, append(promptMessages, userMessages...)) + return len(compressed.summaries) > 0 && len(compressed.calls) > 0 && len(compressed.results) > 0 +} + +func hasMessageText(t *testing.T, messages []database.ChatMessage, text string, visibility database.ChatMessageVisibility) bool { + t.Helper() + for _, message := range messages { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + if message.Visibility == visibility && len(parts) == 1 && parts[0].Text == text { + return true + } + } + return false +} diff --git a/coderd/x/chatd/create_hooks_test.go b/coderd/x/chatd/create_hooks_test.go new file mode 100644 index 00000000000..7ab6d60f06a --- /dev/null +++ b/coderd/x/chatd/create_hooks_test.go @@ -0,0 +1,238 @@ +package chatd_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/testutil" +) + +func TestCreateChatUserPromptSubmitHook(t *testing.T) { + t.Parallel() + + t.Run("passthrough", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, requests := newCreateHookTestServer(t, db, ps, http.StatusOK, `{}`) + + chat, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "passthrough")) + require.NoError(t, err) + request := testutil.RequireReceive(ctx, t, requests) + require.Equal(t, agenthooks.EventUserPromptSubmit, request.Type) + require.Equal(t, chat.ID, request.Meta.ChatID) + require.Equal(t, user.ID, request.Meta.OwnerID) + require.NotNil(t, request.Meta.TurnID) + decoded, err := request.Decode() + require.NoError(t, err) + data, ok := decoded.(*agenthooks.UserPromptSubmitData) + require.True(t, ok) + require.Equal(t, "passthrough", data.Prompt) + var hookParts []codersdk.ChatMessagePart + require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) + require.Equal(t, []codersdk.ChatMessagePart{codersdk.ChatMessageText("passthrough")}, hookParts) + + messages := chatMessages(ctx, t, db, chat.ID) + initialUser := messages[len(messages)-1] + require.Equal(t, database.ChatMessageRoleUser, initialUser.Role) + require.Equal(t, database.ChatMessageVisibilityBoth, initialUser.Visibility) + require.Equal(t, "passthrough", hookMessageText(t, initialUser)) + }) + + t.Run("override", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, requests := newCreateHookTestServer(t, db, ps, http.StatusOK, `{"permission":{"decision":"allow","input_override":{"prompt":"redacted"}}}`) + + opts := createHookOptions(t, db, user.ID, org.ID, model.ID, "secret") + opts.Title = chatprompt.FallbackTitle(chatprompt.TitleText(opts.InitialUserContent, nil)) + opts.TitleDerivedFromContent = true + chat, err := server.CreateChat(ctx, opts) + require.NoError(t, err) + request := testutil.RequireReceive(ctx, t, requests) + require.NotNil(t, request.Meta.TurnID) + messages := chatMessages(ctx, t, db, chat.ID) + initialUser := messages[len(messages)-1] + require.Equal(t, "redacted", hookMessageText(t, initialUser)) + require.Equal(t, "redacted", chat.Title, "prompt-derived title must be recomputed from the override") + }) + + t.Run("override keeps explicit title", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, _ := newCreateHookTestServer(t, db, ps, http.StatusOK, `{"permission":{"decision":"allow","input_override":{"prompt":"redacted"}}}`) + + chat, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "secret")) + require.NoError(t, err) + require.Equal(t, "create hook test", chat.Title) + }) + + t.Run("invalid model config rejected before dispatch", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, requests := newCreateHookTestServer(t, db, ps, http.StatusOK, `{}`) + + opts := createHookOptions(t, db, user.ID, org.ID, model.ID, "prompt") + opts.ModelConfigID = uuid.New() + _, err := server.CreateChat(ctx, opts) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + select { + case request := <-requests: + t.Fatalf("unexpected hook dispatch %s for rejected create", request.Type) + default: + } + }) + + t.Run("override recomputes paste-derived title", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, _ := newCreateHookTestServer(t, db, ps, http.StatusOK, `{"permission":{"decision":"allow","input_override":{"prompt":"redacted"}}}`) + + opts := createHookOptions(t, db, user.ID, org.ID, model.ID, " ") + opts.Title = chatprompt.FallbackTitle("secret paste content") + opts.TitleDerivedFromContent = true + chat, err := server.CreateChat(ctx, opts) + require.NoError(t, err) + require.Equal(t, "redacted", chat.Title, + "paste-derived title must be recomputed from the override") + }) + + t.Run("response messages", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, _ := newCreateHookTestServer(t, db, ps, http.StatusOK, `{"model_context":"model only","user_message":"user only"}`) + + chat, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "prompt")) + require.NoError(t, err) + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + require.NotEmpty(t, promptMessages) + initialUser := promptMessages[len(promptMessages)-1] + require.Equal(t, database.ChatMessageRoleUser, initialUser.Role) + require.Equal(t, database.ChatMessageVisibilityBoth, initialUser.Visibility) + parts, err := chatprompt.ParseContent(initialUser) + require.NoError(t, err) + require.Equal(t, []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("prompt"), + {Type: codersdk.ChatMessagePartTypeHookContext, Text: "model only"}, + {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "user only"}, + }, parts) + }) + + t.Run("deny", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, requests := newCreateHookTestServer(t, db, ps, http.StatusOK, `{"permission":{"decision":"deny"},"user_message":"blocked"}`) + + _, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "prompt")) + var denied *chatd.UserPromptDeniedError + require.ErrorAs(t, err, &denied) + require.Equal(t, "blocked", denied.UserMessage) + request := testutil.RequireReceive(ctx, t, requests) + requireCreateHookChatMissing(ctx, t, db, request.Meta.ChatID) + }) + + t.Run("dispatch failure", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server, requests := newCreateHookTestServer(t, db, ps, http.StatusInternalServerError, "") + + _, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "prompt")) + var dispatchErr *chathooks.DispatchError + require.ErrorAs(t, err, &dispatchErr) + require.Equal(t, chathooks.ResultHTTPError, dispatchErr.Class) + request := testutil.RequireReceive(ctx, t, requests) + requireCreateHookChatMissing(ctx, t, db, request.Meta.ChatID) + }) +} + +func TestCreateChatHooksDisabledUnchanged(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server := newTestServer(t, db, ps, uuid.New()) + + chat, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "unchanged")) + require.NoError(t, err) + messages := chatMessages(ctx, t, db, chat.ID) + initialUser := messages[len(messages)-1] + require.Equal(t, "unchanged", hookMessageText(t, initialUser)) +} + +func newCreateHookTestServer( + t *testing.T, + db database.Store, + ps dbpubsub.Pubsub, + statusCode int, + response string, +) (*chatd.Server, <-chan agenthooks.Request) { + t.Helper() + 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(statusCode) + if response != "" { + _, err := w.Write([]byte(response)) + require.NoError(t, err) + } + })) + t.Cleanup(consumer.Close) + return newHookTestServer(t, db, ps, consumer), requests +} + +func createHookOptions( + t *testing.T, + db database.Store, + userID uuid.UUID, + organizationID uuid.UUID, + modelConfigID uuid.UUID, + prompt string, +) chatd.CreateOptions { + t.Helper() + return chatd.CreateOptions{ + OrganizationID: organizationID, + OwnerID: userID, + Title: "create hook test", + ModelConfigID: modelConfigID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}, + } +} + +func requireCreateHookChatMissing(ctx context.Context, t *testing.T, db database.Store, chatID uuid.UUID) { + t.Helper() + _, err := db.GetChatByID(ctx, chatID) + require.ErrorIs(t, err, sql.ErrNoRows) +} diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 443909157df..add77bf0c89 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" ) // generationPrepareInput contains the committed state used to prepare one @@ -317,6 +318,37 @@ func unresolvedToolCallsFromHistory( return localCalls, dynamicCalls, nil } +// priorToolCallIDsInTurn returns tool call IDs from assistant steps +// before the latest one in the current turn. Their recorded +// pre_tool_use decisions must not be replayed for a repeated call. +func priorToolCallIDsInTurn(messages []database.ChatMessage) (map[string]bool, error) { + assistantIndex := lastMessageIndex(messages, func(msg database.ChatMessage) bool { + return msg.Role == database.ChatMessageRoleAssistant + }) + prior := make(map[string]bool) + // Assistant steps lack turn IDs, so user-visible prompts bound the turn. + // Including earlier turns would duplicate hook effects on retry. + start := currentTurnStartIndex(messages) + if assistantIndex < start { + return prior, nil + } + for _, msg := range messages[start:assistantIndex] { + if msg.Deleted || msg.Compressed || msg.Role != database.ChatMessageRoleAssistant { + continue + } + parts, err := chatprompt.ParseContent(msg) + if err != nil { + return nil, xerrors.Errorf("parse assistant message: %w", err) + } + for _, part := range parts { + if part.Type == codersdk.ChatMessagePartTypeToolCall && part.ToolCallID != "" { + prior[part.ToolCallID] = true + } + } + } + return prior, nil +} + func hasExclusiveToolCall(toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool) bool { if len(exclusiveToolNames) == 0 { return false @@ -329,13 +361,63 @@ func hasExclusiveToolCall(toolCalls []fantasy.ToolCallContent, exclusiveToolName return false } +func (s *taskStarter) startGenerationSession( + ctx context.Context, + machine *chatstate.ChatMachine, + input chatWorkerTaskStartInput, + chat database.Chat, + messages []database.ChatMessage, +) (result sessionStartResult, dispatched bool, err error) { + dispatched, complete, err := input.SessionStart.claim(ctx) + if err != nil { + return sessionStartResult{}, false, errors.Join(errTaskExpectedExit, xerrors.Errorf("claim session_start: %w", err)) + } + if !dispatched { + return sessionStartResult{Chat: chat}, false, nil + } + + completed := false + // Re-arm the claim until its response is applied so a replacement task + // can replay session_start effects. + defer func() { complete(completed) }() + response, err := s.server.dispatchLifecycleHook(ctx, chat, input.hookTurnID(), agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSource(messages)}) + if err != nil { + return sessionStartResult{}, true, sessionStartDispatchError(err) + } + result, err = applySessionStartResponse(ctx, machine, input, chat, response) + if err != nil { + return sessionStartResult{}, true, err + } + completed = true + return result, true, nil +} + func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskStartInput) error { + if input.StopNudges == nil { + input.StopNudges = &stopNudgeTracker{} + } + if input.TurnID == uuid.Nil { + input.TurnID = uuid.New() + } machine := chatstate.NewChatMachine(s.opts.Store, s.opts.Pubsub, input.ChatID) for { chat, messages, err := loadGenerationState(ctx, machine, input) if err != nil { return xerrors.Errorf("load generation state: %w", err) } + if s.server.hookDispatcher.Enabled() { + result, dispatched, err := s.startGenerationSession(ctx, machine, input, chat, messages) + if err != nil { + if errors.Is(err, errTaskExpectedExit) { + return err + } + return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) + } + if dispatched { + input.HistoryVersion = result.Chat.HistoryVersion + continue + } + } prepareInput := generationPrepareInput{ Chat: chat, Messages: messages, @@ -350,20 +432,25 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) } cleanup := prepared.Cleanup - decision, err := retryGenerationPhase(ctx, s, "decide", func() (generationDecision, error) { - return decideGenerationAction(generationDecisionInput{ - chat: prepared.Chat, - messages: prepared.Messages, - dynamicToolNames: prepared.DynamicToolNames, - exclusiveToolNames: prepared.ExclusiveToolNames, - stopAfterTools: prepared.StopAfterTools, - maxSteps: prepared.MaxSteps, - compactionEnabled: prepared.Compaction != nil, - compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required, - compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction), - compactionContextLimit: generationCompactionContextLimit(prepared.Compaction), + var decision generationDecision + if input.StopNudges.consume(stopNudgeKey(prepared.Messages)) { + decision = generationDecision{kind: generationActionGenerateAssistant} + } else { + decision, err = retryGenerationPhase(ctx, s, "decide", func() (generationDecision, error) { + return decideGenerationAction(generationDecisionInput{ + chat: prepared.Chat, + messages: prepared.Messages, + dynamicToolNames: prepared.DynamicToolNames, + exclusiveToolNames: prepared.ExclusiveToolNames, + stopAfterTools: prepared.StopAfterTools, + maxSteps: prepared.MaxSteps, + compactionEnabled: prepared.Compaction != nil, + compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required, + compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction), + compactionContextLimit: generationCompactionContextLimit(prepared.Compaction), + }) }) - }) + } if err != nil { cleanup() if errors.Is(err, errTaskExpectedExit) || errors.Is(err, errTaskRetryable) { @@ -384,8 +471,32 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS var actionErr error switch decision.kind { case generationActionEnterRequiresAction: - cleanup() - return s.enterRequiresAction(ctx, machine, input) + toolCalls := make([]fantasy.ToolCallContent, 0, len(decision.pendingDynamicToolCalls)) + for _, toolCall := range decision.pendingDynamicToolCalls { + toolCalls = append(toolCalls, fantasy.ToolCallContent{ + ToolCallID: toolCall.ToolCallID, + ToolName: toolCall.ToolName, + Input: toolCall.Args, + }) + } + var priorToolCallIDs map[string]bool + if s.server.hookDispatcher.Enabled() { + priorToolCallIDs, err = priorToolCallIDsInTurn(prepared.Messages) + if err != nil { + cleanup() + return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) + } + } + preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), toolCalls, priorToolCallIDs) + if err != nil { + cleanup() + return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventPreToolUse, err), generationAttemptNotRequired) + } + if len(preflight.Denied) == 0 { + cleanup() + return s.enterRequiresAction(ctx, machine, input, prepared, preflight) + } + actionErr = s.commitPreToolUseDeniedResults(ctx, machine, input, prepared, preflight) case generationActionFinishTurn: cleanup() return s.finishGenerationTurn(ctx, machine, input, decision, generationAttemptNotRequired) @@ -636,10 +747,21 @@ func (s *taskStarter) generateAssistant( if len(outcome.Step.Content) == 0 { return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt.number)) } + var priorToolCallIDs map[string]bool + if s.server.hookDispatcher.Enabled() { + priorToolCallIDs, err = priorToolCallIDsInTurn(prepared.Messages) + if err != nil { + return err + } + } + preflight, err := s.server.preflightToolCalls(ctx, prepared.Chat, input.hookTurnID(), outcome.Step, outcome.ToolCalls, priorToolCallIDs) + if err != nil { + return generationHookDispatchError(agenthooks.EventPreToolUse, err) + } messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{ modelConfigID: prepared.ModelConfigID, modelCallConfig: prepared.ModelConfig, - step: stepDataFromPersisted(outcome.Step), + step: stepDataFromPersisted(preflight.Step), toolNameToConfigID: prepared.ToolNameToConfigID, logger: s.opts.Logger, contentVersion: chatprompt.CurrentContentVersion, @@ -647,7 +769,55 @@ func (s *taskStarter) generateAssistant( if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionGenerateAssistant, messages) + messages, err = applyHookResponseMessages(messages, preflight.Responses, prepared.ModelConfigID) + if err != nil { + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionGenerateAssistant, messages, generationCommitHooks{ + EffectToolUseIDs: preflight.EffectToolUseIDs, + }) +} + +func (s *taskStarter) commitPreToolUseDeniedResults( + ctx context.Context, + machine *chatstate.ChatMachine, + input chatWorkerTaskStartInput, + prepared generationPrepared, + preflight preToolUseExecutionResult, +) error { + attempt, err := s.beginGenerationAttempt(ctx, machine, input) + if err != nil { + return xerrors.Errorf("begin generation attempt: %w", err) + } + defer attempt.closeEpisode() + content := make([]fantasy.Content, 0, len(preflight.Denied)) + for _, denied := range preflight.Denied { + content = append(content, denied) + } + messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{ + modelConfigID: prepared.ModelConfigID, + modelCallConfig: prepared.ModelConfig, + step: stepDataFromPersisted(chatloop.PersistedStep{Content: content}), + toolNameToConfigID: prepared.ToolNameToConfigID, + logger: s.opts.Logger, + contentVersion: chatprompt.CurrentContentVersion, + }) + if err != nil { + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + messages, err = applyHookResponseMessages(messages, preflight.Responses, prepared.ModelConfigID) + if err != nil { + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + settled := make([]string, 0, len(preflight.Denied)) + for _, denied := range preflight.Denied { + settled = append(settled, denied.ToolCallID) + } + return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ + Overrides: preflight.Overrides, + EffectToolUseIDs: preflight.EffectToolUseIDs, + SettledToolUseIDs: settled, + }) } func (s *taskStarter) executeLocalTools( @@ -657,6 +827,18 @@ func (s *taskStarter) executeLocalTools( prepared generationPrepared, decision generationDecision, ) error { + var priorToolCallIDs map[string]bool + if s.server.hookDispatcher.Enabled() { + ids, err := priorToolCallIDsInTurn(prepared.Messages) + if err != nil { + return err + } + priorToolCallIDs = ids + } + preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), decision.localToolCalls, priorToolCallIDs) + if err != nil { + return generationHookDispatchError(agenthooks.EventPreToolUse, err) + } attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { return xerrors.Errorf("beginGenerationAttempt: %w", err) @@ -668,25 +850,33 @@ func (s *taskStarter) executeLocalTools( provider = prepared.Model.Provider() modelName = prepared.Model.Model() } - outcome, err := chatloop.ExecuteLocalTools(ctx, chatloop.ExecuteLocalToolsOptions{ - Tools: prepared.Tools, - ActiveTools: prepared.ActiveTools, - ProviderTools: prepared.ProviderTools, - ToolCalls: decision.localToolCalls, - ExclusiveToolNames: prepared.ExclusiveToolNames, - BuiltinToolNames: prepared.BuiltinToolNames, - ModelProvider: provider, - ModelName: modelName, - ContextLimit: prepared.ContextLimitFallback, - ToolNameAliases: subagentToolNameAliases, - PublishMessagePart: attempt.publish, - Logger: s.opts.Logger, - Metrics: s.server.metrics, - Clock: s.opts.Clock, - }) - if err != nil { - return xerrors.Errorf("execute local tools: %w", err) + var outcome chatloop.ToolExecutionOutcome + if len(preflight.Allowed) > 0 { + outcome, err = chatloop.ExecuteLocalTools(ctx, chatloop.ExecuteLocalToolsOptions{ + Tools: prepared.Tools, + ActiveTools: prepared.ActiveTools, + ProviderTools: prepared.ProviderTools, + ToolCalls: preflight.Allowed, + ExclusiveToolNames: prepared.ExclusiveToolNames, + BuiltinToolNames: prepared.BuiltinToolNames, + ModelProvider: provider, + ModelName: modelName, + ContextLimit: prepared.ContextLimitFallback, + ToolNameAliases: subagentToolNameAliases, + PublishMessagePart: attempt.publish, + Logger: s.opts.Logger, + Metrics: s.server.metrics, + Clock: s.opts.Clock, + }) + if err != nil { + return xerrors.Errorf("execute local tools: %w", err) + } + } + postResponses, postDispatchErr := s.server.dispatchPostToolUseResults(ctx, prepared.Chat, input.hookTurnID(), outcome.Step.Content) + for _, denied := range preflight.Denied { + outcome.Step.Content = append(outcome.Step.Content, denied) } + restoreToolCallOrder(outcome.Step.Content, decision.localToolCalls) messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{ modelConfigID: prepared.ModelConfigID, modelCallConfig: prepared.ModelConfig, @@ -698,7 +888,28 @@ func (s *taskStarter) executeLocalTools( if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages) + messages, err = applyHookResponseMessages(messages, preflight.Responses, prepared.ModelConfigID) + if err != nil { + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + messages, err = appendHookResponseMessages(messages, postResponses, prepared.ModelConfigID) + if err != nil { + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + var postCommitErr error + if postDispatchErr != nil { + postCommitErr = generationHookDispatchError(agenthooks.EventPostToolUse, postDispatchErr) + } + settled := make([]string, 0, len(decision.localToolCalls)) + for _, toolCall := range decision.localToolCalls { + settled = append(settled, toolCall.ToolCallID) + } + return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ + Overrides: preflight.Overrides, + PostCommitError: postCommitErr, + EffectToolUseIDs: preflight.EffectToolUseIDs, + SettledToolUseIDs: settled, + }) } // compactionSourceForDecision maps a compact decision to the @@ -751,6 +962,11 @@ func (s *taskStarter) generateCompaction( overrideModel.modelConfig, ) } + preResponse, err := s.server.dispatchLifecycleHook(ctx, prepared.Chat, input.hookTurnID(), agenthooks.EventPreCompact, agenthooks.PreCompactData{}) + if err != nil { + return generationHookDispatchError(agenthooks.EventPreCompact, err) + } + compactionOpts.SummaryHint = preResponse.ModelContext compactionOpts.PublishMessagePart = attempt.publish compactionOpts.Source = source compactionOpts.Force = source == chatloop.CompactionSourceManual @@ -779,14 +995,37 @@ func (s *taskStarter) generateCompaction( s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - err = s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionCompact, stepMessagesForCommit{ + persistedPreResponse := preResponse + persistedPreResponse.ModelContext = "" + commitMessages, err := applyHookResponseMessages(stepMessagesForCommit{ Messages: messages.Messages, VisibleIndexes: visibleMessageIndexes(messages.Messages), ConsumeCompactionRequest: true, + }, []agenthooks.Response{persistedPreResponse}, prepared.ModelConfigID) + if err != nil { + s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + // Hook effects and fail-closed errors must commit atomically with + // compaction; a separate commit races the runner and can be dropped + // on crash. + postResponse, postDispatchErr := s.server.dispatchLifecycleHook(ctx, prepared.Chat, input.hookTurnID(), agenthooks.EventPostCompact, agenthooks.PostCompactData{}) + var postCommitErr error + if postDispatchErr != nil { + postCommitErr = generationHookDispatchError(agenthooks.EventPostCompact, postDispatchErr) + } else { + commitMessages, err = appendHookResponseMessages(commitMessages, []agenthooks.Response{postResponse}, prepared.ModelConfigID) + if err != nil { + s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) + return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) + } + } + err = s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionCompact, commitMessages, generationCommitHooks{ + PostCommitError: postCommitErr, }) s.server.metrics.RecordCompaction(metricProvider, metricModel, err == nil, err) if err != nil { - return xerrors.Errorf("commit generation step: %w", err) + return xerrors.Errorf("commit compaction step: %w", err) } return nil } @@ -871,6 +1110,17 @@ func (s *taskStarter) beginGenerationAttempt( }, nil } +type generationCommitHooks struct { + Overrides map[string]json.RawMessage + PostCommitError error + // EffectToolUseIDs marks banked pre_tool_use decisions whose + // transcript effects land in this commit. + EffectToolUseIDs []string + // SettledToolUseIDs are tool calls whose results land in this + // commit; their banked decisions are evicted after it succeeds. + SettledToolUseIDs []string +} + func (s *taskStarter) commitGenerationStep( ctx context.Context, machine *chatstate.ChatMachine, @@ -878,16 +1128,40 @@ func (s *taskStarter) commitGenerationStep( attempt int64, kind generationActionKind, messages stepMessagesForCommit, + hooks generationCommitHooks, ) error { if len(messages.Messages) == 0 { + if hooks.PostCommitError != nil { + return s.finishGenerationError(ctx, machine, input, hooks.PostCommitError, requireGenerationAttempt(attempt)) + } return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt)) } + failClosed := hooks.PostCommitError != nil + var postCommitLastError pqtype.NullRawMessage + var postCommitMessage string + if hooks.PostCommitError != nil { + classified := chaterror.Classify(hooks.PostCommitError) + s.opts.Logger.Warn(ctx, "chat generation failed", + slog.F("chat_id", input.ChatID), + slog.F("worker_id", input.WorkerID), + slog.F("generation_attempt", input.GenerationAttempt), + slog.F("error_kind", classified.Kind), + slog.F("provider", classified.Provider), + slog.F("status_code", classified.StatusCode), + slog.F("retryable", classified.Retryable), + slog.Error(hooks.PostCommitError), + ) + postCommitLastError, postCommitMessage = generationLastError(hooks.PostCommitError) + } var committed database.Chat insertedMessages := []runnerActionMessage{} err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { if _, err := loadChatForGeneration(ctx, store, input, requireGenerationAttempt(attempt)); err != nil { return xerrors.Errorf("load chat for generation: %w", err) } + if err := replacePersistedToolCallInputs(ctx, store, input.ChatID, hooks.Overrides); err != nil { + return err + } commitResult, err := tx.CommitStep(chatstate.CommitStepInput{ Messages: messages.Messages, ConsumeCompactionRequest: messages.ConsumeCompactionRequest, @@ -895,19 +1169,45 @@ func (s *taskStarter) commitGenerationStep( if err != nil { return xerrors.Errorf("tx.CommitStep: %w", err) } - insertedMessages = make([]runnerActionMessage, 0, len(commitResult.InsertedMessages)) - for _, msg := range commitResult.InsertedMessages { + inserted := commitResult.InsertedMessages + // The fail-closed hook error must commit atomically with the + // step; a separate commit races the runner and can be dropped + // on crash. + if failClosed { + if _, err := tx.FinishError(chatstate.FinishErrorInput{LastError: postCommitLastError}); err != nil { + return xerrors.Errorf("tx.FinishError: %w", err) + } + } + insertedMessages = make([]runnerActionMessage, 0, len(inserted)) + for _, msg := range inserted { insertedMessages = append(insertedMessages, runnerActionMessage{ID: msg.ID, Role: codersdk.ChatMessageRole(msg.Role)}) } - committed, err = store.GetChatByID(ctx, input.ChatID) + loadedChat, err := store.GetChatByID(ctx, input.ChatID) if err != nil { return xerrors.Errorf("load committed chat: %w", err) } + committed = loadedChat return nil }) if err != nil { return normalizeTaskTransitionError(err, "commit generation step") } + s.server.hookDecisions.markEffectsApplied(input.ChatID, hooks.EffectToolUseIDs) + s.server.hookDecisions.evict(input.ChatID, hooks.SettledToolUseIDs) + if failClosed { + input.DebugTurn.RecordOutcome(chatdebug.StatusError) + postCommitCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout) + defer cancel() + if err := s.publishWatchAndRoute(postCommitCtx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil { + return xerrors.Errorf("publish watch and route: %w", err) + } + return s.afterGenerationOutcome(postCommitCtx, generationOutcome{ + Chat: committed, + Kind: runnerActionKindFinishError, + WatchEventKind: codersdk.ChatWatchEventKindStatusChange, + LastError: postCommitMessage, + }) + } s.routeStateHint(ctx, stateUpdateFromChat(committed)) return s.afterGenerationOutcome(ctx, generationOutcome{ Chat: committed, @@ -920,15 +1220,36 @@ func (s *taskStarter) enterRequiresAction( ctx context.Context, machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, + prepared generationPrepared, + preflight preToolUseExecutionResult, ) error { + messages, err := applyHookResponseMessages(stepMessagesForCommit{}, preflight.Responses, prepared.ModelConfigID) + if err != nil { + return err + } var committed database.Chat - err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + insertedMessages := []runnerActionMessage{} + err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { if _, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true}); err != nil { return xerrors.Errorf("load chat for task: %w", err) } + if err := replacePersistedToolCallInputs(ctx, store, input.ChatID, preflight.Overrides); err != nil { + return err + } + var inserted []database.ChatMessage + if len(messages.Messages) > 0 { + result, err := tx.CommitStep(chatstate.CommitStepInput{Messages: messages.Messages}) + if err != nil { + return xerrors.Errorf("tx.CommitStep: %w", err) + } + inserted = result.InsertedMessages + } if _, err := tx.EnterRequiresAction(chatstate.EnterRequiresActionInput{}); err != nil { return xerrors.Errorf("tx.EnterRequiresAction: %w", err) } + for _, message := range inserted { + insertedMessages = append(insertedMessages, runnerActionMessage{ID: message.ID, Role: codersdk.ChatMessageRole(message.Role)}) + } chat, err := store.GetChatByID(ctx, input.ChatID) if err != nil { return xerrors.Errorf("load committed chat: %w", err) @@ -939,13 +1260,15 @@ func (s *taskStarter) enterRequiresAction( if err != nil { return normalizeTaskTransitionError(err, "enter requires action") } + s.server.hookDecisions.markEffectsApplied(input.ChatID, preflight.EffectToolUseIDs) if err := s.publishWatchAndRoute(ctx, committed, codersdk.ChatWatchEventKindActionRequired); err != nil { return xerrors.Errorf("publish watch and route: %w", err) } return s.afterGenerationOutcome(ctx, generationOutcome{ - Chat: committed, - Kind: runnerActionKindEnterRequiresAction, - WatchEventKind: codersdk.ChatWatchEventKindActionRequired, + Chat: committed, + Kind: runnerActionKindEnterRequiresAction, + WatchEventKind: codersdk.ChatWatchEventKindActionRequired, + InsertedMessages: insertedMessages, }) } @@ -997,7 +1320,33 @@ func recordGenerationFinishFailure(turn *runnerDebugTurn, err error) { turn.RecordOutcome(chatdebug.StatusError) } -func (s *taskStarter) finishGenerationTurn( +func (s *taskStarter) completeGenerationTurn( + ctx context.Context, + input chatWorkerTaskStartInput, + committed database.Chat, + promotedMessageID int64, +) error { + input.StopNudges.reset() + s.server.hookDecisions.evictChat(input.ChatID) + input.DebugTurn.RecordOutcome(chatdebug.StatusCompleted) + watchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout) + defer cancel() + if err := s.publishWatchWithRetry(watchCtx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil { + return xerrors.Errorf("publish watch and route: %w", err) + } + if err := s.afterGenerationOutcome(ctx, generationOutcome{ + Chat: committed, + Kind: runnerActionKindFinishTurn, + WatchEventKind: codersdk.ChatWatchEventKindStatusChange, + PromotedMessageID: promotedMessageID, + }); err != nil { + return xerrors.Errorf("after generation outcome: %w", err) + } + s.routeStateHint(ctx, stateUpdateFromChat(committed)) + return nil +} + +func (s *taskStarter) finishGenerationTurnWithoutHook( ctx context.Context, machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, @@ -1024,22 +1373,95 @@ func (s *taskStarter) finishGenerationTurn( recordGenerationFinishFailure(input.DebugTurn, err) return err } - input.DebugTurn.RecordOutcome(chatdebug.StatusCompleted) - watchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout) - defer cancel() - if err := s.publishWatchWithRetry(watchCtx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil { - return xerrors.Errorf("publish watch and route: %w", err) + return s.completeGenerationTurn(ctx, input, committed, decision.promotedMessageID) +} + +func (s *taskStarter) finishGenerationTurn( + ctx context.Context, + machine *chatstate.ChatMachine, + input chatWorkerTaskStartInput, + decision generationDecision, + fence generationAttemptFence, +) error { + if !s.server.hookDispatcher.Enabled() { + return s.finishGenerationTurnWithoutHook(ctx, machine, input, decision, fence) } - if err := s.afterGenerationOutcome(ctx, generationOutcome{ - Chat: committed, - Kind: runnerActionKindFinishTurn, - WatchEventKind: codersdk.ChatWatchEventKindStatusChange, - PromotedMessageID: decision.promotedMessageID, - }); err != nil { - return xerrors.Errorf("after generation outcome: %w", err) + var chat database.Chat + var messages []database.ChatMessage + err := machine.ReadLock(ctx, func(store database.Store) error { + loadedChat, err := loadChatForGeneration(ctx, store, input, fence) + if err != nil { + return xerrors.Errorf("load chat for stop hook: %w", err) + } + loadedMessages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: input.ChatID, + AfterID: 0, + }) + if err != nil { + return xerrors.Errorf("load messages for stop hook: %w", err) + } + chat = loadedChat + messages = loadedMessages + return nil + }) + if err != nil { + return normalizeTaskTransitionError(err, "load stop hook state") } - s.routeStateHint(ctx, stateUpdateFromChat(committed)) - return nil + response, err := s.server.dispatchLifecycleHook(ctx, chat, input.hookTurnID(), agenthooks.EventStop, agenthooks.StopData{}) + if err != nil { + return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventStop, err), fence) + } + stopMessages, err := hookEventMessages(response, chat.LastModelConfigID) + if err != nil { + return s.finishGenerationError(ctx, machine, input, err, fence) + } + nudgeKey := stopNudgeKey(messages) + continueTurn := response.ModelContext != "" && input.StopNudges.claim(nudgeKey) + + var committed database.Chat + err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + if _, err := loadChatForGeneration(ctx, store, input, fence); err != nil { + return xerrors.Errorf("load chat for generation: %w", err) + } + if len(stopMessages) > 0 { + if _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: stopMessages}); err != nil { + return xerrors.Errorf("commit stop hook messages: %w", err) + } + } + if !continueTurn { + finishResult, err := tx.FinishTurn(chatstate.FinishTurnInput{}) + if err != nil { + return xerrors.Errorf("tx.FinishTurn: %w", err) + } + if finishResult.PromotedMessage != nil { + decision.promotedMessageID = finishResult.PromotedMessage.ID + } + committed = finishResult.Chat + return nil + } + loadedChat, err := store.GetChatByID(ctx, input.ChatID) + if err != nil { + return xerrors.Errorf("load committed chat: %w", err) + } + committed = loadedChat + return nil + }) + if err != nil { + if continueTurn { + input.StopNudges.cancel(nudgeKey) + } + err := normalizeTaskTransitionError(err, "finish generation turn") + recordGenerationFinishFailure(input.DebugTurn, err) + return err + } + if continueTurn { + s.routeStateHint(ctx, stateUpdateFromChat(committed)) + return s.afterGenerationOutcome(ctx, generationOutcome{ + Chat: committed, + Kind: runnerActionKind(generationActionGenerateAssistant), + }) + } + return s.completeGenerationTurn(ctx, input, committed, decision.promotedMessageID) } func (s *taskStarter) finishGenerationError( diff --git a/coderd/x/chatd/generation_internal_test.go b/coderd/x/chatd/generation_internal_test.go index aa8e93a3d96..c84ea68de4d 100644 --- a/coderd/x/chatd/generation_internal_test.go +++ b/coderd/x/chatd/generation_internal_test.go @@ -1,15 +1,18 @@ package chatd //nolint:testpackage // Exercises unexported generation helpers. import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -87,3 +90,66 @@ func TestRecordGenerationFinishFailure(t *testing.T) { }) } } + +func TestPriorToolCallIDsInTurn(t *testing.T) { + t.Parallel() + + t.Run("ExcludesEarlierTurns", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("first prompt")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("reused-1", "run_command", json.RawMessage(`{}`))), + dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("reused-1", "run_command", json.RawMessage(`{}`), false, false)), + dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("second prompt")), + dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("reused-1", "run_command", json.RawMessage(`{}`))), + } + prior, err := priorToolCallIDsInTurn(messages) + require.NoError(t, err) + require.Empty(t, prior) + }) + + t.Run("IncludesEarlierStepsInTurn", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("prompt")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), + dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("call-1", "run_command", json.RawMessage(`{}`), false, false)), + dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), + } + prior, err := priorToolCallIDsInTurn(messages) + require.NoError(t, err) + require.Equal(t, map[string]bool{"call-1": true}, prior) + }) + + t.Run("HookContextDoesNotSplitTurn", func(t *testing.T) { + t.Parallel() + + hookContext := dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("hook context")) + hookContext.Visibility = database.ChatMessageVisibilityModel + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("prompt")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), + dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("call-1", "run_command", json.RawMessage(`{}`), false, false)), + hookContext, + dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), + } + prior, err := priorToolCallIDsInTurn(messages) + require.NoError(t, err) + require.Equal(t, map[string]bool{"call-1": true}, prior) + }) + + t.Run("NoAssistantInCurrentTurn", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("first prompt")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("old-1", "run_command", json.RawMessage(`{}`))), + dbMessage(t, 3, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("second prompt")), + } + prior, err := priorToolCallIDsInTurn(messages) + require.NoError(t, err) + require.Empty(t, prior) + }) +} diff --git a/coderd/x/chatd/hookreplay.go b/coderd/x/chatd/hookreplay.go new file mode 100644 index 00000000000..775c29ecb4c --- /dev/null +++ b/coderd/x/chatd/hookreplay.go @@ -0,0 +1,163 @@ +package chatd + +import ( + "encoding/json" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/codersdk/agenthooks" +) + +const ( + // hookDecisionCacheMaxEntries bounds process memory; overflow evicts + // the oldest entries. A miss only widens the duplicate-dispatch + // window, so the bound is a resource guard, not a correctness limit. + hookDecisionCacheMaxEntries = 4096 + hookDecisionCacheTTL = 2 * time.Hour +) + +// hookDecisionCache banks successful pre_tool_use decisions so +// same-process turn re-drives (interruptions, transient errors, +// requires-action recovery, retry after a hook-caused chat error) reuse +// the consumer's decision instead of re-consulting it. The cache is +// best-effort only: correctness and the documented at-least-once +// contract never depend on it. A miss (process loss, failover, +// eviction) dispatches fresh and the consumer's latest decision wins. +type hookDecisionCache struct { + mu sync.Mutex + entries map[hookDecisionKey]*hookDecisionEntry +} + +type hookDecisionKey struct { + chatID uuid.UUID + toolUseID string +} + +type hookDecisionEntry struct { + toolName string + toolInput string + response agenthooks.Response + // effectsApplied is set once transcript effects commit; replay + // then applies only the permission decision. + effectsApplied bool + addedAt time.Time +} + +func newHookDecisionCache() *hookDecisionCache { + return &hookDecisionCache{entries: make(map[hookDecisionKey]*hookDecisionEntry)} +} + +// Replayed lookups derive input from jsonb-round-tripped content whose +// whitespace and key order differ from the streamed original. +func canonicalHookInput(input string) string { + var value any + if err := json.Unmarshal([]byte(input), &value); err != nil { + return input + } + out, err := json.Marshal(value) + if err != nil { + return input + } + return string(out) +} + +func (c *hookDecisionCache) put(chatID uuid.UUID, toolUseID, toolName, toolInput string, response agenthooks.Response) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.pruneLocked() + c.entries[hookDecisionKey{chatID: chatID, toolUseID: toolUseID}] = &hookDecisionEntry{ + toolName: toolName, + toolInput: canonicalHookInput(toolInput), + response: response, + addedAt: time.Now(), + } +} + +func (c *hookDecisionCache) lookup(chatID uuid.UUID, toolUseID, toolName, toolInput string) (response agenthooks.Response, effectsApplied bool, ok bool) { + if c == nil { + return agenthooks.Response{}, false, false + } + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[hookDecisionKey{chatID: chatID, toolUseID: toolUseID}] + if !ok || entry.toolName != toolName || time.Since(entry.addedAt) > hookDecisionCacheTTL { + return agenthooks.Response{}, false, false + } + input := canonicalHookInput(toolInput) + if entry.toolInput != input && !entry.matchesOverride(input) { + return agenthooks.Response{}, false, false + } + return entry.response, entry.effectsApplied, true +} + +func (e *hookDecisionEntry) matchesOverride(canonicalInput string) bool { + permission := e.response.Permission + return permission != nil && + permission.Decision == agenthooks.PermissionAllow && + len(permission.InputOverride) > 0 && + canonicalHookInput(string(permission.InputOverride)) == canonicalInput +} + +func (c *hookDecisionCache) markEffectsApplied(chatID uuid.UUID, toolUseIDs []string) { + if c == nil || len(toolUseIDs) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for _, toolUseID := range toolUseIDs { + if entry, ok := c.entries[hookDecisionKey{chatID: chatID, toolUseID: toolUseID}]; ok { + entry.effectsApplied = true + } + } +} + +func (c *hookDecisionCache) evict(chatID uuid.UUID, toolUseIDs []string) { + if c == nil || len(toolUseIDs) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for _, toolUseID := range toolUseIDs { + delete(c.entries, hookDecisionKey{chatID: chatID, toolUseID: toolUseID}) + } +} + +// Entries deliberately survive chat error state so a retry reuses +// decisions banked for the step's other tool calls. +func (c *hookDecisionCache) evictChat(chatID uuid.UUID) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for key := range c.entries { + if key.chatID == chatID { + delete(c.entries, key) + } + } +} + +func (c *hookDecisionCache) pruneLocked() { + now := time.Now() + for key, entry := range c.entries { + if now.Sub(entry.addedAt) > hookDecisionCacheTTL { + delete(c.entries, key) + } + } + for len(c.entries) >= hookDecisionCacheMaxEntries { + var oldestKey hookDecisionKey + var oldest time.Time + first := true + for key, entry := range c.entries { + if first || entry.addedAt.Before(oldest) { + oldestKey, oldest, first = key, entry.addedAt, false + } + } + delete(c.entries, oldestKey) + } +} diff --git a/coderd/x/chatd/hooks.go b/coderd/x/chatd/hooks.go new file mode 100644 index 00000000000..3ea06712018 --- /dev/null +++ b/coderd/x/chatd/hooks.go @@ -0,0 +1,745 @@ +package chatd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/coderd/x/chatd/chatloop" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" +) + +const ( + sessionStartSourceStartup = "startup" + sessionStartSourceResume = "resume" + sessionStartSourceClear = "clear" +) + +func lifecycleHookEvent( + chat database.Chat, + turnID *uuid.UUID, + eventType agenthooks.EventType, + data any, +) chathooks.Event { + var workspaceID *uuid.UUID + if chat.WorkspaceID.Valid { + workspaceID = &chat.WorkspaceID.UUID + } + var parentChatID *uuid.UUID + if chat.ParentChatID.Valid { + parentChatID = &chat.ParentChatID.UUID + } + var rootChatID *uuid.UUID + if chat.RootChatID.Valid { + rootChatID = &chat.RootChatID.UUID + } + return chathooks.Event{ + Type: eventType, + ChatRef: agenthooks.ChatRef{ + ChatID: chat.ID, + OwnerID: chat.OwnerID, + WorkspaceID: workspaceID, + TurnID: turnID, + ParentChatID: parentChatID, + RootChatID: rootChatID, + }, + Data: data, + } +} + +func (p *Server) dispatchLifecycleHook( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + eventType agenthooks.EventType, + data any, +) (agenthooks.Response, error) { + if !p.hookDispatcher.Enabled() { + return agenthooks.Response{}, nil + } + resp, _, err := p.hookDispatcher.Dispatch(ctx, lifecycleHookEvent(chat, turnID, eventType, data)) + return resp, err +} + +type preToolUseResult struct { + Step chatloop.PersistedStep + // Responses carries the responses whose transcript effects still + // need to be committed with the step. + Responses []agenthooks.Response + // EffectToolUseIDs identifies the banked decisions whose transcript + // effects commit with the step, for post-commit cache marking. + EffectToolUseIDs []string +} + +func (p *Server) dispatchPreToolUse( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + toolCall fantasy.ToolCallContent, +) (agenthooks.Response, error) { + return p.dispatchLifecycleHook(ctx, chat, turnID, agenthooks.EventPreToolUse, agenthooks.PreToolUseData{ + ToolUseID: toolCall.ToolCallID, + ToolName: toolCall.ToolName, + ToolInput: json.RawMessage(toolCall.Input), + }) +} + +func (p *Server) dispatchPostToolUseData( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + data agenthooks.PostToolUseData, +) (agenthooks.Response, error) { + return p.dispatchLifecycleHook(ctx, chat, turnID, agenthooks.EventPostToolUse, data) +} + +func (p *Server) dispatchPostToolUse( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + toolResult fantasy.ToolResultContent, +) (agenthooks.Response, error) { + data := agenthooks.PostToolUseData{ + ToolUseID: toolResult.ToolCallID, + ToolName: toolResult.ToolName, + } + switch output := toolResult.Result.(type) { + case fantasy.ToolResultOutputContentError: + if output.Error != nil { + data.ToolError = output.Error.Error() + } + case *fantasy.ToolResultOutputContentError: + if output != nil && output.Error != nil { + data.ToolError = output.Error.Error() + } + default: + encoded, err := json.Marshal(toolResult.Result) + if err != nil { + return agenthooks.Response{}, xerrors.Errorf("marshal post_tool_use response: %w", err) + } + data.ToolResponse = encoded + } + return p.dispatchPostToolUseData(ctx, chat, turnID, data) +} + +func (p *Server) dispatchPostToolUseResults( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + content []fantasy.Content, +) ([]agenthooks.Response, error) { + if !p.hookDispatcher.Enabled() { + return nil, nil + } + responses := make([]agenthooks.Response, 0, len(content)) + // Dispatch every completed non-provider-executed tool result. + // Preserve only the first failure. + var firstErr error + for _, block := range content { + toolResult, ok := asToolResultContent(block) + if !ok || toolResult.ProviderExecuted { + continue + } + response, err := p.dispatchPostToolUse(ctx, chat, turnID, toolResult) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + responses = append(responses, response) + } + return responses, firstErr +} + +func (p *Server) resolvePreToolUseDecision( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + toolCall fantasy.ToolCallContent, + priorToolCallIDs map[string]bool, +) (agenthooks.Response, bool, error) { + // Re-consult invalid JSON or IDs reused earlier in the turn because + // no banked decision can safely authorize those calls. + if json.Valid([]byte(toolCall.Input)) && !priorToolCallIDs[toolCall.ToolCallID] { + response, effectsApplied, ok := p.hookDecisions.lookup(chat.ID, toolCall.ToolCallID, toolCall.ToolName, toolCall.Input) + if ok { + return response, effectsApplied, nil + } + } + response, err := p.dispatchPreToolUse(ctx, chat, turnID, toolCall) + if err != nil { + return agenthooks.Response{}, false, err + } + p.bankPreToolUseDecision(chat.ID, toolCall, response) + return response, false, nil +} + +func (p *Server) preflightToolCalls( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + step chatloop.PersistedStep, + toolCalls []fantasy.ToolCallContent, + priorToolCallIDs map[string]bool, +) (preToolUseResult, error) { + result := preToolUseResult{Step: step} + if !p.hookDispatcher.Enabled() { + return result, nil + } + if err := rejectDuplicateToolUseIDs(toolCalls); err != nil { + return preToolUseResult{}, err + } + + for _, toolCall := range toolCalls { + if toolCall.ProviderExecuted { + continue + } + banked, effectsApplied, err := p.resolvePreToolUseDecision(ctx, chat, turnID, toolCall, priorToolCallIDs) + if err != nil { + return preToolUseResult{}, err + } + if err := applyPreToolUsePermission(&result.Step, toolCall, banked); err != nil { + return preToolUseResult{}, err + } + if !effectsApplied { + result.Responses = append(result.Responses, transcriptHookResponse(banked)) + result.EffectToolUseIDs = append(result.EffectToolUseIDs, toolCall.ToolCallID) + } + } + return result, nil +} + +// bankPreToolUseDecision caches a successful decision for same-process +// replay. Invalid JSON input is never banked because replay identity +// requires the exact input. +func (p *Server) bankPreToolUseDecision(chatID uuid.UUID, toolCall fantasy.ToolCallContent, response agenthooks.Response) { + if !json.Valid([]byte(toolCall.Input)) { + return + } + p.hookDecisions.put(chatID, toolCall.ToolCallID, toolCall.ToolName, toolCall.Input, response) +} + +// transcriptHookResponse returns the response with denial model context +// cleared: a denied call's model_context is folded into the synthetic +// tool result, so persisting it again as a row would duplicate it. +func transcriptHookResponse(response agenthooks.Response) agenthooks.Response { + if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { + response.ModelContext = "" + } + return response +} + +// restoreToolCallOrder reorders tool results to match the assistant's +// call order because providers pair results with calls positionally. +// Entries that are not tool results for the given calls keep their slots. +func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallContent) { + position := make(map[string]int, len(calls)) + for index, call := range calls { + position[call.ToolCallID] = index + } + slots := make([]int, 0, len(content)) + results := make([]fantasy.ToolResultContent, 0, len(content)) + for index, entry := range content { + result, ok := entry.(fantasy.ToolResultContent) + if !ok { + continue + } + if _, known := position[result.ToolCallID]; !known { + continue + } + slots = append(slots, index) + results = append(results, result) + } + slices.SortStableFunc(results, func(a, b fantasy.ToolResultContent) int { + return position[a.ToolCallID] - position[b.ToolCallID] + }) + for index, slot := range slots { + content[slot] = results[index] + } +} + +// rejectDuplicateToolUseIDs fails closed because banked replay decisions +// are keyed by tool-use ID within a turn. +func rejectDuplicateToolUseIDs(toolCalls []fantasy.ToolCallContent) error { + seen := make(map[string]struct{}, len(toolCalls)) + for _, toolCall := range toolCalls { + if toolCall.ProviderExecuted { + continue + } + if _, ok := seen[toolCall.ToolCallID]; ok { + return xerrors.Errorf("duplicate tool use ID %q in one step; lifecycle hook decisions cannot be attributed unambiguously", toolCall.ToolCallID) + } + seen[toolCall.ToolCallID] = struct{}{} + } + return nil +} + +type preToolUseExecutionResult struct { + Allowed []fantasy.ToolCallContent + Denied []fantasy.ToolResultContent + Responses []agenthooks.Response + Overrides map[string]json.RawMessage + // EffectToolUseIDs identifies the banked decisions whose transcript + // effects commit with this step, for post-commit cache marking. + EffectToolUseIDs []string +} + +func (p *Server) preflightPendingToolCalls( + ctx context.Context, + chat database.Chat, + turnID *uuid.UUID, + toolCalls []fantasy.ToolCallContent, + priorToolCallIDs map[string]bool, +) (preToolUseExecutionResult, error) { + if !p.hookDispatcher.Enabled() { + return preToolUseExecutionResult{Allowed: toolCalls}, nil + } + result := preToolUseExecutionResult{ + Allowed: make([]fantasy.ToolCallContent, 0, len(toolCalls)), + } + if err := rejectDuplicateToolUseIDs(toolCalls); err != nil { + return preToolUseExecutionResult{}, err + } + + for _, toolCall := range toolCalls { + banked, effectsApplied, err := p.resolvePreToolUseDecision(ctx, chat, turnID, toolCall, priorToolCallIDs) + if err != nil { + return preToolUseExecutionResult{}, err + } + if !effectsApplied { + result.Responses = append(result.Responses, transcriptHookResponse(banked)) + result.EffectToolUseIDs = append(result.EffectToolUseIDs, toolCall.ToolCallID) + } + if banked.Permission == nil { + result.Allowed = append(result.Allowed, toolCall) + continue + } + switch banked.Permission.Decision { + case agenthooks.PermissionAllow: + if len(banked.Permission.InputOverride) > 0 { + toolCall.Input = string(banked.Permission.InputOverride) + if result.Overrides == nil { + result.Overrides = make(map[string]json.RawMessage) + } + result.Overrides[toolCall.ToolCallID] = banked.Permission.InputOverride + } + result.Allowed = append(result.Allowed, toolCall) + case agenthooks.PermissionDeny: + result.Denied = append(result.Denied, deniedToolResult(toolCall, banked.Permission.Reason, banked.ModelContext)) + } + } + return result, nil +} + +func replacePersistedToolCallInputs( + ctx context.Context, + store database.Store, + chatID uuid.UUID, + overrides map[string]json.RawMessage, +) error { + if len(overrides) == 0 { + return nil + } + assistant, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ + ChatID: chatID, + Role: database.ChatMessageRoleAssistant, + }) + if err != nil { + return xerrors.Errorf("get assistant message for tool override: %w", err) + } + parts, err := chatprompt.ParseContent(assistant) + if err != nil { + return xerrors.Errorf("parse assistant message for tool override: %w", err) + } + changed := false + for i := range parts { + if override, ok := overrides[parts[i].ToolCallID]; ok && parts[i].Type == codersdk.ChatMessagePartTypeToolCall && !bytes.Equal(parts[i].Args, override) { + parts[i].Args = override + changed = true + } + } + if !changed { + return nil + } + content, err := chatprompt.MarshalParts(parts) + if err != nil { + return xerrors.Errorf("marshal assistant message with tool override: %w", err) + } + if err := store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ + Content: content.RawMessage, + ID: assistant.ID, + }); err != nil { + return xerrors.Errorf("update assistant message with tool override: %w", err) + } + return nil +} + +func applyPreToolUsePermission(step *chatloop.PersistedStep, toolCall fantasy.ToolCallContent, response agenthooks.Response) error { + if response.Permission == nil { + return nil + } + switch response.Permission.Decision { + case agenthooks.PermissionAllow: + if !replaceToolCallInput(step.Content, toolCall.ToolCallID, string(response.Permission.InputOverride)) { + return xerrors.Errorf("tool call %q is missing from generated step", toolCall.ToolCallID) + } + case agenthooks.PermissionDeny: + step.Content = append(step.Content, deniedToolResult(toolCall, response.Permission.Reason, response.ModelContext)) + } + return nil +} + +// deniedToolResult synthesizes the denial as a tool result so the model +// can replan within the same turn. The consumer's model_context rides in +// the same result instead of a separate transcript row. +func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "denied by lifecycle hook" + } + message := "DENIED: " + reason + if modelContext = strings.TrimSpace(modelContext); modelContext != "" { + message += "\n\n" + modelContext + } + return fantasy.ToolResultContent{ + ToolCallID: toolCall.ToolCallID, + ToolName: toolCall.ToolName, + Result: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(message), + }, + } +} + +func replaceToolCallInput(content []fantasy.Content, toolCallID, input string) bool { + for i, block := range content { + if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok && toolCall.ToolCallID == toolCallID { + toolCall.Input = input + content[i] = toolCall + return true + } + if toolCall, ok := fantasy.AsContentType[*fantasy.ToolCallContent](block); ok && toolCall != nil && toolCall.ToolCallID == toolCallID { + updated := *toolCall + updated.Input = input + content[i] = updated + return true + } + } + return false +} + +func sessionStartSource(messages []database.ChatMessage) string { + for _, message := range messages { + if message.Role == database.ChatMessageRoleAssistant { + return sessionStartSourceResume + } + } + return sessionStartSourceStartup +} + +// UserPromptDeniedError reports that a lifecycle hook rejected a prompt. +type UserPromptDeniedError struct { + UserMessage string +} + +// Error includes UserMessage so callers that only surface the error +// string, such as subagent tool responses, still expose the hook's +// reason. The HTTP handlers unwrap the typed error instead. +func (e *UserPromptDeniedError) Error() string { + if e.UserMessage == "" { + return "user prompt denied by lifecycle hook" + } + return "user prompt denied by lifecycle hook: " + e.UserMessage +} + +func (p *Server) dispatchUserPromptSubmit( + ctx context.Context, + chat database.Chat, + turnID uuid.UUID, + parts []codersdk.ChatMessagePart, +) (agenthooks.Response, error) { + encodedParts, err := chatprompt.MarshalParts(parts) + if err != nil { + return agenthooks.Response{}, xerrors.Errorf("marshal prompt parts for hook: %w", err) + } + response, err := p.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventUserPromptSubmit, agenthooks.UserPromptSubmitData{ + Prompt: textFromParts(parts), + Parts: encodedParts.RawMessage, + }) + if err != nil { + return agenthooks.Response{}, err + } + if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { + return response, &UserPromptDeniedError{UserMessage: response.UserMessage} + } + return response, nil +} + +func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { + return p.handleAPIDispatchError(ctx, chatID, agenthooks.EventUserPromptSubmit, dispatchErr) +} + +func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType agenthooks.EventType, dispatchErr error) error { + lastError, ok := hookDispatchErrorMessage(eventType, dispatchErr) + if !ok { + return dispatchErr + } + var failedChat database.Chat + machine := p.newChatMachine(chatID) + err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + if _, err := tx.FailIdle(chatstate.FailIdleInput{ + LastError: lastError, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }); err != nil { + return err + } + chat, err := store.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("reload chat after hook failure: %w", err) + } + failedChat = chat + return nil + }) + if errors.Is(err, chatstate.ErrTransitionNotAllowed) { + return dispatchErr + } + if err != nil { + return errors.Join(dispatchErr, xerrors.Errorf("fail idle chat after hook dispatch: %w", err)) + } + p.publishChatPubsubEvent(failedChat, codersdk.ChatWatchEventKindStatusChange, nil) + return dispatchErr +} + +func hookDispatchErrorMessage(eventType agenthooks.EventType, dispatchErr error) (string, bool) { + var structured *chathooks.DispatchError + if !errors.As(dispatchErr, &structured) { + return "", false + } + return fmt.Sprintf( + "hook dispatch failed: %s: %s (dispatch %s)", + eventType, + structured.Class, + structured.DispatchID, + ), true +} + +func sessionStartDispatchError(dispatchErr error) error { + return generationHookDispatchError(agenthooks.EventSessionStart, dispatchErr) +} + +func generationHookDispatchError(eventType agenthooks.EventType, dispatchErr error) error { + message, ok := hookDispatchErrorMessage(eventType, dispatchErr) + if !ok { + message = dispatchErr.Error() + } + return chaterror.WithClassification(dispatchErr, chaterror.ClassifiedError{ + Message: message, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) +} + +type sessionStartResult struct { + Chat database.Chat +} + +func applySessionStartResponse( + ctx context.Context, + machine *chatstate.ChatMachine, + input chatWorkerTaskStartInput, + chat database.Chat, + response agenthooks.Response, +) (sessionStartResult, error) { + if response.ModelContext == "" && response.UserMessage == "" { + return sessionStartResult{Chat: chat}, nil + } + + eventMessages, err := hookEventMessages(response, chat.LastModelConfigID) + if err != nil { + return sessionStartResult{}, err + } + + var result sessionStartResult + err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + if _, err := loadChatForGeneration(ctx, store, input, generationAttemptNotRequired); err != nil { + return xerrors.Errorf("load chat for session_start response: %w", err) + } + if len(eventMessages) > 0 { + if _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: eventMessages}); err != nil { + return xerrors.Errorf("insert session_start response messages: %w", err) + } + } + result.Chat, err = store.GetChatByID(ctx, input.ChatID) + if err != nil { + return xerrors.Errorf("reload chat after session_start response: %w", err) + } + return nil + }) + if err != nil { + return sessionStartResult{}, normalizeTaskTransitionError(err, "apply session_start response") + } + return result, nil +} + +// hookEventMessages converts a turn-time hook response into ordinary +// transcript rows: model context becomes a user-role, model-visible row +// and the user message becomes a system-role, user-visible notice row. +func hookEventMessages(response agenthooks.Response, modelConfigID uuid.UUID) ([]chatstate.Message, error) { + messages := make([]chatstate.Message, 0, 2) + if response.ModelContext != "" { + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(response.ModelContext)}) + if err != nil { + return nil, xerrors.Errorf("marshal hook model context: %w", err) + } + messages = append(messages, chatstate.Message{ + Role: database.ChatMessageRoleUser, + Content: content, + Visibility: database.ChatMessageVisibilityModel, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + }) + } + if response.UserMessage != "" { + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(response.UserMessage)}) + if err != nil { + return nil, xerrors.Errorf("marshal hook user message: %w", err) + } + messages = append(messages, chatstate.Message{ + Role: database.ChatMessageRoleSystem, + Content: content, + Visibility: database.ChatMessageVisibilityUser, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + }) + } + return messages, nil +} + +func hookEventMessagesForResponses( + responses []agenthooks.Response, + modelConfigID uuid.UUID, +) ([]chatstate.Message, error) { + var messages []chatstate.Message + for _, response := range responses { + responseMessages, err := hookEventMessages(response, modelConfigID) + if err != nil { + return nil, err + } + messages = append(messages, responseMessages...) + } + return messages, nil +} + +// applyHookResponseMessages inserts hook event rows before the step's +// own rows so injected model context precedes the assistant content it +// steers; providers require tool results to directly follow the +// assistant tool calls. +func applyHookResponseMessages( + messages stepMessagesForCommit, + responses []agenthooks.Response, + modelConfigID uuid.UUID, +) (stepMessagesForCommit, error) { + rows, err := hookEventMessagesForResponses(responses, modelConfigID) + if err != nil { + return stepMessagesForCommit{}, err + } + if len(rows) > 0 { + messages.Messages = append(rows, messages.Messages...) + messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) + } + return messages, nil +} + +func appendHookResponseMessages( + messages stepMessagesForCommit, + responses []agenthooks.Response, + modelConfigID uuid.UUID, +) (stepMessagesForCommit, error) { + suffix, err := hookEventMessagesForResponses(responses, modelConfigID) + if err != nil { + return stepMessagesForCommit{}, err + } + if len(suffix) > 0 { + messages.Messages = append(messages.Messages, suffix...) + messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) + } + return messages, nil +} + +func userPromptOverride(response agenthooks.Response) (string, bool, error) { + if response.Permission == nil || response.Permission.Decision != agenthooks.PermissionAllow { + return "", false, nil + } + var override struct { + Prompt *string `json:"prompt"` + } + decoder := json.NewDecoder(bytes.NewReader(response.Permission.InputOverride)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&override); err != nil { + return "", false, xerrors.Errorf("decode user prompt input override: %w", err) + } + if override.Prompt == nil { + return "", false, xerrors.New("decode user prompt input override: prompt is required") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return "", false, xerrors.New("decode user prompt input override: trailing JSON value") + } + return *override.Prompt, true, nil +} + +// userPromptHookParts converts a user_prompt_submit response into the +// typed parts carried inside the submitted message: hook-context is +// model-only steering, hook-notice is a client-only notice. +func userPromptHookParts(response agenthooks.Response) []codersdk.ChatMessagePart { + parts := make([]codersdk.ChatMessagePart, 0, 2) + if response.ModelContext != "" { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeHookContext, + Text: response.ModelContext, + }) + } + if response.UserMessage != "" { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeHookNotice, + Text: response.UserMessage, + }) + } + return parts +} + +// composeUserPromptContent applies a user_prompt_submit response to the +// submitted parts. The merge order is fixed: override-or-original user +// parts first, then hook-context, then hook-notice. The composite +// content then flows through the ordinary send, queue, and edit paths. +func composeUserPromptContent(parts []codersdk.ChatMessagePart, response agenthooks.Response) ([]codersdk.ChatMessagePart, bool, error) { + override, overridden, err := userPromptOverride(response) + if err != nil { + return nil, false, err + } + userParts := parts + if overridden { + userParts = []codersdk.ChatMessagePart{codersdk.ChatMessageText(override)} + } + hookParts := userPromptHookParts(response) + if len(hookParts) == 0 { + return userParts, overridden, nil + } + combined := make([]codersdk.ChatMessagePart, 0, len(userParts)+len(hookParts)) + combined = append(combined, userParts...) + combined = append(combined, hookParts...) + return combined, overridden, nil +} diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go new file mode 100644 index 00000000000..072efd99108 --- /dev/null +++ b/coderd/x/chatd/hooks_internal_test.go @@ -0,0 +1,274 @@ +package chatd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/testutil" +) + +func TestSessionStartDispatchSources(t *testing.T) { + t.Parallel() + + const secret = "test-hook-secret-32-bytes-minimum!!" + type received struct { + request agenthooks.Request + claims agenthooks.Claims + data *agenthooks.SessionStartData + } + receivedCh := make(chan received, 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)) + claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte(secret)) + require.NoError(t, err) + decoded, err := request.Decode() + require.NoError(t, err) + data, ok := decoded.(*agenthooks.SessionStartData) + require.True(t, ok) + receivedCh <- received{request: request, claims: claims, data: data} + _, err = w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + db, _ := dbtestutil.NewDB(t) + dispatcher := chathooks.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + secret, + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + ) + server := &Server{hookDispatcher: dispatcher} + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{OwnerID: user.ID, OrganizationID: org.ID, LastModelConfigID: model.ID}) + turnID := uuid.New() + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := server.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSource(nil)}) + require.NoError(t, err) + _, err = server.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}) + require.NoError(t, err) + + startup := <-receivedCh + resume := <-receivedCh + require.Equal(t, agenthooks.EventSessionStart, startup.request.Type) + require.Equal(t, sessionStartSourceStartup, startup.data.Source) + require.Equal(t, startup.request.Meta.DispatchID, startup.claims.JTI) + require.Equal(t, agenthooks.EventSessionStart, resume.request.Type) + require.Equal(t, sessionStartSourceResume, resume.data.Source) + require.Equal(t, resume.request.Meta.DispatchID, resume.claims.JTI) + require.NotEqual(t, startup.claims.JTI, resume.claims.JTI) +} + +func TestSessionStartTrackerRetriesIncompleteDispatch(t *testing.T) { + t.Parallel() + tracker := &sessionStartTracker{} + claimed, complete, err := tracker.claim(t.Context()) + require.NoError(t, err) + require.True(t, claimed) + + canceled, cancel := context.WithCancel(t.Context()) + cancel() + _, _, err = tracker.claim(canceled) + require.ErrorIs(t, err, context.Canceled) + complete(false) + + claimed, complete, err = tracker.claim(t.Context()) + require.NoError(t, err) + require.True(t, claimed) + complete(true) + claimed, _, err = tracker.claim(t.Context()) + require.NoError(t, err) + require.False(t, claimed) +} + +func TestSessionStartDispatchFailureFinishesGeneration(t *testing.T) { + t.Parallel() + f := newTaskTestFixture(t) + chat := f.createRunningChat(t) + workerID := uuid.New() + runnerID := uuid.New() + chat = f.acquireChat(t, chat.ID, workerID, runnerID) + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(consumer.Close) + dispatcher := chathooks.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + "test-hook-secret-32-bytes-minimum!!", + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + ) + starter := newTestTaskStarter(t, f, newTaskSideEffectRecorder()) + starter.server.hookDispatcher = dispatcher + ctx := testutil.Context(t, testutil.WaitLong) + debugTurn := newRunnerDebugTurn(ctx, starter.opts.Logger) + defer debugTurn.Finalize(ctx) + err := starter.StartGeneration(ctx, chatWorkerTaskStartInput{ + ChatID: chat.ID, + WorkerID: workerID, + RunnerID: runnerID, + HistoryVersion: chat.HistoryVersion, + GenerationAttempt: chat.GenerationAttempt, + Status: database.ChatStatusRunning, + DebugTurn: debugTurn, + SessionStart: &sessionStartTracker{}, + }) + require.NoError(t, err) + updated, err := f.db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusError, updated.Status) + var chatErr codersdk.ChatError + require.NoError(t, json.Unmarshal(updated.LastError.RawMessage, &chatErr)) + require.Equal(t, codersdk.ChatErrorKindHookDispatchFailed, chatErr.Kind) + require.Contains(t, chatErr.Message, "hook dispatch failed: session_start: http_error (dispatch ") + require.False(t, chatErr.Retryable) +} + +func TestApplySessionStartResponse(t *testing.T) { + t.Parallel() + f := newTaskTestFixture(t) + chat := f.createRunningChat(t) + workerID := uuid.New() + runnerID := uuid.New() + chat = f.acquireChat(t, chat.ID, workerID, runnerID) + ctx := testutil.Context(t, testutil.WaitLong) + input := chatWorkerTaskStartInput{ + ChatID: chat.ID, + WorkerID: workerID, + RunnerID: runnerID, + HistoryVersion: chat.HistoryVersion, + Status: database.ChatStatusRunning, + } + _, err := applySessionStartResponse( + ctx, + chatstate.NewChatMachine(f.db, f.pubsub, chat.ID), + input, + chat, + agenthooks.Response{ + ModelContext: "model context", + UserMessage: "user notice", + }, + ) + require.NoError(t, err) + + promptRows, err := f.db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, "model context", hookMessageTextInternal(t, promptRows[len(promptRows)-1])) + require.Equal(t, database.ChatMessageVisibilityModel, promptRows[len(promptRows)-1].Visibility) + allRows, err := f.db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) + require.NoError(t, err) + userNotice := allRows[len(allRows)-1] + require.Equal(t, database.ChatMessageRoleSystem, userNotice.Role) + require.Equal(t, database.ChatMessageVisibilityUser, userNotice.Visibility) + require.Equal(t, "user notice", hookMessageTextInternal(t, userNotice)) +} + +func TestApplySessionStartResponseNoOp(t *testing.T) { + t.Parallel() + f := newTaskTestFixture(t) + chat := f.createRunningChat(t) + f.pubsub.clear() + result, err := applySessionStartResponse( + testutil.Context(t, testutil.WaitLong), + chatstate.NewChatMachine(f.db, f.pubsub, chat.ID), + chatWorkerTaskStartInput{}, + chat, + agenthooks.Response{}, + ) + require.NoError(t, err) + require.Equal(t, chat.SnapshotVersion, result.Chat.SnapshotVersion) + require.Empty(t, f.pubsub.events()) +} + +func hookMessageTextInternal(t *testing.T, message database.ChatMessage) string { + t.Helper() + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + require.Len(t, parts, 1) + return parts[0].Text +} + +func TestRejectDuplicateToolUseIDs(t *testing.T) { + t.Parallel() + + require.NoError(t, rejectDuplicateToolUseIDs([]fantasy.ToolCallContent{ + {ToolCallID: "first", ToolName: "read_file", Input: `{}`}, + {ToolCallID: "second", ToolName: "execute", Input: `{}`}, + })) + require.ErrorContains(t, rejectDuplicateToolUseIDs([]fantasy.ToolCallContent{ + {ToolCallID: "duplicate", ToolName: "read_file", Input: `{}`}, + {ToolCallID: "duplicate", ToolName: "execute", Input: `{}`}, + }), "duplicate tool use ID") +} + +func TestRestoreToolCallOrder(t *testing.T) { + t.Parallel() + + calls := []fantasy.ToolCallContent{ + {ToolCallID: "call_a", ToolName: "write_file"}, + {ToolCallID: "call_b", ToolName: "read_file"}, + {ToolCallID: "call_c", ToolName: "execute"}, + } + content := []fantasy.Content{ + fantasy.ToolResultContent{ToolCallID: "call_c", ToolName: "execute"}, + fantasy.ToolResultContent{ToolCallID: "call_b", ToolName: "read_file"}, + fantasy.ToolResultContent{ToolCallID: "call_a", ToolName: "write_file"}, + } + restoreToolCallOrder(content, calls) + gotIDs := make([]string, 0, len(content)) + for _, entry := range content { + result, ok := entry.(fantasy.ToolResultContent) + require.True(t, ok) + gotIDs = append(gotIDs, result.ToolCallID) + } + require.Equal(t, []string{"call_a", "call_b", "call_c"}, gotIDs) + + mixed := []fantasy.Content{ + fantasy.ToolResultContent{ToolCallID: "call_b", ToolName: "read_file"}, + fantasy.TextContent{Text: "note"}, + fantasy.ToolResultContent{ToolCallID: "unknown", ToolName: "other"}, + fantasy.ToolResultContent{ToolCallID: "call_a", ToolName: "write_file"}, + } + restoreToolCallOrder(mixed, calls) + first, ok := mixed[0].(fantasy.ToolResultContent) + require.True(t, ok) + require.Equal(t, "call_a", first.ToolCallID) + _, ok = mixed[1].(fantasy.TextContent) + require.True(t, ok) + unknown, ok := mixed[2].(fantasy.ToolResultContent) + require.True(t, ok) + require.Equal(t, "unknown", unknown.ToolCallID) + last, ok := mixed[3].(fantasy.ToolResultContent) + require.True(t, ok) + require.Equal(t, "call_b", last.ToolCallID) +} diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go new file mode 100644 index 00000000000..c727cdbb292 --- /dev/null +++ b/coderd/x/chatd/hooks_test.go @@ -0,0 +1,657 @@ +package chatd_test + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/testutil" +) + +func TestSendMessageUserPromptSubmitHook(t *testing.T) { + t.Parallel() + + t.Run("override", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + + submitted := []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("before"), + codersdk.ChatMessageFileReference("main.go", 1, 3, "package main"), + } + 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)) + decoded, err := request.Decode() + require.NoError(t, err) + data, ok := decoded.(*agenthooks.UserPromptSubmitData) + require.True(t, ok) + require.Equal(t, "before", data.Prompt) + var hookParts []codersdk.ChatMessagePart + require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) + require.Equal(t, submitted, hookParts, "hook payload must carry non-text parts") + require.NotNil(t, request.Meta.TurnID) + _, err = w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"prompt":"after"}},"model_context":"model only","user_message":"user only"}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + server := newHookTestServer(t, db, ps, consumer) + result, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + Content: submitted, + }) + require.NoError(t, err) + parts, err := chatprompt.ParseContent(result.Message) + require.NoError(t, err) + require.Equal(t, []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("after"), + {Type: codersdk.ChatMessagePartTypeHookContext, Text: "model only"}, + {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "user only"}, + }, parts) + require.Len(t, result.InsertedMessages, 1) + require.Equal(t, result.Message.ID, result.InsertedMessages[0].ID) + }) + + t.Run("deny", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{"permission":{"decision":"deny"},"user_message":"blocked"}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + server := newHookTestServer(t, db, ps, consumer) + _, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("blocked prompt")}, + }) + var denied *chatd.UserPromptDeniedError + require.ErrorAs(t, err, &denied) + require.Equal(t, "blocked", denied.UserMessage) + + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) + require.NoError(t, err) + require.Empty(t, messages) + }) +} + +func newHookDispatcher(t *testing.T, _ database.Store, consumer *httptest.Server) *chathooks.Dispatcher { + t.Helper() + return chathooks.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + "test-hook-secret-32-bytes-minimum!!", + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + ) +} + +func newHookTestServer(t *testing.T, db database.Store, ps dbpubsub.Pubsub, consumer *httptest.Server) *chatd.Server { + t.Helper() + return newTestServer(t, db, ps, uuid.New(), func(cfg *chatd.Config) { + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) +} + +func TestHookDispatcherRequiresExperiment(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + + 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) + + server := newTestServer(t, db, ps, uuid.New(), func(cfg *chatd.Config) { + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.Experiments = slices.DeleteFunc( + slices.Clone(codersdk.ExperimentsKnown), + func(e codersdk.Experiment) bool { return e == codersdk.ExperimentAgentLifecycleHooks }, + ) + }) + result, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, + }) + require.NoError(t, err) + parts, err := chatprompt.ParseContent(result.Message) + require.NoError(t, err) + require.Equal(t, []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, parts) + + require.Zero(t, hookRequests.Load()) +} + +func TestSendMessageUserPromptSubmitPassthrough(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + var received agenthooks.Request + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + result, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("passthrough")}, + }) + require.NoError(t, err) + require.Equal(t, "passthrough", hookMessageText(t, result.Message)) + require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) + data, err := received.Decode() + require.NoError(t, err) + promptData, ok := data.(*agenthooks.UserPromptSubmitData) + require.True(t, ok) + require.Equal(t, "passthrough", promptData.Prompt) + // The persisted content is jsonb-normalized, so compare JSON + // semantics rather than raw bytes. + require.JSONEq(t, string(result.Message.Content.RawMessage), string(promptData.Parts)) +} + +func TestSendMessageUserPromptSubmitQueue(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat, err := newTestServer(t, db, ps, uuid.New()).CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "queued hook", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("running")}, + }) + require.NoError(t, err) + var received agenthooks.Request + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) + _, err := w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"prompt":"queued override"}},"model_context":"queued context","user_message":"queued notice"}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + + result, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("queued original")}, + BusyBehavior: chatd.SendMessageBusyBehaviorQueue, + }) + require.NoError(t, err) + require.True(t, result.Queued) + require.NotNil(t, result.QueuedMessage) + queuedParts, err := chatprompt.ParseContent(database.ChatMessage{ + Role: database.ChatMessageRoleUser, + Content: pqtype.NullRawMessage{RawMessage: result.QueuedMessage.Content, Valid: true}, + ContentVersion: chatprompt.CurrentContentVersion, + }) + require.NoError(t, err) + wantQueuedParts := []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("queued override"), + {Type: codersdk.ChatMessagePartTypeHookContext, Text: "queued context"}, + {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "queued notice"}, + } + require.Equal(t, wantQueuedParts, queuedParts) + queued, err := db.GetChatQueuedMessages(ctx, chat.ID) + require.NoError(t, err) + require.Len(t, queued, 1) + persistedParts, err := chatprompt.ParseContent(database.ChatMessage{ + Role: database.ChatMessageRoleUser, + Content: pqtype.NullRawMessage{RawMessage: queued[0].Content, Valid: true}, + ContentVersion: chatprompt.CurrentContentVersion, + }) + require.NoError(t, err) + require.Equal(t, wantQueuedParts, persistedParts) + require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) + data, err := received.Decode() + require.NoError(t, err) + require.Equal(t, "queued original", data.(*agenthooks.UserPromptSubmitData).Prompt) +} + +func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statusCode int + response string + assertErr func(*testing.T, error) + }{ + { + name: "deny", + statusCode: http.StatusOK, + response: `{"permission":{"decision":"deny"},"user_message":"blocked"}`, + assertErr: func(t *testing.T, err error) { + var denied *chatd.UserPromptDeniedError + require.ErrorAs(t, err, &denied) + }, + }, + { + name: "dispatch failure", + statusCode: http.StatusInternalServerError, + assertErr: func(t *testing.T, err error) { + var dispatchErr *chathooks.DispatchError + require.ErrorAs(t, err, &dispatchErr) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat, err := newTestServer(t, db, ps, uuid.New()).CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "queued rejection", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("running")}, + }) + require.NoError(t, err) + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.statusCode) + if test.response != "" { + _, err := w.Write([]byte(test.response)) + require.NoError(t, err) + } + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("queued")}, + BusyBehavior: chatd.SendMessageBusyBehaviorQueue, + }) + test.assertErr(t, err) + queued, err := db.GetChatQueuedMessages(ctx, chat.ID) + require.NoError(t, err) + require.Empty(t, queued) + updated, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusRunning, updated.Status) + require.False(t, updated.LastError.Valid) + }) + } +} + +func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + var received agenthooks.Request + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + + _, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("fails")}, + }) + var dispatchErr *chathooks.DispatchError + require.ErrorAs(t, err, &dispatchErr) + require.Equal(t, chathooks.ResultHTTPError, dispatchErr.Class) + updated, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusError, updated.Status) + var chatErr codersdk.ChatError + require.NoError(t, json.Unmarshal(updated.LastError.RawMessage, &chatErr)) + require.Equal(t, "hook dispatch failed: user_prompt_submit: http_error (dispatch "+dispatchErr.DispatchID.String()+")", chatErr.Message) + require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) + data, err := received.Decode() + require.NoError(t, err) + prompt, ok := data.(*agenthooks.UserPromptSubmitData) + require.True(t, ok) + require.Equal(t, "fails", prompt.Prompt) + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) + require.NoError(t, err) + require.Empty(t, messages) + queued, err := db.GetChatQueuedMessages(ctx, chat.ID) + require.NoError(t, err) + require.Empty(t, queued) +} + +func TestEditMessageUserPromptSubmitHook(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("original")}) + require.NoError(t, err) + inserted, err := db.InsertChatMessages(ctx, chatd.BuildSingleChatMessageInsertParams( + chat.ID, database.ChatMessageRoleUser, content, database.ChatMessageVisibilityBoth, model.ID, chatprompt.CurrentContentVersion, user.ID, + )) + require.NoError(t, err) + require.Len(t, inserted, 1) + type receivedHook struct { + request agenthooks.Request + claims agenthooks.Claims + } + var receivedMu sync.Mutex + received := make([]receivedHook, 0, 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)) + claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte("test-hook-secret-32-bytes-minimum!!")) + require.NoError(t, err) + receivedMu.Lock() + received = append(received, receivedHook{request: request, claims: claims}) + receivedMu.Unlock() + response := `{"model_context":"clear context","user_message":"clear notice"}` + if request.Type == agenthooks.EventUserPromptSubmit { + response = `{"permission":{"decision":"allow","input_override":{"prompt":"edited override"}},"model_context":"edit context","user_message":"edit notice"}` + } + _, err = w.Write([]byte(response)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + + result, err := server.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + EditedMessageID: inserted[0].ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited original")}, + }) + require.NoError(t, err) + parts, err := chatprompt.ParseContent(result.Message) + require.NoError(t, err) + require.Equal(t, []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("edited override"), + {Type: codersdk.ChatMessagePartTypeHookContext, Text: "edit context"}, + {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "edit notice"}, + }, parts) + receivedMu.Lock() + received = slices.Clone(received) + receivedMu.Unlock() + require.Len(t, received, 2) + require.Equal(t, agenthooks.EventSessionStart, received[0].request.Type) + data, err := received[0].request.Decode() + require.NoError(t, err) + require.Equal(t, &agenthooks.SessionStartData{Source: "clear"}, data) + require.Equal(t, received[0].request.Meta.DispatchID, received[0].claims.JTI) + require.Equal(t, agenthooks.EventUserPromptSubmit, received[1].request.Type) + promptData, err := received[1].request.Decode() + require.NoError(t, err) + prompt, ok := promptData.(*agenthooks.UserPromptSubmitData) + require.True(t, ok) + require.Equal(t, "edited original", prompt.Prompt) + require.NotNil(t, received[0].request.Meta.TurnID) + require.Equal(t, received[0].request.Meta.TurnID, received[1].request.Meta.TurnID) + require.Equal(t, received[1].request.Meta.DispatchID, received[1].claims.JTI) + require.NotEqual(t, received[0].claims.JTI, received[1].claims.JTI) + rows, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) + require.NoError(t, err) + var foundNotice bool + for _, row := range rows { + if row.Role == database.ChatMessageRoleSystem && row.Visibility == database.ChatMessageVisibilityUser && hookMessageText(t, row) == "clear notice" { + foundNotice = true + } + } + require.True(t, foundNotice) + promptRows, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + var foundContext bool + for _, row := range promptRows { + if row.Visibility == database.ChatMessageVisibilityModel && hookMessageText(t, row) == "clear context" { + foundContext = true + } + } + require.True(t, foundContext) +} + +func TestEditMessageInvalidTargetSkipsHooks(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + var dispatched atomic.Int32 + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + dispatched.Add(1) + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + + _, err := server.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + EditedMessageID: 999999, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edit of nothing")}, + }) + require.ErrorIs(t, err, chatd.ErrEditedMessageNotFound) + + // dbgen.Chat ignores seed.Archived; archive explicitly. + archived := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + _, err = db.ArchiveChatByID(ctx, archived.ID) + require.NoError(t, err) + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: archived.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("send to archived")}, + }) + require.ErrorIs(t, err, chatd.ErrChatArchived) + _, err = server.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: archived.ID, + CreatedBy: user.ID, + EditedMessageID: 1, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edit archived")}, + }) + require.ErrorIs(t, err, chatd.ErrChatArchived) + + require.Zero(t, dispatched.Load(), "invalid targets must not dispatch hooks") +} + +func TestPromptHooksAdmissionPreflight(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + received := make(chan agenthooks.Request, 8) + 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)) + received <- request + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + server := newHookTestServer(t, db, ps, consumer) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + _, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("bad model")}, + ModelConfigID: uuid.New(), + }) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("original")}) + require.NoError(t, err) + inserted, err := db.InsertChatMessages(ctx, chatd.BuildSingleChatMessageInsertParams( + chat.ID, database.ChatMessageRoleUser, content, database.ChatMessageVisibilityBoth, model.ID, chatprompt.CurrentContentVersion, user.ID, + )) + require.NoError(t, err) + require.Len(t, inserted, 1) + _, err = server.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + EditedMessageID: inserted[0].ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("bad model edit")}, + ModelConfigID: uuid.New(), + }) + require.ErrorIs(t, err, chatd.ErrInvalidModelConfigID) + + busy := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + Status: database.ChatStatusRunning, + }) + queuedContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("queued")}) + require.NoError(t, err) + for range chatstate.MaxQueueSize { + _, err = db.InsertChatQueuedMessageWithCreator(ctx, database.InsertChatQueuedMessageWithCreatorParams{ + ChatID: busy.ID, + Content: queuedContent.RawMessage, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + CreatedBy: user.ID, + }) + require.NoError(t, err) + } + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: busy.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("queue full")}, + }) + require.ErrorIs(t, err, chatstate.ErrMessageQueueFull) + + // Usage limits are deployment-wide, so these cases run last. + _, err = db.UpsertChatUsageLimitConfig(ctx, database.UpsertChatUsageLimitConfigParams{ + Enabled: true, + DefaultLimitMicros: 100, + Period: string(codersdk.ChatUsageLimitPeriodDay), + }) + require.NoError(t, err) + assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("assistant")}) + require.NoError(t, err) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + ContentVersion: chatprompt.CurrentContentVersion, + Content: assistantContent, + TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true}, + }) + var limitErr *chatd.UsageLimitExceededError + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("over limit")}, + }) + require.ErrorAs(t, err, &limitErr) + _, err = server.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + EditedMessageID: inserted[0].ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("over limit edit")}, + }) + require.ErrorAs(t, err, &limitErr) + + select { + case request := <-received: + t.Fatalf("admission-rejected prompt dispatched %s", request.Type) + default: + } +} + +func TestSendMessageHooksDisabled(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + server := newTestServer(t, db, ps, uuid.New()) + result, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("unchanged")}, + }) + require.NoError(t, err) + require.Equal(t, "unchanged", hookMessageText(t, result.Message)) +} + +func hookMessageText(t *testing.T, message database.ChatMessage) string { + t.Helper() + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + require.Len(t, parts, 1) + return parts[0].Text +} diff --git a/coderd/x/chatd/message_conversion.go b/coderd/x/chatd/message_conversion.go index c70f1efcd8c..82fcc8c2029 100644 --- a/coderd/x/chatd/message_conversion.go +++ b/coderd/x/chatd/message_conversion.go @@ -336,19 +336,28 @@ func buildCompactionMessages(input buildCompactionMessagesInput) (compactionMess return compactionMessagesForCommit{Messages: messages, HiddenCount: 1}, nil } -func currentTurnStepCount(messages []database.ChatMessage) int { - latestUser := -1 +// Hook model-context messages use the user role but must not reset +// per-turn guards. +func lastUserPromptIndex(messages []database.ChatMessage) int { + index := -1 for i, msg := range messages { if msg.Deleted || msg.Compressed { continue } - if msg.Role == database.ChatMessageRoleUser { - latestUser = i + if msg.Role == database.ChatMessageRoleUser && msg.Visibility != database.ChatMessageVisibilityModel { + index = i } } + return index +} + +func currentTurnStartIndex(messages []database.ChatMessage) int { + return lastUserPromptIndex(messages) + 1 +} + +func currentTurnStepCount(messages []database.ChatMessage) int { count := 0 - for i := latestUser + 1; i < len(messages); i++ { - msg := messages[i] + for _, msg := range messages[currentTurnStartIndex(messages):] { if msg.Deleted || msg.Compressed { continue } @@ -477,16 +486,7 @@ func historyHasStopAfterToolResult(messages []database.ChatMessage, stopAfterToo if len(stopAfterTools) == 0 { return false, nil } - start := 0 - for i, msg := range messages { - if msg.Deleted || msg.Compressed { - continue - } - if msg.Role == database.ChatMessageRoleUser { - start = i + 1 - } - } - for _, msg := range messages[start:] { + for _, msg := range messages[currentTurnStartIndex(messages):] { if msg.Deleted || msg.Compressed || msg.Role != database.ChatMessageRoleTool { continue } diff --git a/coderd/x/chatd/message_conversion_test.go b/coderd/x/chatd/message_conversion_test.go index 4a14d3cd34a..40eb2fa291d 100644 --- a/coderd/x/chatd/message_conversion_test.go +++ b/coderd/x/chatd/message_conversion_test.go @@ -271,6 +271,22 @@ func TestCurrentTurnStepCount_CountsAssistantMessagesAfterLatestUser(t *testing. require.Equal(t, 2, got) } +func TestCurrentTurnStepCount_IgnoresHookModelContext(t *testing.T) { + t.Parallel() + + hookContext := dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("hook context")) + hookContext.Visibility = database.ChatMessageVisibilityModel + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("prompt")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("one")), + dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("call", "tool", json.RawMessage(`{}`), false, false)), + hookContext, + dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("two")), + } + got := currentTurnStepCount(messages) + require.Equal(t, 2, got) +} + func TestDecisionCompactsAgainAfterPostCompactionTurn(t *testing.T) { t.Parallel() @@ -545,6 +561,22 @@ func TestDecisionDetectsStopAfterToolFromCommittedHistory(t *testing.T) { require.False(t, got) } +func TestDecisionDetectsStopAfterToolAcrossHookContext(t *testing.T) { + t.Parallel() + + hookContext := dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("hook context")) + hookContext.Visibility = database.ChatMessageVisibilityModel + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("plan")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("plan-1", "propose_plan", json.RawMessage(`{}`))), + dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("plan-1", "propose_plan", json.RawMessage(`{"ok":true}`), false, false)), + hookContext, + } + got, err := historyHasStopAfterToolResult(messages, map[string]struct{}{"propose_plan": {}}) + require.NoError(t, err) + require.True(t, got) +} + func TestDecisionDetectsCurrentHistoryCompletion(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/options.go b/coderd/x/chatd/options.go index ff3dbdd3d9a..f7570a9035b 100644 --- a/coderd/x/chatd/options.go +++ b/coderd/x/chatd/options.go @@ -3,6 +3,7 @@ package chatd import ( "context" "database/sql" + "sync" "sync/atomic" "time" @@ -53,8 +54,12 @@ type chatWorkerTaskStarter interface { // chatWorkerTaskStartInput describes one runner task invocation. type chatWorkerTaskStartInput struct { - TaskID uuid.UUID - ChatID uuid.UUID + TaskID uuid.UUID + ChatID uuid.UUID + // TurnID is a process-local correlation ID minted per generation + // task run. It groups the run's hook events; it is best-effort only + // and never persisted. + TurnID uuid.UUID WorkerID uuid.UUID RunnerID uuid.UUID HistoryVersion int64 @@ -62,6 +67,114 @@ type chatWorkerTaskStartInput struct { Status database.ChatStatus RequiresActionDeadlineAt sql.NullTime DebugTurn *runnerDebugTurn + SessionStart *sessionStartTracker + StopNudges *stopNudgeTracker +} + +func (i chatWorkerTaskStartInput) hookTurnID() *uuid.UUID { + if i.TurnID == uuid.Nil { + return nil + } + turnID := i.TurnID + return &turnID +} + +// stopNudgeTracker allows at most one stop-hook nudge continuation per +// turn. Turns are keyed by the last user prompt's message ID so the +// claim survives task restarts, which mint fresh process-local turn +// IDs. +type stopNudgeTracker struct { + mu sync.Mutex + turnKey int64 + claimed bool + pending bool +} + +// stopNudgeKey identifies the current turn by its prompt row. Model +// visibility user rows are hook context, not prompts. +func stopNudgeKey(messages []database.ChatMessage) int64 { + index := lastUserPromptIndex(messages) + if index == -1 { + return 0 + } + return messages[index].ID +} + +func (t *stopNudgeTracker) claim(turnKey int64) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.turnKey != turnKey { + t.turnKey = turnKey + t.claimed = false + } + if t.claimed { + return false + } + t.claimed = true + t.pending = true + return true +} + +func (t *stopNudgeTracker) consume(turnKey int64) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.turnKey != turnKey || !t.pending { + return false + } + t.pending = false + return true +} + +func (t *stopNudgeTracker) cancel(turnKey int64) { + t.mu.Lock() + defer t.mu.Unlock() + if t.turnKey != turnKey || !t.pending { + return + } + t.pending = false + t.claimed = false +} + +func (t *stopNudgeTracker) reset() { + t.mu.Lock() + t.turnKey = 0 + t.claimed = false + t.pending = false + t.mu.Unlock() +} + +type sessionStartTracker struct { + mu sync.Mutex + completed bool + inFlight chan struct{} +} + +func (t *sessionStartTracker) claim(ctx context.Context) (bool, func(bool), error) { + for { + t.mu.Lock() + if t.completed { + t.mu.Unlock() + return false, nil, nil + } + if t.inFlight == nil { + t.inFlight = make(chan struct{}) + t.mu.Unlock() + return true, func(completed bool) { + t.mu.Lock() + t.completed = completed + close(t.inFlight) + t.inFlight = nil + t.mu.Unlock() + }, nil + } + inFlight := t.inFlight + t.mu.Unlock() + select { + case <-inFlight: + case <-ctx.Done(): + return false, nil, ctx.Err() + } + } } // chatWorkerOptions configures a chatWorker. diff --git a/coderd/x/chatd/post_tool_use_test.go b/coderd/x/chatd/post_tool_use_test.go new file mode 100644 index 00000000000..30c64b312c2 --- /dev/null +++ b/coderd/x/chatd/post_tool_use_test.go @@ -0,0 +1,520 @@ +package chatd_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/testutil" +) + +func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) == 1 { + first := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/first.txt"}`) + first.Choices[0].ToolCalls[0].ID = "call_first" + second := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/second.txt"}`).Choices[0].ToolCalls[0] + second.ID = "call_second" + second.Index = 1 + first.Choices[0].ToolCalls = append(first.Choices[0].ToolCalls, second) + return chattest.OpenAIStreamingResponse(first) + } + toolResultIndex := -1 + contextIndex := -1 + for i, message := range req.Messages { + if message.Role == "tool" && strings.Contains(message.Content, "data") { + toolResultIndex = i + } + if strings.Contains(message.Content, "lint feedback") { + contextIndex = i + } + } + require.NotEqual(t, -1, toolResultIndex) + require.NotEqual(t, -1, contextIndex) + require.Less(t, toolResultIndex, contextIndex) + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + var mu sync.Mutex + var received []agenthooks.PostToolUseData + 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)) + if request.Type != agenthooks.EventPostToolUse { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + decoded, err := request.Decode() + require.NoError(t, err) + data := decoded.(*agenthooks.PostToolUseData) + mu.Lock() + received = append(received, *data) + index := len(received) + mu.Unlock() + + messages := chatMessages(ctx, t, db, request.Meta.ChatID) + for _, message := range messages { + require.NotEqual(t, database.ChatMessageRoleTool, message.Role) + } + if index == 1 { + _, err = w.Write([]byte(`{"model_context":"lint feedback","user_message":"tool notice"}`)) + } else { + _, err = w.Write([]byte(`{}`)) + } + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), gomock.Any(), int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 4, TotalLines: 1, LinesRead: 1, Content: "data", + }, nil). + Times(2) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "post-tool-use-responses", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read both files"), + }, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + mu.Lock() + receivedSnapshot := append([]agenthooks.PostToolUseData(nil), received...) + mu.Unlock() + require.Len(t, receivedSnapshot, 2) + require.Equal(t, "call_first", receivedSnapshot[0].ToolUseID) + require.Equal(t, "call_second", receivedSnapshot[1].ToolUseID) + require.Equal(t, "read_file", receivedSnapshot[0].ToolName) + require.Empty(t, receivedSnapshot[0].ToolError) + require.Contains(t, string(receivedSnapshot[0].ToolResponse), "data") + + var toolResults, userMessages int + for _, message := range chatMessages(ctx, t, db, chat.ID) { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + if message.Role == database.ChatMessageRoleTool { + toolResults++ + } + if len(parts) == 1 && parts[0].Text == "tool notice" { + userMessages++ + require.Equal(t, database.ChatMessageVisibilityUser, message.Visibility) + } + } + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + var modelContexts int + for _, message := range promptMessages { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + if len(parts) == 1 && parts[0].Text == "lint feedback" { + modelContexts++ + require.Equal(t, database.ChatMessageVisibilityModel, message.Visibility) + } + } + require.Equal(t, 2, toolResults) + require.Equal(t, 1, modelContexts) + require.Equal(t, 1, userMessages) +} + +func TestPostToolUseHookDynamicResult(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) == 1 { + chunk := chattest.OpenAIToolCallChunk("my_dynamic_tool", `{"query":"value"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_dynamic_result" + return chattest.OpenAIStreamingResponse(chunk) + } + resultIndex := -1 + contextIndex := -1 + for i, message := range req.Messages { + if message.Role == "tool" && strings.Contains(message.Content, "answer") { + resultIndex = i + } + if strings.Contains(message.Content, "dynamic feedback") { + contextIndex = i + } + } + require.NotEqual(t, -1, resultIndex) + require.NotEqual(t, -1, contextIndex) + require.Less(t, resultIndex, contextIndex) + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + var postCalls atomic.Int32 + 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)) + if request.Type != agenthooks.EventPostToolUse { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + postCalls.Add(1) + decoded, err := request.Decode() + require.NoError(t, err) + data := decoded.(*agenthooks.PostToolUseData) + require.Equal(t, "call_dynamic_result", data.ToolUseID) + require.Equal(t, "my_dynamic_tool", data.ToolName) + require.JSONEq(t, `{"answer":42}`, string(data.ToolResponse)) + _, err = w.Write([]byte(`{"model_context":"dynamic feedback","user_message":"dynamic notice"}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "post-tool-use-dynamic", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("call the dynamic tool"), + }, + DynamicTools: dynamicToolJSON(t, "my_dynamic_tool"), + }) + require.NoError(t, err) + testutil.Eventually(ctx, t, func(context.Context) bool { + updated, err := db.GetChatByID(ctx, chat.ID) + return err == nil && updated.Status == database.ChatStatusRequiresAction + }, testutil.IntervalFast) + + err = server.SubmitToolResults(ctx, chatd.SubmitToolResultsOptions{ + ChatID: chat.ID, + UserID: user.ID, + ModelConfigID: model.ID, + Results: []codersdk.ToolResult{{ + ToolCallID: "call_dynamic_result", + Output: json.RawMessage(`{"answer":42}`), + }}, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.Equal(t, int32(1), postCalls.Load()) + + err = server.SubmitToolResults(ctx, chatd.SubmitToolResultsOptions{ + ChatID: chat.ID, + UserID: user.ID, + ModelConfigID: model.ID, + Results: []codersdk.ToolResult{{ + ToolCallID: "call_dynamic_result", + Output: json.RawMessage(`{"answer":42}`), + }}, + }) + require.Error(t, err) + require.Equal(t, int32(1), postCalls.Load()) + var notices int + for _, message := range chatMessages(ctx, t, db, chat.ID) { + if hookMessageText(t, message) == "dynamic notice" { + notices++ + } + } + require.Equal(t, 1, notices) +} + +func TestPostToolUseHookDynamicFailureRejectsSubmission(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + chunk := chattest.OpenAIToolCallChunk("my_dynamic_tool", `{}`) + chunk.Choices[0].ToolCalls[0].ID = "call_dynamic_failure" + return chattest.OpenAIStreamingResponse(chunk) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + var postCalls atomic.Int32 + var failPostToolUse atomic.Bool + failPostToolUse.Store(true) + 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)) + if request.Type == agenthooks.EventPostToolUse { + postCalls.Add(1) + if failPostToolUse.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + } + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "post-tool-use-dynamic-failure", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("call the dynamic tool"), + }, + DynamicTools: dynamicToolJSON(t, "my_dynamic_tool"), + }) + require.NoError(t, err) + testutil.Eventually(ctx, t, func(context.Context) bool { + updated, err := db.GetChatByID(ctx, chat.ID) + return err == nil && updated.Status == database.ChatStatusRequiresAction + }, testutil.IntervalFast) + + results := []codersdk.ToolResult{{ + ToolCallID: "call_dynamic_failure", + Output: json.RawMessage(`{"answer":42}`), + }} + err = server.SubmitToolResults(ctx, chatd.SubmitToolResultsOptions{ + ChatID: chat.ID, + UserID: user.ID, + ModelConfigID: model.ID, + Results: results, + }) + var dispatchErr *chathooks.DispatchError + require.ErrorAs(t, err, &dispatchErr) + + unchanged, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusRequiresAction, unchanged.Status) + require.False(t, unchanged.LastError.Valid) + for _, part := range chatToolParts(ctx, t, db, chat.ID) { + require.NotEqual(t, codersdk.ChatMessagePartTypeToolResult, part.Type, + "rejected submission must not commit tool results") + } + require.Equal(t, int32(1), postCalls.Load()) + + failPostToolUse.Store(false) + require.NoError(t, server.SubmitToolResults(ctx, chatd.SubmitToolResultsOptions{ + ChatID: chat.ID, + UserID: user.ID, + ModelConfigID: model.ID, + Results: results, + })) + result := requireToolResultPart(t, chatToolParts(ctx, t, db, chat.ID), "my_dynamic_tool") + require.JSONEq(t, `{"answer":42}`, string(result.Result)) + require.Equal(t, int32(2), postCalls.Load()) +} + +func TestPostToolUseHookFailureCommitsResultThenErrors(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + chunk := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/file.txt"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_failure" + return chattest.OpenAIStreamingResponse(chunk) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + var postCalls atomic.Int32 + 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)) + if request.Type == agenthooks.EventPostToolUse { + postCalls.Add(1) + decoded, err := request.Decode() + require.NoError(t, err) + data := decoded.(*agenthooks.PostToolUseData) + require.Equal(t, "call_failure", data.ToolUseID) + w.WriteHeader(http.StatusInternalServerError) + return + } + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/file.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 4, TotalLines: 1, LinesRead: 1, Content: "data", + }, nil) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "post-tool-use-failure", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read the file"), + }, + }) + require.NoError(t, err) + failed := waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + + result := requireToolResultPart(t, chatToolParts(ctx, t, db, chat.ID), "read_file") + require.Contains(t, string(result.Result), "data") + require.Equal(t, int32(1), postCalls.Load()) + lastError := chatLastErrorMessage(failed.LastError) + require.Contains(t, lastError, "hook dispatch failed: post_tool_use: http_error") +} + +func TestPostToolUseHookFailureDispatchesRemainingResults(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + first := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/first.txt"}`) + first.Choices[0].ToolCalls[0].ID = "call_first" + second := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/second.txt"}`).Choices[0].ToolCalls[0] + second.ID = "call_second" + second.Index = 1 + first.Choices[0].ToolCalls = append(first.Choices[0].ToolCalls, second) + return chattest.OpenAIStreamingResponse(first) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + var mu sync.Mutex + results := map[string]string{} + 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)) + if request.Type != agenthooks.EventPostToolUse { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + decoded, err := request.Decode() + require.NoError(t, err) + data := decoded.(*agenthooks.PostToolUseData) + result := "ok" + if data.ToolUseID == "call_first" { + result = "http_error" + } + mu.Lock() + results[data.ToolUseID] = result + mu.Unlock() + if result == "http_error" { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, err = w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), gomock.Any(), int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 4, TotalLines: 1, LinesRead: 1, Content: "data", + }, nil). + Times(2) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "post-tool-use-continue", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read both files"), + }, + }) + require.NoError(t, err) + failed := waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + + mu.Lock() + received := make(map[string]string, len(results)) + for toolUseID, result := range results { + received[toolUseID] = result + } + mu.Unlock() + require.Equal(t, map[string]string{ + "call_first": "http_error", + "call_second": "ok", + }, received) + require.Contains(t, chatLastErrorMessage(failed.LastError), "hook dispatch failed: post_tool_use: http_error") +} diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go new file mode 100644 index 00000000000..28fde7f7b9b --- /dev/null +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -0,0 +1,865 @@ +package chatd_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/testutil" +) + +func TestPreToolUseHookAllow(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response string + expectedPath string + }{ + { + name: "passthrough", + response: `{}`, + expectedPath: "/tmp/before.txt", + }, + { + name: "override", + response: `{"permission":{"decision":"allow","input_override":{"path":"/tmp/after.txt"}},"user_message":"tool approved"}`, + expectedPath: "/tmp/after.txt", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) == 1 { + chunk := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/before.txt"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_non_uuid" + return chattest.OpenAIStreamingResponse(chunk) + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + var hookCalls atomic.Int32 + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + hookCalls.Add(1) + require.Equal(t, "call_non_uuid", data.ToolUseID) + require.Equal(t, "read_file", data.ToolName) + require.JSONEq(t, `{"path":"/tmp/before.txt"}`, string(data.ToolInput)) + return tt.response + }) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), tt.expectedPath, int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 4, TotalLines: 1, LinesRead: 1, Content: "data", + }, nil). + Times(1) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "pre-tool-use-allow", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read the file"), + }, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + require.Equal(t, int32(1), hookCalls.Load()) + call := requireToolCallPart(t, chatToolParts(ctx, t, db, chat.ID), "read_file") + require.JSONEq(t, `{"path":"`+tt.expectedPath+`"}`, string(call.Args)) + }) + } +} + +func TestPreToolUseHookDeny(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + var secondMessages []chattest.OpenAIMessage + var messagesMu sync.Mutex + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) == 1 { + chunk := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/secret.txt"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_denied" + return chattest.OpenAIStreamingResponse(chunk) + } + messagesMu.Lock() + secondMessages = append([]chattest.OpenAIMessage(nil), req.Messages...) + messagesMu.Unlock() + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("used another approach")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + require.Equal(t, "call_denied", data.ToolUseID) + return `{"permission":{"decision":"deny","reason":"blocked by policy"},"model_context":"Do not read secrets."}` + }) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "pre-tool-use-deny", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read the secret"), + }, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + parts := chatToolParts(ctx, t, db, chat.ID) + result := requireToolResultPart(t, parts, "read_file") + require.True(t, result.IsError) + require.Contains(t, string(result.Result), "DENIED: blocked by policy") + require.Contains(t, string(result.Result), "Do not read secrets.") + + messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + for _, message := range messages { + if message.Visibility != database.ChatMessageVisibilityModel { + continue + } + parsed, err := chatprompt.ParseContent(message) + require.NoError(t, err) + for _, part := range parsed { + require.NotEqual(t, "Do not read secrets.", part.Text) + } + } + + messagesMu.Lock() + modelMessages := append([]chattest.OpenAIMessage(nil), secondMessages...) + messagesMu.Unlock() + require.True(t, openAIMessagesContain(modelMessages, "DENIED: blocked by policy")) + require.True(t, openAIMessagesContain(modelMessages, "Do not read secrets.")) +} + +func TestPreToolUseSkipsProviderExecutedTools(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + anthropicURL := chattest.NewAnthropic(t, func(_ *chattest.AnthropicRequest) chattest.AnthropicResponse { + return chattest.AnthropicStreamingResponse( + anthropicWebSearchPairChunks("ws-hook-skip", `{"query":"coder"}`, "search done", "end_turn")..., + ) + }) + user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) + model = enableAnthropicWebSearchForTest(t, db, model) + var preToolCalls atomic.Int32 + consumer := preToolUseConsumer(t, func(agenthooks.PreToolUseData) string { + preToolCalls.Add(1) + return `{}` + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "search for coder") + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + require.Zero(t, preToolCalls.Load()) + call := requireToolCallPart(t, chatToolParts(ctx, t, db, chat.ID), "web_search") + require.True(t, call.ProviderExecuted) +} + +func TestPreToolUseHookDynamicAllowResponse(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + chunk := chattest.OpenAIToolCallChunk("my_dynamic_tool", `{"query":"original"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_dynamic_allow" + return chattest.OpenAIStreamingResponse(chunk) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + require.Equal(t, "call_dynamic_allow", data.ToolUseID) + return `{"permission":{"decision":"allow","input_override":{"query":"redacted"}},"model_context":"dynamic context","user_message":"dynamic notice"}` + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "pre-tool-use-dynamic-allow", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("call the dynamic tool"), + }, + DynamicTools: dynamicToolJSON(t, "my_dynamic_tool"), + }) + require.NoError(t, err) + + var action database.Chat + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + action, err = db.GetChatByID(ctx, chat.ID) + return err == nil && action.Status == database.ChatStatusRequiresAction + }, testutil.IntervalFast) + call := requireToolCallPart(t, chatToolParts(ctx, t, db, chat.ID), "my_dynamic_tool") + require.JSONEq(t, `{"query":"redacted"}`, string(call.Args)) + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + var foundContext bool + for _, message := range promptMessages { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + if len(parts) == 1 && parts[0].Text == "dynamic context" { + foundContext = true + } + } + require.True(t, foundContext) + var foundNotice bool + for _, message := range chatMessages(ctx, t, db, chat.ID) { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + if len(parts) == 1 && parts[0].Text == "dynamic notice" { + foundNotice = true + } + } + require.True(t, foundNotice) +} + +func TestPreToolUseHookRepeatedToolCallIDDispatchesFresh(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + const toolUseID = "call_repeated" + toolInput := `{"path":"/tmp/dup.txt"}` + userContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("resume")}) + require.NoError(t, err) + created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + LastModelConfigID: model.ID, + Title: "repeated-tool-call-id", + ClientType: database.ChatClientTypeApi, + InitialMessages: []chatstate.Message{ + { + Role: database.ChatMessageRoleUser, + Content: userContent, + Visibility: database.ChatMessageVisibilityBoth, + ContentVersion: chatprompt.CurrentContentVersion, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + }, + }, + }) + require.NoError(t, err) + chatID := created.Chat.ID + toolCallContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: toolUseID, + ToolName: "read_file", + Args: json.RawMessage(toolInput), + }}) + require.NoError(t, err) + toolResultContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeToolResult, + ToolCallID: toolUseID, + ToolName: "read_file", + Result: json.RawMessage(`{"output":"data"}`), + }}) + require.NoError(t, err) + machine := chatstate.NewChatMachine(db, ps, chatID) + require.NoError(t, machine.Update(ctx, func(tx *chatstate.Tx, _ database.Store) error { + _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: []chatstate.Message{ + { + Role: database.ChatMessageRoleAssistant, + Content: toolCallContent, + Visibility: database.ChatMessageVisibilityBoth, + ContentVersion: chatprompt.CurrentContentVersion, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + }, + { + Role: database.ChatMessageRoleTool, + Content: toolResultContent, + Visibility: database.ChatMessageVisibilityBoth, + ContentVersion: chatprompt.CurrentContentVersion, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + }, + { + Role: database.ChatMessageRoleAssistant, + Content: toolCallContent, + Visibility: database.ChatMessageVisibilityBoth, + ContentVersion: chatprompt.CurrentContentVersion, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + }, + }}) + return err + })) + + var hookCalls atomic.Int32 + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + hookCalls.Add(1) + require.Equal(t, toolUseID, data.ToolUseID) + return `{"permission":{"decision":"deny","reason":"repeated call"}}` + }) + + server := newTestServer(t, db, ps, uuid.New(), func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + server.Start() + waitForChatStatus(ctx, t, db, chatID, database.ChatStatusWaiting) + + require.Equal(t, int32(1), hookCalls.Load(), "the repeated occurrence must dispatch fresh") + denied := 0 + for _, part := range chatToolParts(ctx, t, db, chatID) { + if part.Type == codersdk.ChatMessagePartTypeToolResult && part.IsError && + strings.Contains(string(part.Result), "repeated call") { + denied++ + } + } + require.Equal(t, 1, denied, "the repeated call must persist the fresh deny result") +} + +func TestPreToolUseHookDispatchFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + response string + result string + }{ + { + name: "http error", + statusCode: http.StatusInternalServerError, + result: "http_error", + }, + { + name: "ask protocol error", + response: `{"permission":{"decision":"ask"}}`, + result: "protocol_error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + chunk := chattest.OpenAIToolCallChunk("my_dynamic_tool", `{}`) + chunk.Choices[0].ToolCalls[0].ID = "call_failure" + return chattest.OpenAIStreamingResponse(chunk) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + 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)) + if request.Type != agenthooks.EventPreToolUse { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + if tt.statusCode != 0 { + w.WriteHeader(tt.statusCode) + return + } + _, err := w.Write([]byte(tt.response)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "pre-tool-use-failure", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("fail before commit"), + }, + DynamicTools: dynamicToolJSON(t, "my_dynamic_tool"), + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + + failed, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + lastError := chatLastErrorMessage(failed.LastError) + require.Contains(t, lastError, "hook dispatch failed: pre_tool_use: "+tt.result) + messages := chatMessages(ctx, t, db, chat.ID) + require.Len(t, messages, 1) + require.Equal(t, database.ChatMessageRoleUser, messages[0].Role) + }) + } +} + +func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) <= 2 { + first := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/first.txt"}`) + first.Choices[0].ToolCalls[0].ID = "call_first" + second := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/second.txt"}`).Choices[0].ToolCalls[0] + second.ID = "call_second" + second.Index = 1 + first.Choices[0].ToolCalls = append(first.Choices[0].ToolCalls, second) + return chattest.OpenAIStreamingResponse(first) + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + var firstCalls atomic.Int32 + var secondCalls atomic.Int32 + var failSecond atomic.Bool + failSecond.Store(true) + 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)) + if request.Type != agenthooks.EventPreToolUse { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + decoded, err := request.Decode() + require.NoError(t, err) + data := decoded.(*agenthooks.PreToolUseData) + switch data.ToolUseID { + case "call_first": + firstCalls.Add(1) + _, err = w.Write([]byte(`{"model_context":"first context","user_message":"first notice"}`)) + require.NoError(t, err) + case "call_second": + secondCalls.Add(1) + if failSecond.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, err = w.Write([]byte(`{}`)) + require.NoError(t, err) + default: + t.Fatalf("unexpected tool use ID %q", data.ToolUseID) + } + })) + t.Cleanup(consumer.Close) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), gomock.Any(), int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{Success: true, Content: "data"}, nil). + Times(2) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "pre-tool-use-cache-retry", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read both files"), + }, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + require.Equal(t, int32(1), firstCalls.Load()) + require.Equal(t, int32(1), secondCalls.Load()) + require.Len(t, chatMessages(ctx, t, db, chat.ID), 1) + + failSecond.Store(false) + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + ModelConfigID: model.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("retry")}, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + require.Equal(t, int32(1), firstCalls.Load()) + require.Equal(t, int32(2), secondCalls.Load()) + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + var contextCount, noticeCount int + for _, message := range append(promptMessages, chatMessages(ctx, t, db, chat.ID)...) { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + for _, part := range parts { + switch part.Text { + case "first context": + contextCount++ + case "first notice": + noticeCount++ + } + } + } + require.Equal(t, 1, contextCount) + require.Equal(t, 1, noticeCount) +} + +func TestPreToolUseHookSettledDecisionDispatchesFresh(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + switch modelCalls.Add(1) { + case 1, 3: + chunk := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/file.txt"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_reused_after_settle" + return chattest.OpenAIStreamingResponse(chunk) + default: + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + } + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + var hookCalls atomic.Int32 + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + hookCalls.Add(1) + require.Equal(t, "call_reused_after_settle", data.ToolUseID) + return `{}` + }) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/file.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{Success: true, Content: "data"}, nil). + Times(2) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "pre-tool-use-settled-cache", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read once"), + }, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.Equal(t, int32(1), hookCalls.Load()) + + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + ModelConfigID: model.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("read again")}, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.Equal(t, int32(2), hookCalls.Load()) +} + +func TestPreToolUseHookResumeFallback(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + modelCalls.Add(1) + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + chatID := seedPendingToolCall(ctx, t, db, ps, pendingToolCallSeed{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: ws.ID, + AgentID: dbAgent.ID, + ModelConfigID: model.ID, + ToolCallID: "call_resume_fallback", + ToolName: "read_file", + ToolInput: `{"path":"/tmp/original.txt"}`, + }) + + var hookCalls atomic.Int32 + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + hookCalls.Add(1) + require.Equal(t, "call_resume_fallback", data.ToolUseID) + return `{"permission":{"decision":"allow","input_override":{"path":"/tmp/resume.txt"}}}` + }) + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/resume.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 4, TotalLines: 1, LinesRead: 1, Content: "data", + }, nil). + Times(1) + + server := newTestServer(t, db, ps, uuid.New(), func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + server.Start() + waitForChatStatus(ctx, t, db, chatID, database.ChatStatusWaiting) + + require.Equal(t, int32(1), hookCalls.Load()) + require.Equal(t, int32(1), modelCalls.Load()) + call := requireToolCallPart(t, chatToolParts(ctx, t, db, chatID), "read_file") + require.JSONEq(t, `{"path":"/tmp/resume.txt"}`, string(call.Args)) + result := requireToolResultPart(t, chatToolParts(ctx, t, db, chatID), "read_file") + require.False(t, result.IsError) +} + +type pendingToolCallSeed struct { + OrganizationID uuid.UUID + OwnerID uuid.UUID + WorkspaceID uuid.UUID + AgentID uuid.UUID + ModelConfigID uuid.UUID + ToolCallID string + ToolName string + ToolInput string + DynamicTools json.RawMessage +} + +func seedPendingToolCall( + ctx context.Context, + t *testing.T, + db database.Store, + ps dbpubsub.Pubsub, + seed pendingToolCallSeed, +) uuid.UUID { + t.Helper() + userContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("resume")}) + require.NoError(t, err) + created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{ + OrganizationID: seed.OrganizationID, + OwnerID: seed.OwnerID, + WorkspaceID: uuid.NullUUID{UUID: seed.WorkspaceID, Valid: seed.WorkspaceID != uuid.Nil}, + AgentID: uuid.NullUUID{UUID: seed.AgentID, Valid: seed.AgentID != uuid.Nil}, + LastModelConfigID: seed.ModelConfigID, + Title: "pending-tool-call", + DynamicTools: nullRawMessage(seed.DynamicTools), + ClientType: database.ChatClientTypeApi, + InitialMessages: []chatstate.Message{ + { + Role: database.ChatMessageRoleUser, + Content: userContent, + Visibility: database.ChatMessageVisibilityBoth, + ContentVersion: chatprompt.CurrentContentVersion, + CreatedBy: uuid.NullUUID{UUID: seed.OwnerID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: seed.ModelConfigID, Valid: true}, + }, + }, + }) + require.NoError(t, err) + assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: seed.ToolCallID, + ToolName: seed.ToolName, + Args: json.RawMessage(seed.ToolInput), + }, + }) + require.NoError(t, err) + machine := chatstate.NewChatMachine(db, ps, created.Chat.ID) + require.NoError(t, machine.Update(ctx, func(tx *chatstate.Tx, _ database.Store) error { + _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: []chatstate.Message{ + { + Role: database.ChatMessageRoleAssistant, + Content: assistantContent, + Visibility: database.ChatMessageVisibilityBoth, + ContentVersion: chatprompt.CurrentContentVersion, + ModelConfigID: uuid.NullUUID{UUID: seed.ModelConfigID, Valid: true}, + }, + }}) + return err + })) + return created.Chat.ID +} + +func TestPreToolUseHookDynamicDeny(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) == 1 { + chunk := chattest.OpenAIToolCallChunk("my_dynamic_tool", `{"query":"test"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_dynamic_denied" + return chattest.OpenAIStreamingResponse(chunk) + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("replanned")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + require.Equal(t, "call_dynamic_denied", data.ToolUseID) + return `{"permission":{"decision":"deny","reason":"dynamic denied"}}` + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "pre-tool-use-dynamic-deny", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("call the dynamic tool"), + }, + DynamicTools: dynamicToolJSON(t, "my_dynamic_tool"), + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + chatResult, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.False(t, chatResult.RequiresActionDeadlineAt.Valid) + require.Equal(t, int32(2), modelCalls.Load()) + result := requireToolResultPart(t, chatToolParts(ctx, t, db, chat.ID), "my_dynamic_tool") + require.True(t, result.IsError) + require.Contains(t, string(result.Result), "DENIED: dynamic denied") +} + +func preToolUseConsumer(t *testing.T, response func(agenthooks.PreToolUseData) string) *httptest.Server { + t.Helper() + 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)) + if request.Type != agenthooks.EventPreToolUse { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + decoded, err := request.Decode() + require.NoError(t, err) + data, ok := decoded.(*agenthooks.PreToolUseData) + require.True(t, ok) + _, err = w.Write([]byte(response(*data))) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + return consumer +} diff --git a/coderd/x/chatd/runner.go b/coderd/x/chatd/runner.go index e36364aab86..72b916a38a6 100644 --- a/coderd/x/chatd/runner.go +++ b/coderd/x/chatd/runner.go @@ -57,6 +57,8 @@ type runner struct { tasksByIndex map[taskIndexKey]taskInstanceID localLocks *localLockSet debugTurn *runnerDebugTurn + sessionStart sessionStartTracker + stopNudges stopNudgeTracker } func newRunner(ctx context.Context, mgr *runnerManager, rec *runnerRecord, opts chatWorkerOptions) *runner { @@ -227,6 +229,8 @@ func (r *runner) spawnTaskIfNeeded(kind taskKind, state runnerStateUpdate) { Status: state.Status, RequiresActionDeadlineAt: state.RequiresActionDeadlineAt, DebugTurn: r.debugTurn, + SessionStart: &r.sessionStart, + StopNudges: &r.stopNudges, } go r.runTask(taskCtx, kind, key, input, done) } diff --git a/coderd/x/chatd/runner_test.go b/coderd/x/chatd/runner_test.go index ef950695186..04381278924 100644 --- a/coderd/x/chatd/runner_test.go +++ b/coderd/x/chatd/runner_test.go @@ -42,6 +42,7 @@ func TestRunner_CancelsActiveTaskWhenHistoryChanges(t *testing.T) { require.NotErrorIs(t, context.Cause(first.ctx), errTaskTimeout) second := starter.waitCall(t, taskKindGeneration, chat.ID) require.Equal(t, updated.HistoryVersion, second.input.HistoryVersion) + require.Same(t, first.input.SessionStart, second.input.SessionStart) } func TestRunner_CancelsActiveTaskWhenStatusChanges(t *testing.T) { diff --git a/coderd/x/chatd/stop_test.go b/coderd/x/chatd/stop_test.go new file mode 100644 index 00000000000..b1d57015886 --- /dev/null +++ b/coderd/x/chatd/stop_test.go @@ -0,0 +1,181 @@ +package chatd_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" + "github.com/coder/coder/v2/testutil" +) + +func TestStopHookNoOpFinishesTurn(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + var stopCalls atomic.Int32 + consumer := stopConsumer(t, func() (int, string) { + stopCalls.Add(1) + return http.StatusOK, `{}` + }) + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "stop-noop", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("finish normally"), + }, + }) + require.NoError(t, err) + testutil.Eventually(ctx, t, func(context.Context) bool { + return stopCalls.Load() == 1 + }, testutil.IntervalFast) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.Equal(t, int32(1), stopCalls.Load()) +} + +func TestStopHookNudgeContinuesOnce(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + switch modelCalls.Add(1) { + case 1: + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("first answer")...) + case 2: + var found bool + for _, message := range req.Messages { + found = found || strings.Contains(message.Content, "continue please") + } + require.True(t, found) + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("second answer")...) + default: + require.FailNow(t, "stop nudge exceeded continuation cap") + return chattest.OpenAIStreamingResponse() + } + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + var stopCalls atomic.Int32 + consumer := stopConsumer(t, func() (int, string) { + stopCalls.Add(1) + return http.StatusOK, `{"model_context":"continue please"}` + }) + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "stop-nudge", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("continue once"), + }, + }) + require.NoError(t, err) + testutil.Eventually(ctx, t, func(context.Context) bool { + return stopCalls.Load() == 2 + }, testutil.IntervalFast) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.Equal(t, int32(2), modelCalls.Load()) + require.Equal(t, int32(2), stopCalls.Load()) + + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + var contextRows int + for _, message := range promptMessages { + parts, err := chatprompt.ParseContent(message) + require.NoError(t, err) + if len(parts) == 1 && parts[0].Text == "continue please" { + contextRows++ + require.Equal(t, database.ChatMessageVisibilityModel, message.Visibility) + } + } + require.Equal(t, 2, contextRows) +} + +func TestStopHookDispatchFailureErrorsChat(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + consumer := stopConsumer(t, func() (int, string) { + return http.StatusInternalServerError, "" + }) + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "stop-failure", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("fail on stop"), + }, + }) + require.NoError(t, err) + failed := waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + lastError := chatLastErrorMessage(failed.LastError) + require.Contains(t, lastError, "hook dispatch failed: stop: http_error") +} + +func stopConsumer(t *testing.T, response func() (int, string)) *httptest.Server { + t.Helper() + 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)) + if request.Type != agenthooks.EventStop { + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + return + } + status, body := response() + w.WriteHeader(status) + if body != "" { + _, err := w.Write([]byte(body)) + require.NoError(t, err) + } + })) + t.Cleanup(consumer.Close) + return consumer +} diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 19f06bd0cdd..4d72bc60373 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -24,7 +24,9 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -769,6 +771,14 @@ func (p *Server) subagentTools( options, ) if err != nil { + // A failed hook dispatch must fail closed instead of + // degrading into a tool error the model can ignore. + var hookErr *chathooks.DispatchError + if errors.As(err, &hookErr) { + return fantasy.ToolResponse{}, err + } + // UserPromptDeniedError.Error() carries the hook's + // reason, so the model can adjust its prompt. return fantasy.NewTextErrorResponse(err.Error()), nil } @@ -1142,7 +1152,6 @@ func (p *Server) loadSubagentSpawnParentChat( if err := validateSubagentSpawnParent(parent); err != nil { return database.Chat{}, err } - return parent, nil } @@ -1234,9 +1243,6 @@ func (p *Server) createChildSubagentChatWithOptions( } title = strings.TrimSpace(title) - if title == "" { - title = subagentFallbackChatTitle(prompt) - } rootChatID := parent.ID if parent.RootChatID.Valid { @@ -1280,6 +1286,34 @@ func (p *Server) createChildSubagentChatWithOptions( return database.Chat{}, limitErr } + // Review before persistence so spawned chats cannot bypass prompt policy. + childChatID := uuid.New() + var hookResponse agenthooks.Response + if p.hookDispatcher.Enabled() { + mintedTurnID := uuid.New() + hookChat := database.Chat{} + hookChat.ID = childChatID + hookChat.OwnerID = parent.OwnerID + hookChat.WorkspaceID = parent.WorkspaceID + hookChat.ParentChatID = uuid.NullUUID{UUID: parent.ID, Valid: true} + hookChat.RootChatID = uuid.NullUUID{UUID: rootChatID, Valid: true} + hookResponse, err = p.dispatchUserPromptSubmit(ctx, hookChat, mintedTurnID, []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}) + if err != nil { + return database.Chat{}, err + } + override, overridden, overrideErr := userPromptOverride(hookResponse) + if overrideErr != nil { + return database.Chat{}, overrideErr + } + if overridden { + // The overridden prompt also feeds the fallback title below. + prompt = override + } + } + if title == "" { + title = subagentFallbackChatTitle(prompt) + } + workspaceAwareness := workspaceDetachedNoCreateAwareness if parent.WorkspaceID.Valid { workspaceAwareness = workspaceAttachedAwareness @@ -1290,7 +1324,9 @@ func (p *Server) createChildSubagentChatWithOptions( if err != nil { return database.Chat{}, xerrors.Errorf("marshal workspace awareness: %w", err) } - userContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}) + childUserParts := []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)} + childUserParts = append(childUserParts, userPromptHookParts(hookResponse)...) + userContent, err := chatprompt.MarshalParts(childUserParts) if err != nil { return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", err) } @@ -1326,7 +1362,7 @@ func (p *Server) createChildSubagentChatWithOptions( if publisher == nil { publisher = dbpubsub.NewInMemory() } - result, err := chatstate.CreateChat(ctx, p.db, publisher, chatstate.CreateChatInput{ + result, err := chatstate.CreateChatWithID(ctx, p.db, publisher, childChatID, chatstate.CreateChatInput{ OrganizationID: parent.OrganizationID, OwnerID: parent.OwnerID, WorkspaceID: parent.WorkspaceID, diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 927ce60d3c6..827ed62efd8 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -15,6 +15,7 @@ import ( "charm.land/fantasy" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,6 +35,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -267,6 +269,154 @@ func insertInternalAIProvider( }) } +func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { + t.Parallel() + + newFixture := func(t *testing.T, handler http.HandlerFunc) (context.Context, database.Store, database.Chat, *Server) { + t.Helper() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID}) + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + parent := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID) + + consumer := httptest.NewServer(handler) + t.Cleanup(consumer.Close) + server := &Server{ + db: db, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + hookDispatcher: chathooks.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + "test-hook-secret-32-bytes-minimum!!", + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + ), + } + return ctx, db, parent, server + } + + t.Run("Rewrite", func(t *testing.T) { + t.Parallel() + + var meta struct { + sync.Mutex + parentChatID string + prompt string + } + ctx, db, parent, server := newFixture(t, func(rw http.ResponseWriter, r *http.Request) { + var request struct { + Type string `json:"type"` + Meta struct { + ParentChatID *uuid.UUID `json:"parent_chat_id"` + } `json:"meta"` + Data struct { + Prompt string `json:"prompt"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + require.Equal(t, "user_prompt_submit", request.Type) + meta.Lock() + if request.Meta.ParentChatID != nil { + meta.parentChatID = request.Meta.ParentChatID.String() + } + meta.prompt = request.Data.Prompt + meta.Unlock() + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write([]byte(`{ + "permission": {"decision": "allow", "input_override": {"prompt": "REVIEWED: inspect"}} + }`)) + }) + + child, err := server.createChildSubagentChatWithOptions(ctx, parent, "inspect the workspace", "", childSubagentChatOptions{}) + require.NoError(t, err) + + meta.Lock() + require.Equal(t, parent.ID.String(), meta.parentChatID, "spawn dispatch must identify the parent chat") + require.Equal(t, "inspect the workspace", meta.prompt) + meta.Unlock() + + messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: child.ID}) + require.NoError(t, err) + var childUserMessage database.ChatMessage + for _, message := range messages { + if message.Role == database.ChatMessageRoleUser { + childUserMessage = message + break + } + } + require.NotZero(t, childUserMessage.ID) + require.True(t, childUserMessage.Content.Valid) + require.Contains(t, string(childUserMessage.Content.RawMessage), "REVIEWED: inspect", + "the hook rewrite must land as the child's initial prompt") + require.NotContains(t, string(childUserMessage.Content.RawMessage), "inspect the workspace") + }) + + t.Run("DenyRefusesSpawn", func(t *testing.T) { + t.Parallel() + + ctx, db, parent, server := newFixture(t, func(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write([]byte(`{"permission": {"decision": "deny", "reason": "spawn blocked"}, "user_message": "not allowed"}`)) + }) + + _, err := server.createChildSubagentChatWithOptions(ctx, parent, "exfiltrate secrets", "", childSubagentChatOptions{}) + var denied *UserPromptDeniedError + require.ErrorAs(t, err, &denied) + require.Equal(t, "not allowed", denied.UserMessage) + + chats, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{ + ParentIds: []uuid.UUID{parent.ID}, + }) + require.NoError(t, err) + require.Empty(t, chats, "a denied spawn must not create a child chat") + }) + + t.Run("DispatchFailurePropagatesFromSpawnTool", func(t *testing.T) { + t.Parallel() + + ctx, db, parent, server := newFixture(t, func(rw http.ResponseWriter, _ *http.Request) { + http.Error(rw, "hook consumer down", http.StatusInternalServerError) + }) + + tools := server.subagentTools(ctx, func() database.Chat { return parent }, parent.LastModelConfigID) + tool := findToolByName(tools, spawnAgentToolName) + require.NotNil(t, tool) + input, err := json.Marshal(spawnAgentArgs{ + Type: subagentTypeExplore, + Prompt: "inspect the workspace", + Title: "sub", + }) + require.NoError(t, err) + + _, runErr := tool.Run(ctx, fantasy.ToolCall{ + ID: uuid.NewString(), + Name: spawnAgentToolName, + Input: string(input), + }) + var hookErr *chathooks.DispatchError + require.ErrorAs(t, runErr, &hookErr, + "dispatch failures must fail closed, not degrade to a tool error the model can ignore") + + chats, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{ + ParentIds: []uuid.UUID{parent.ID}, + }) + require.NoError(t, err) + require.Empty(t, chats) + }) +} + func TestResolveUserProviderAPIKeys_AIProvider(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/tasks.go b/coderd/x/chatd/tasks.go index c1c0c840e4a..df02eeaa5c7 100644 --- a/coderd/x/chatd/tasks.go +++ b/coderd/x/chatd/tasks.go @@ -317,6 +317,7 @@ func (s *taskStarter) StartInterrupt(ctx context.Context, input chatWorkerTaskSt } return normalizeTaskTransitionError(err, "finish interruption") } + s.server.hookDecisions.evictChat(input.ChatID) input.DebugTurn.RecordOutcome(chatdebug.StatusInterrupted) if err := s.publishWatchAndRoute(ctx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil { return xerrors.Errorf("publish watch and route: %w", err) diff --git a/codersdk/chats.go b/codersdk/chats.go index c6990e8a776..e76784b6148 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -287,6 +287,15 @@ const ( ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference" ChatMessagePartTypeContextFile ChatMessagePartType = "context-file" ChatMessagePartTypeSkill ChatMessagePartType = "skill" + // ChatMessagePartTypeHookContext is model context injected into a user + // prompt by a lifecycle hook. It is included in model prompt assembly + // and stripped from every client-facing conversion; the server rejects + // it in client-submitted content. + ChatMessagePartTypeHookContext ChatMessagePartType = "hook-context" + // ChatMessagePartTypeHookNotice is a user-facing notice attached to a + // user prompt by a lifecycle hook. It is excluded from model prompt + // assembly; the server rejects it in client-submitted content. + ChatMessagePartTypeHookNotice ChatMessagePartType = "hook-notice" ) // AllChatMessagePartTypes returns all known ChatMessagePartType values. @@ -301,6 +310,8 @@ func AllChatMessagePartTypes() []ChatMessagePartType { ChatMessagePartTypeFileReference, ChatMessagePartTypeContextFile, ChatMessagePartTypeSkill, + ChatMessagePartTypeHookContext, + ChatMessagePartTypeHookNotice, } } @@ -329,7 +340,7 @@ func AllChatMessagePartTypes() []ChatMessagePartType { // and wastes space in persisted chat_messages rows. type ChatMessagePart struct { Type ChatMessagePartType `json:"type"` - Text string `json:"text" variants:"text,reasoning"` + Text string `json:"text" variants:"text,reasoning,hook-notice"` Signature string `json:"signature,omitempty"` ToolCallID string `json:"tool_call_id,omitempty" variants:"tool-call?,tool-result?"` ToolName string `json:"tool_name,omitempty" variants:"tool-call?,tool-result?"` @@ -631,18 +642,28 @@ type EditChatMessageRequest struct { // CreateChatMessageResponse is the response from adding a message to a chat. type CreateChatMessageResponse struct { - Message *ChatMessage `json:"message,omitempty"` + Message *ChatMessage `json:"message,omitempty"` + // Messages contains all user-visible messages inserted by the send, in + // insertion order. A queued send on an errored chat may promote the + // previous queue head, so clients must upsert the full batch. + Messages []ChatMessage `json:"messages,omitempty"` QueuedMessage *ChatQueuedMessage `json:"queued_message,omitempty"` Queued bool `json:"queued"` Warnings []string `json:"warnings,omitempty"` } // EditChatMessageResponse is the response from editing a message in a chat. -// Edits are always synchronous (no queueing), so the message is returned -// directly. type EditChatMessageResponse struct { - Message ChatMessage `json:"message"` - Warnings []string `json:"warnings,omitempty"` + Message ChatMessage `json:"message"` + // Messages holds every user-visible message inserted by the edit, in + // insertion order. Hook-generated suffix messages may follow Message, + // so clients must upsert the full batch. + Messages []ChatMessage `json:"messages,omitempty"` + // DeletedMessageIDs holds the IDs of previously visible messages the + // edit removed, including stale hook notices from the edited turn. + // Clients should drop them from local caches. + DeletedMessageIDs []int64 `json:"deleted_message_ids,omitempty"` + Warnings []string `json:"warnings,omitempty"` } // UploadChatFileResponse is the response from uploading a chat file. @@ -1718,6 +1739,7 @@ const ( ChatErrorKindMissingKey ChatErrorKind = "missing_key" ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled" ChatErrorKindContentFilter ChatErrorKind = "content_filter" + ChatErrorKindHookDispatchFailed ChatErrorKind = "hook_dispatch_failed" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1734,6 +1756,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindMissingKey, ChatErrorKindProviderDisabled, ChatErrorKindContentFilter, + ChatErrorKindHookDispatchFailed, } // ChatError represents a terminal chat error in persisted chat state or the @@ -1994,6 +2017,14 @@ type ChatUsageLimitExceededResponse struct { ResetsAt time.Time `json:"resets_at" format:"date-time"` } +// ChatHookDispatchFailedResponse is the error body returned when a +// lifecycle hook dispatch fails during a synchronous chat operation. +// Kind lets clients classify the failure without parsing message text. +type ChatHookDispatchFailedResponse struct { + Response + Kind ChatErrorKind `json:"kind"` +} + type chatUsageLimitExceededError struct { err *Error response ChatUsageLimitExceededResponse diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index 29727491c1f..f5a4690b39d 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -315,6 +315,12 @@ func TestChatMessagePartVariantTags(t *testing.T) { "skill_dir": "internal only, used by read_skill tools (typescript:\"-\")", "context_file_skill_meta_file": "internal only, restored on subsequent turns (typescript:\"-\")", } + // Part types intentionally excluded from all generated variants. + // If you add a new part type, either reference it in a variants + // tag or add it here with a reason. + excludedTypes := map[codersdk.ChatMessagePartType]string{ + codersdk.ChatMessagePartTypeHookContext: "internal only, stripped from client-facing conversions by db2sdk", + } knownTypes := make(map[codersdk.ChatMessagePartType]bool) for _, pt := range codersdk.AllChatMessagePartTypes() { knownTypes[pt] = true @@ -354,8 +360,14 @@ func TestChatMessagePartVariantTags(t *testing.T) { } } - // Every known type must appear in at least one variants tag. + // Every known type must appear in at least one variants tag + // unless it is intentionally excluded from client codegen. for pt := range knownTypes { + if _, excluded := excludedTypes[pt]; excluded { + assert.False(t, coveredTypes[pt], + "ChatMessagePartType %q is in excludedTypes but referenced by a variants tag; %s", pt, editHint) + continue + } assert.True(t, coveredTypes[pt], "ChatMessagePartType %q is not referenced by any variants tag; %s", pt, editHint) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2c4a0bffa9d..dc10160b498 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4293,6 +4293,47 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupChat, YAML: "debugLoggingEnabled", }, + { + Name: "Chat: Hook URL", + Description: "HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.", + Flag: "chat-hook-url", + Env: "CODER_CHAT_HOOK_URL", + Value: &c.AI.Chat.HookURL, + Default: "", + Group: &deploymentGroupChat, + YAML: "hookURL", + }, + { + Name: "Chat: Hook Secret", + Description: "Shared secret used to sign chat agent lifecycle hook JWTs.", + Flag: "chat-hook-secret", + Env: "CODER_CHAT_HOOK_SECRET", + Value: &c.AI.Chat.HookSecret, + Default: "", + Group: &deploymentGroupChat, + Annotations: serpent.Annotations{}.Mark(annotationSecretKey, "true"), + }, + { + Name: "Chat: Hook Timeout", + Description: "Maximum time to wait for a chat agent lifecycle hook response.", + Flag: "chat-hook-timeout", + Env: "CODER_CHAT_HOOK_TIMEOUT", + Value: &c.AI.Chat.HookTimeout, + Default: (1500 * time.Millisecond).String(), + Group: &deploymentGroupChat, + YAML: "hookTimeout", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + }, + { + Name: "Chat: Hook Enabled", + Description: "Whether to dispatch chat agent lifecycle hooks when a hook URL is configured. Requires the agent-lifecycle-hooks experiment.", + Flag: "chat-hook-enabled", + Env: "CODER_CHAT_HOOK_ENABLED", + Value: &c.AI.Chat.HookEnabled, + Default: "true", + Group: &deploymentGroupChat, + YAML: "hookEnabled", + }, { Name: "Chat: AI Gateway Routing Enabled", Description: "Deprecated: AI Gateway routing is now the only routing path. Setting this value has no effect. This option will be removed in a future release.", @@ -4975,8 +5016,12 @@ type AIBridgeProxyConfig struct { } type ChatConfig struct { - AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"` - DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"` + AcquireBatchSize serpent.Int64 `json:"acquire_batch_size" typescript:",notnull"` + DebugLoggingEnabled serpent.Bool `json:"debug_logging_enabled" typescript:",notnull"` + HookURL serpent.URL `json:"hook_url" typescript:",notnull"` + HookSecret serpent.String `json:"hook_secret" typescript:",notnull"` + HookTimeout serpent.Duration `json:"hook_timeout" typescript:",notnull"` + HookEnabled serpent.Bool `json:"hook_enabled" typescript:",notnull"` // Deprecated: AI Gateway routing is now the only routing path. Setting this // value has no effect. This option will be removed in a future release. AIGatewayRoutingEnabled serpent.Bool `json:"ai_gateway_routing_enabled" typescript:",notnull" swaggerignore:"true"` @@ -5026,6 +5071,38 @@ func (c *DeploymentValues) Validate() error { refresh, access, ) } + + // Disabled hooks must not validate inert settings. + if c.AI.Chat.HookEnabled.Value() { + if c.AI.Chat.HookURL.String() != "" { + hookURL := c.AI.Chat.HookURL.Value() + if hookURL.Scheme != "https" { + return xerrors.New("chat hook URL must use HTTPS; set --chat-hook-url to an HTTPS URL") + } + if hookURL.Host == "" { + return xerrors.New("chat hook URL must include a host; set --chat-hook-url to a complete HTTPS URL") + } + // The configured string is signed verbatim as the JWT audience, + // but fragments and userinfo never reach the consumer, so its + // reconstructed audience would mismatch on every dispatch. + if hookURL.Fragment != "" || hookURL.RawFragment != "" || hookURL.User != nil { + return xerrors.New("chat hook URL must not contain a fragment or userinfo; set --chat-hook-url to a plain HTTPS URL") + } + if c.AI.Chat.HookSecret.Value() == "" { + return xerrors.New("chat hook secret is required when chat hook URL is set; set --chat-hook-secret") + } + // go-jose requires HS256 secrets to be at least 32 bytes. + if len(c.AI.Chat.HookSecret.Value()) < 32 { + return xerrors.New("chat hook secret must be at least 32 bytes of cryptographically random data; set --chat-hook-secret to a longer value") + } + } + + hookTimeout := c.AI.Chat.HookTimeout.Value() + if hookTimeout <= 0 || hookTimeout > 5*time.Second { + return xerrors.Errorf("chat hook timeout (%s) must be greater than zero and no more than 5s; set --chat-hook-timeout to a valid duration", hookTimeout) + } + } + return nil } @@ -5246,6 +5323,7 @@ const ( ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents. + ExperimentAgentLifecycleHooks Experiment = "agent-lifecycle-hooks" // Enables chat lifecycle hook webhooks for agent chats. ) func (e Experiment) DisplayName() string { @@ -5274,6 +5352,8 @@ func (e Experiment) DisplayName() string { return "Chat Advisor" case ExperimentChatVirtualDesktop: return "Chat Virtual Desktop" + case ExperimentAgentLifecycleHooks: + return "Agent Lifecycle Hooks" default: // Split on hyphen and convert to title case // e.g. "mcp-server-http" -> "Mcp Server Http" @@ -5296,6 +5376,7 @@ var ExperimentsKnown = Experiments{ ExperimentAIGatewayCostControl, ExperimentChatAdvisor, ExperimentChatVirtualDesktop, + ExperimentAgentLifecycleHooks, } // ExperimentsSafe should include all experiments that are safe for diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index a70fef938a6..901ba9f3578 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -82,6 +82,9 @@ func TestDeploymentValues_HighlyConfigurable(t *testing.T) { "Email Auth: Password": { yaml: true, }, + "Chat: Hook Secret": { + yaml: true, + }, "Notifications: Email Auth: Password": { yaml: true, }, @@ -726,6 +729,7 @@ func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) { dv := &codersdk.DeploymentValues{} dv.Sessions.DefaultDuration = serpent.Duration(access) dv.Sessions.RefreshDefaultDuration = serpent.Duration(refresh) + dv.AI.Chat.HookTimeout = serpent.Duration(1500 * time.Millisecond) return dv } @@ -770,6 +774,115 @@ func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) { }) } +func TestDeploymentValues_Validate_ChatHooks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disabled bool + url string + secret string + timeout time.Duration + wantErr string + }{ + { + name: "NoURL", + timeout: 1500 * time.Millisecond, + }, + { + name: "DisabledSkipsValidation", + disabled: true, + url: "http://hooks.example.com/agent", + timeout: 0, + }, + { + name: "Valid", + url: "https://hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcdef", + timeout: 5 * time.Second, + }, + { + name: "HTTPURL", + url: "http://hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcdef", + timeout: 1500 * time.Millisecond, + wantErr: "chat hook URL must use HTTPS", + }, + { + name: "HostlessURL", + url: "https:///hook", + secret: "0123456789abcdef0123456789abcdef", + timeout: 1500 * time.Millisecond, + wantErr: "must include a host", + }, + { + name: "FragmentURL", + url: "https://hooks.example.com/agent#frag", + secret: "0123456789abcdef0123456789abcdef", + timeout: 1500 * time.Millisecond, + wantErr: "must not contain a fragment or userinfo", + }, + { + name: "UserinfoURL", + url: "https://user:pass@hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcdef", + timeout: 1500 * time.Millisecond, + wantErr: "must not contain a fragment or userinfo", + }, + { + name: "MissingSecret", + url: "https://hooks.example.com/agent", + timeout: 1500 * time.Millisecond, + wantErr: "chat hook secret is required", + }, + { + name: "ShortSecret", + url: "https://hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcde", + timeout: 1500 * time.Millisecond, + wantErr: "chat hook secret must be at least 32 bytes", + }, + { + name: "ZeroTimeout", + timeout: 0, + wantErr: "chat hook timeout", + }, + { + name: "NegativeTimeout", + timeout: -time.Millisecond, + wantErr: "chat hook timeout", + }, + { + name: "TimeoutAboveMaximum", + timeout: 5*time.Second + time.Millisecond, + wantErr: "chat hook timeout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dv := &codersdk.DeploymentValues{} + dv.Sessions.DefaultDuration = serpent.Duration(time.Hour) + dv.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour) + dv.AI.Chat.HookEnabled = serpent.Bool(!tt.disabled) + dv.AI.Chat.HookSecret = serpent.String(tt.secret) + dv.AI.Chat.HookTimeout = serpent.Duration(tt.timeout) + if tt.url != "" { + require.NoError(t, dv.AI.Chat.HookURL.Set(tt.url)) + } + + err := dv.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestDeploymentValues_DurationFormatNanoseconds(t *testing.T) { t.Parallel() diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md new file mode 100644 index 00000000000..62c60320a5b --- /dev/null +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -0,0 +1,196 @@ +# Configure chat lifecycle hooks + +> [!NOTE] +> Chat lifecycle hooks are an experimental feature. +> The feature requires the `agent-lifecycle-hooks` experiment, and the consumer contract (including the request schema and JWT claims) may change or be removed in any release without a compatibility guarantee. + +This reference is for Coder deployment administrators who need to apply an external policy service to the agent loop. +It covers deployment configuration, the consumer contract, failure behavior, and rollout. + +Chat lifecycle hooks send events from the agent loop to 1 deployment-wide webhook endpoint. +The configured consumer can observe all 7 lifecycle events, add model or user context, replace mutable input, or deny selected actions. +Coder keeps no record of dispatched events or consumer decisions: any policy state, audit trail, or decision history lives in the consumer. + +> [!IMPORTANT] +> A consumer can block agent activity across the deployment. +> Start with an observe-only consumer and test failure recovery before enforcing policy. + +## Configure the deployment + +Enable the experiment first: + +```env +CODER_EXPERIMENTS=agent-lifecycle-hooks +``` + +Without the experiment, an enabled hook configuration is inactive: `coder server` logs a warning at startup and dispatches no hook events. +Enabled hook settings are still validated at startup. Setting `CODER_CHAT_HOOK_ENABLED=false` makes the URL, secret, and timeout inert and skips their validation. +The experiment list is read at startup, so enabling or disabling it requires a `coder server` restart. + +Set the following deployment options on `coder server`. + +| Environment variable | CLI flag | Default | Requirement | +|---------------------------|-----------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------| +| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. | +| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. | +| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. | +| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. | + +Treat `CODER_CHAT_HOOK_ENABLED=false` as the break-glass control. +Changing deployment options requires the normal `coder server` configuration rollout for your installation. + +Use a dedicated secret and rotate it through your existing secret-management process. +Rotation is a hard cutover: Coder signs with exactly one secret, so dispatches fail until the consumer accepts the new value. +Rotate during a maintenance window, or temporarily set `CODER_CHAT_HOOK_ENABLED=false` for the cutover if blocked chats are worse than unreviewed ones for your deployment. +Coder requires the configured URL to use HTTPS. +A TLS terminator can forward the request to a consumer over plain HTTP on a trusted local network. +It must set `X-Forwarded-Proto: https` and either preserve the original `Host` header or carry it in `X-Forwarded-Host` for the SDK handler's audience check. +The SDK trusts those forwarded headers, so the audience check is only as strong as that proxy boundary: the proxy must strip or overwrite client-supplied forwarded headers, and the consumer must not be reachable except through the proxy. + +## Handle lifecycle events + +Coder sends an HTTP `POST` request for each event. +The JSON body contains `type`, `meta`, and event-specific `data`. +The `meta` object includes `dispatch_id`, `schema_version`, `chat_id`, `owner_id`, and optional workspace and turn IDs. +Events from subagent chats also carry `parent_chat_id` and `root_chat_id` so a consumer can correlate a subagent subtree with the user-facing conversation and apply the parent's policy context. +The current `schema_version` is `1`. + +| Event | When Coder sends it | Decision-relevant data | +|----------------------|---------------------------------------------------------------------|------------------------------------------------------------------------| +| `session_start` | A chat session starts, resumes, or clears | `source` (`startup`, `resume`, or `clear`) | +| `user_prompt_submit` | A user submits a prompt, or `spawn_agent` submits a subagent prompt | `prompt` and `parts` | +| `pre_tool_use` | Before a non-provider-executed tool runs | `tool_use_id`, `tool_name`, and `tool_input` | +| `post_tool_use` | After a non-provider-executed tool returns | `tool_use_id`, `tool_name`, and either `tool_response` or `tool_error` | +| `pre_compact` | Before Coder compacts chat context | No event-specific fields | +| `post_compact` | After Coder compacts chat context | No event-specific fields | +| `stop` | The model stops a turn | No event-specific fields | + +Provider-executed tools don't produce `pre_tool_use` or `post_tool_use` events because the provider executes them outside Coder's tool runtime. + +For `user_prompt_submit`, `prompt` concatenates the original submitted text parts, and `parts` carries the original structured message, including non-text parts such as file references. +These values are captured before the consumer's override or injected context changes the stored prompt. +A consumer that gates prompt content must inspect `parts`. + +### Verify each request + +Coder sends the JWT in the `Authorization: Bearer ` header. +A consumer must apply all of the following checks before it uses the body: + +- Accept only the `HS256` algorithm and verify the signature with `CODER_CHAT_HOOK_SECRET`. +- Check that `iss` is the Coder deployment ID associated with the secret. +- Check that `aud` exactly matches `CODER_CHAT_HOOK_URL`. +- Check `nbf` and reject expired tokens using `exp`. +- Check that `jti` equals the request `meta.dispatch_id`. +- Check that the JWT event `type` equals the body event `type`. +- Compute SHA-256 over the exact request body bytes and compare it with `body_sha256`. +- Check that the chat ID in `sub` matches `meta.chat_id`. + +The Go consumer SDK in `codersdk/agenthooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. +Use `agenthooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. +Pass `agenthooks.WithExpectedIssuer` with the deployment ID associated with the secret to enforce the `iss` check. +Without it, `NewHTTPHandler` accepts any non-empty `iss` signed with the shared secret, so use a secret dedicated to one deployment or always set the expected issuer. + +### Return a response + +Return any `2xx` status with an empty body for a no-op response. +An empty JSON object has the same effect. +If the response has a body, return a JSON object with these optional fields. + +| Field | Effect | +|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `permission` | Allows or denies mutable input for `user_prompt_submit` and `pre_tool_use` only. | +| `model_context` | Adds text visible to the model. The value is limited to 16 KiB. The text is absent from the user-visible transcript, but users may infer its effects from model behavior. | +| `user_message` | Adds a message visible to the user. | + +The `permission.decision` value supports `allow` and `deny`. +The `ask` value isn't supported and causes the dispatch to fail closed. + +Permission rules depend on the event: + +- For `user_prompt_submit`, `allow` requires `input_override` in the exact form `{"prompt":"replacement text"}`. + Coder stores and sends the replacement prompt instead of the original prompt. +- For `pre_tool_use`, `allow` requires `input_override` containing the replacement tool input. + Coder persists the replacement with the tool call and executes the tool with it. + Nothing marks the call as rewritten in the chat, so the model may misattribute the changed behavior; a consumer that rewrites input should also return `user_message` explaining the change. +- For either event, `deny` blocks the input and must not include `input_override`. + A denied prompt isn't persisted, and a denied tool call becomes a synthetic error result, carrying any returned `model_context`, so the model can choose another action. +- For all other events, omit `permission`. + +For `user_prompt_submit`, `model_context` and `user_message` are stored as typed parts of the prompt message itself: the model-context part goes to the model but never to clients, and the user-message part is shown to the user attached to the prompt but never sent to the model. +For a denied `pre_tool_use`, `model_context` is included in the synthetic denied tool result. +Other hook effects become ordinary transcript messages with audience-specific visibility. +Coder dispatches `user_prompt_submit` exactly once per submission, when the prompt is admitted (sent, queued, edited, or used to create a chat or subagent), and applies the response effects to the final stored prompt content. + +## Plan failure recovery + +Lifecycle hooks are fail closed. +Coder treats a timeout, connection failure, non-`2xx` response, malformed response, or unsupported response field combination as a hook dispatch failure. +A failure during generation moves the chat to the error state and records the dispatch ID in its error details. +A failed prompt dispatch for an existing idle chat can also move that chat to the error state even though the API request is rejected. +If the first `user_prompt_submit` dispatch fails during chat creation, Coder rejects the request and doesn't create the chat. +If `post_tool_use` fails for a client-submitted tool result, Coder rejects the submission without committing the results, and the client can resubmit them after the consumer recovers. +If `post_tool_use` fails for a tool that Coder already executed, Coder commits the tool result first so the transcript reflects the completed side effect, then moves the chat to the error state. + +Dispatch precedes persistence, so a delivered event doesn't guarantee that the operation commits. +Coder checks admission before dispatching, but concurrent requests can still fail admission afterward, for example two sends racing for the last queue slot or duplicate submissions of the same tool results. +The consumer then observes an event for a request that Coder rejects, and the rejected request doesn't persist a prompt or tool result. +Treat events as attempt notifications rather than proof of a committed operation, and key idempotent tool-event processing on `tool_use_id`. + +Delivery is at least once. +Coder retries one connection failure per dispatch with the same JWT, so use `dispatch_id` to recognize a repeated HTTP attempt and return the same response. +Coder can also re-dispatch the same logical event with a new `dispatch_id`, for example when a chat recovers after a crash and retries a pending tool call. + +Use event-specific identifiers for logical duplicates: + +| Events | Deduplication guidance | +|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `pre_tool_use`, `post_tool_use` | Key by `chat_id`, event type, and `tool_use_id`. | +| `session_start` | Response effects can repeat after runner or process replacement. Make them safe to apply more than once. | +| `user_prompt_submit` | Dispatch occurs before a message ID exists. Avoid non-idempotent external effects because identical prompt content can be submitted twice. | +| `pre_compact`, `post_compact`, `stop` | Don't rely on `turn_id` surviving recovery. Make external effects safe to repeat. | + +Rejecting duplicates breaks Coder's retries. Return the same decision whenever the payload identifies the same logical event. + +After the consumer is healthy, send another message to an existing errored chat to resume it. +Coder emits `session_start` with `source` set to `resume` when the agent loop starts again. +If the consumer continues blocking chat activity, set `CODER_CHAT_HOOK_ENABLED=false` and roll out the Coder deployment configuration before users retry. + +## Roll out enforcement in stages + +Use the following rollout sequence: + +1. Deploy a consumer that verifies every request, logs the event and identifiers, and always returns a `2xx` status with an empty response. +2. Configure the hook URL, secret, and timeout on a test deployment. +3. Exercise normal chats, tool calls, compaction, consumer timeouts, and consumer restarts. +4. Review the consumer's own logs for event coverage and unexpected failures. +5. Add policy responses for a narrow event or tool set. +6. Expand enforcement after the consumer logs show the expected decisions and failure rate. + +Keep the break-glass procedure available throughout the rollout. + +## Start from the reference consumer + +The reference consumer at `scripts/agenthooks-server` uses `agenthooks.NewHTTPHandler` and logs 1 JSON object for each event. +Log-only mode returns an empty response for every verified event. +With log-only mode disabled, the optional example flags can deny tool names by regular expression or replace matching prompt text before the agent loop uses it. +It also demonstrates consumer-owned state: it remembers `pre_tool_use` decisions in memory keyed by chat and tool-use ID, replays them for duplicate deliveries, and marks the duplicates in its log output. + +Run the consumer from a Coder source checkout: + +```sh +CODER_AGENTHOOKS_SECRET='' \ + go run ./scripts/agenthooks-server \ + --listen 127.0.0.1:8081 \ + --log-only=true +``` + +The reference server accepts optional TLS certificate and key paths. +For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs. +Run `go run ./scripts/agenthooks-server --help` for all flags and environment variable names. + +## Audit dispatches + +Coder doesn't store dispatched events or consumer decisions. +The consumer's own logs are the audit trail: log the `dispatch_id`, the stable identifiers, and the returned decision for every event. +Use the `dispatch_id` recorded in a chat's error details to correlate a failed dispatch with the consumer's logs. +Consumer logs can contain prompts, tool input, response context, and user messages, so apply the same access controls that you use for other sensitive chat data. diff --git a/docs/manifest.json b/docs/manifest.json index ffa3db63685..a0b8bfcb17d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -429,6 +429,12 @@ "description": "Learn what usage telemetry Coder collects", "path": "./admin/setup/telemetry.md" }, + { + "title": "Chat lifecycle hooks", + "description": "Configure an external policy service for chat agent lifecycle events", + "path": "./admin/setup/chat-lifecycle-hooks.md", + "state": ["early access"] + }, { "title": "Data Retention", "description": "Configure data retention policies for database tables", diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 1b89aeb459d..7f53292eae7 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -225,12 +225,12 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `content_filter`, `generic`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | -| `status` | `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `requires_action`, `running`, `unreadable`, `waiting` | -| `plan_mode` | `plan` | +| Property | Value(s) | +|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `content_filter`, `generic`, `hook_dispatch_failed`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| `status` | `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `requires_action`, `running`, `unreadable`, `waiting` | +| `plan_mode` | `plan` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -1881,6 +1881,89 @@ Experimental: this endpoint is subject to change. "total_tokens": 0 } }, + "messages": [ + { + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "content": [ + { + "args": [ + 0 + ], + "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", + "content": "string", + "context_file_agent_id": { + "uuid": "string", + "valid": true + }, + "context_file_content": "string", + "context_file_directory": "string", + "context_file_os": "string", + "context_file_path": "string", + "context_file_skill_meta_file": "string", + "context_file_truncated": true, + "created_at": "2019-08-24T14:15:22Z", + "data": [ + 0 + ], + "end_line": 0, + "file_id": { + "uuid": "string", + "valid": true + }, + "file_name": "string", + "is_error": true, + "is_media": true, + "mcp_server_config_id": { + "uuid": "string", + "valid": true + }, + "media_type": "string", + "name": "string", + "parsed_commands": [ + [ + "string" + ] + ], + "provider_executed": true, + "provider_metadata": [ + 0 + ], + "result": [ + 0 + ], + "result_delta": "string", + "result_reset": true, + "signature": "string", + "skill_description": "string", + "skill_dir": "string", + "skill_name": "string", + "source_id": "string", + "start_line": 0, + "text": "string", + "title": "string", + "tool_call_id": "string", + "tool_name": "string", + "type": "text", + "url": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4", + "id": 0, + "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", + "role": "system", + "usage": { + "cache_creation_tokens": 0, + "cache_read_tokens": 0, + "context_limit": 0, + "input_tokens": 0, + "output_tokens": 0, + "reasoning_tokens": 0, + "total_tokens": 0 + } + } + ], "queued": true, "queued_message": { "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", @@ -2016,6 +2099,9 @@ Experimental: this endpoint is subject to change. ```json { + "deleted_message_ids": [ + 0 + ], "message": { "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", "content": [ @@ -2097,6 +2183,89 @@ Experimental: this endpoint is subject to change. "total_tokens": 0 } }, + "messages": [ + { + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "content": [ + { + "args": [ + 0 + ], + "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", + "content": "string", + "context_file_agent_id": { + "uuid": "string", + "valid": true + }, + "context_file_content": "string", + "context_file_directory": "string", + "context_file_os": "string", + "context_file_path": "string", + "context_file_skill_meta_file": "string", + "context_file_truncated": true, + "created_at": "2019-08-24T14:15:22Z", + "data": [ + 0 + ], + "end_line": 0, + "file_id": { + "uuid": "string", + "valid": true + }, + "file_name": "string", + "is_error": true, + "is_media": true, + "mcp_server_config_id": { + "uuid": "string", + "valid": true + }, + "media_type": "string", + "name": "string", + "parsed_commands": [ + [ + "string" + ] + ], + "provider_executed": true, + "provider_metadata": [ + 0 + ], + "result": [ + 0 + ], + "result_delta": "string", + "result_reset": true, + "signature": "string", + "skill_description": "string", + "skill_dir": "string", + "skill_name": "string", + "source_id": "string", + "start_line": 0, + "text": "string", + "title": "string", + "tool_call_id": "string", + "tool_name": "string", + "type": "text", + "url": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4", + "id": 0, + "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", + "role": "system", + "usage": { + "cache_creation_tokens": 0, + "cache_read_tokens": 0, + "context_limit": 0, + "input_tokens": 0, + "output_tokens": 0, + "reasoning_tokens": 0, + "total_tokens": 0 + } + } + ], "warnings": [ "string" ] diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 4715ff82458..3a8abd793cc 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -232,7 +232,23 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "chat": { "acquire_batch_size": 0, - "debug_logging_enabled": true + "debug_logging_enabled": true, + "hook_enabled": true, + "hook_secret": "string", + "hook_timeout": 0, + "hook_url": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } } }, "allow_workspace_renames": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ca6b0fad0d4..cf13255dfce 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1020,7 +1020,23 @@ }, "chat": { "acquire_batch_size": 0, - "debug_logging_enabled": true + "debug_logging_enabled": true, + "hook_enabled": true, + "hook_secret": "string", + "hook_timeout": 0, + "hook_url": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } } } ``` @@ -2390,16 +2406,36 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "acquire_batch_size": 0, - "debug_logging_enabled": true + "debug_logging_enabled": true, + "hook_enabled": true, + "hook_secret": "string", + "hook_timeout": 0, + "hook_url": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------|---------|----------|--------------|-------------| -| `acquire_batch_size` | integer | false | | | -| `debug_logging_enabled` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------|----------------------------|----------|--------------|-------------| +| `acquire_batch_size` | integer | false | | | +| `debug_logging_enabled` | boolean | false | | | +| `hook_enabled` | boolean | false | | | +| `hook_secret` | string | false | | | +| `hook_timeout` | integer | false | | | +| `hook_url` | [serpent.URL](#serpenturl) | false | | | ## codersdk.ChatContext @@ -2622,9 +2658,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `auth`, `config`, `content_filter`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `content_filter`, `generic`, `hook_dispatch_failed`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `stream_silence_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata @@ -2967,9 +3003,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------| -| `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------| +| `context-file`, `file`, `file-reference`, `hook-context`, `hook-notice`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | ## codersdk.ChatMessageRole @@ -4546,6 +4582,89 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "total_tokens": 0 } }, + "messages": [ + { + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "content": [ + { + "args": [ + 0 + ], + "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", + "content": "string", + "context_file_agent_id": { + "uuid": "string", + "valid": true + }, + "context_file_content": "string", + "context_file_directory": "string", + "context_file_os": "string", + "context_file_path": "string", + "context_file_skill_meta_file": "string", + "context_file_truncated": true, + "created_at": "2019-08-24T14:15:22Z", + "data": [ + 0 + ], + "end_line": 0, + "file_id": { + "uuid": "string", + "valid": true + }, + "file_name": "string", + "is_error": true, + "is_media": true, + "mcp_server_config_id": { + "uuid": "string", + "valid": true + }, + "media_type": "string", + "name": "string", + "parsed_commands": [ + [ + "string" + ] + ], + "provider_executed": true, + "provider_metadata": [ + 0 + ], + "result": [ + 0 + ], + "result_delta": "string", + "result_reset": true, + "signature": "string", + "skill_description": "string", + "skill_dir": "string", + "skill_name": "string", + "source_id": "string", + "start_line": 0, + "text": "string", + "title": "string", + "tool_call_id": "string", + "tool_name": "string", + "type": "text", + "url": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4", + "id": 0, + "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", + "role": "system", + "usage": { + "cache_creation_tokens": 0, + "cache_read_tokens": 0, + "context_limit": 0, + "input_tokens": 0, + "output_tokens": 0, + "reasoning_tokens": 0, + "total_tokens": 0 + } + } + ], "queued": true, "queued_message": { "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", @@ -4625,12 +4744,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------|----------------------------------------------------------|----------|--------------|-------------| -| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | -| `queued` | boolean | false | | | -| `queued_message` | [codersdk.ChatQueuedMessage](#codersdkchatqueuedmessage) | false | | | -| `warnings` | array of string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------|----------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | +| `messages` | array of [codersdk.ChatMessage](#codersdkchatmessage) | false | | Messages contains all user-visible messages inserted by the send, in insertion order. A queued send on an errored chat may promote the previous queue head, so clients must upsert the full batch. | +| `queued` | boolean | false | | | +| `queued_message` | [codersdk.ChatQueuedMessage](#codersdkchatqueuedmessage) | false | | | +| `warnings` | array of string | false | | | ## codersdk.CreateChatRequest @@ -5729,7 +5849,23 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o }, "chat": { "acquire_batch_size": 0, - "debug_logging_enabled": true + "debug_logging_enabled": true, + "hook_enabled": true, + "hook_secret": "string", + "hook_timeout": 0, + "hook_url": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } } }, "allow_workspace_renames": true, @@ -6339,7 +6475,23 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o }, "chat": { "acquire_batch_size": 0, - "debug_logging_enabled": true + "debug_logging_enabled": true, + "hook_enabled": true, + "hook_secret": "string", + "hook_timeout": 0, + "hook_url": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } } }, "allow_workspace_renames": true, @@ -7046,6 +7198,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { + "deleted_message_ids": [ + 0 + ], "message": { "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", "content": [ @@ -7127,6 +7282,89 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "total_tokens": 0 } }, + "messages": [ + { + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "content": [ + { + "args": [ + 0 + ], + "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", + "content": "string", + "context_file_agent_id": { + "uuid": "string", + "valid": true + }, + "context_file_content": "string", + "context_file_directory": "string", + "context_file_os": "string", + "context_file_path": "string", + "context_file_skill_meta_file": "string", + "context_file_truncated": true, + "created_at": "2019-08-24T14:15:22Z", + "data": [ + 0 + ], + "end_line": 0, + "file_id": { + "uuid": "string", + "valid": true + }, + "file_name": "string", + "is_error": true, + "is_media": true, + "mcp_server_config_id": { + "uuid": "string", + "valid": true + }, + "media_type": "string", + "name": "string", + "parsed_commands": [ + [ + "string" + ] + ], + "provider_executed": true, + "provider_metadata": [ + 0 + ], + "result": [ + 0 + ], + "result_delta": "string", + "result_reset": true, + "signature": "string", + "skill_description": "string", + "skill_dir": "string", + "skill_name": "string", + "source_id": "string", + "start_line": 0, + "text": "string", + "title": "string", + "tool_call_id": "string", + "tool_name": "string", + "type": "text", + "url": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4", + "id": 0, + "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", + "role": "system", + "usage": { + "cache_creation_tokens": 0, + "cache_read_tokens": 0, + "context_limit": 0, + "input_tokens": 0, + "output_tokens": 0, + "reasoning_tokens": 0, + "total_tokens": 0 + } + } + ], "warnings": [ "string" ] @@ -7135,10 +7373,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -|------------|----------------------------------------------|----------|--------------|-------------| -| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | -| `warnings` | array of string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|-------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `deleted_message_ids` | array of integer | false | | Deleted message ids holds the IDs of previously visible messages the edit removed, including stale hook notices from the edited turn. Clients should drop them from local caches. | +| `message` | [codersdk.ChatMessage](#codersdkchatmessage) | false | | | +| `messages` | array of [codersdk.ChatMessage](#codersdkchatmessage) | false | | Messages holds every user-visible message inserted by the edit, in insertion order. Hook-generated suffix messages may follow Message, so clients must upsert the full batch. | +| `warnings` | array of string | false | | | ## codersdk.Entitlement @@ -7218,9 +7458,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent-lifecycle-hooks`, `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index f4ae058d878..bd7f5215028 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1745,6 +1745,47 @@ Hide AI tasks from the dashboard. Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. +### --chat-hook-url + +| | | +|-------------|-----------------------------------| +| Type | url | +| Environment | $CODER_CHAT_HOOK_URL | +| YAML | chat.hookURL | + +HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment. + +### --chat-hook-secret + +| | | +|-------------|--------------------------------------| +| Type | string | +| Environment | $CODER_CHAT_HOOK_SECRET | + +Shared secret used to sign chat agent lifecycle hook JWTs. + +### --chat-hook-timeout + +| | | +|-------------|---------------------------------------| +| Type | duration | +| Environment | $CODER_CHAT_HOOK_TIMEOUT | +| YAML | chat.hookTimeout | +| Default | 1.5s | + +Maximum time to wait for a chat agent lifecycle hook response. + +### --chat-hook-enabled + +| | | +|-------------|---------------------------------------| +| Type | bool | +| Environment | $CODER_CHAT_HOOK_ENABLED | +| YAML | chat.hookEnabled | +| Default | true | + +Whether to dispatch chat agent lifecycle hooks when a hook URL is configured. Requires the agent-lifecycle-hooks experiment. + ### --ai-gateway-enabled | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 369b2fe72c8..e1962f40464 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -282,6 +282,20 @@ Configure the background chat processing daemon. Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. + --chat-hook-enabled bool, $CODER_CHAT_HOOK_ENABLED (default: true) + Whether to dispatch chat agent lifecycle hooks when a hook URL is + configured. Requires the agent-lifecycle-hooks experiment. + + --chat-hook-secret string, $CODER_CHAT_HOOK_SECRET + Shared secret used to sign chat agent lifecycle hook JWTs. + + --chat-hook-timeout duration, $CODER_CHAT_HOOK_TIMEOUT (default: 1.5s) + Maximum time to wait for a chat agent lifecycle hook response. + + --chat-hook-url url, $CODER_CHAT_HOOK_URL + HTTPS URL to receive chat agent lifecycle hook events. Hooks are + disabled when unset. Requires the agent-lifecycle-hooks experiment. + CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 20c47283fea..eada14adadc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1996,6 +1996,10 @@ export const ChatComputerUseProviders: ChatComputerUseProvider[] = [ export interface ChatConfig { readonly acquire_batch_size: number; readonly debug_logging_enabled: boolean; + readonly hook_url: string; + readonly hook_secret: string; + readonly hook_timeout: number; + readonly hook_enabled: boolean; /** * @deprecated AI Gateway routing is now the only routing path. Setting this * value has no effect. This option will be removed in a future release. @@ -2456,6 +2460,7 @@ export type ChatErrorKind = | "config" | "content_filter" | "generic" + | "hook_dispatch_failed" | "missing_key" | "overloaded" | "provider_disabled" @@ -2469,6 +2474,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "config", "content_filter", "generic", + "hook_dispatch_failed", "missing_key", "overloaded", "provider_disabled", @@ -2585,6 +2591,22 @@ export interface ChatGroup extends Group { readonly role: ChatRole; } +// From codersdk/chats.go +/** + * ChatHookDispatchFailedResponse is the error body returned when a + * lifecycle hook dispatch fails during a synchronous chat operation. + * Kind lets clients classify the failure without parsing message text. + */ +export interface ChatHookDispatchFailedResponse extends Response { + readonly kind: ChatErrorKind; +} + +// From codersdk/chats.go +export interface ChatHookNoticePart { + readonly type: "hook-notice"; + readonly text: string; +} + // From codersdk/chats.go /** * ChatInputPart is a single user input part for creating a chat. @@ -2673,13 +2695,16 @@ export type ChatMessagePart = | ChatFilePart | ChatFileReferencePart | ChatContextFilePart - | ChatSkillPart; + | ChatSkillPart + | ChatHookNoticePart; // From codersdk/chats.go export type ChatMessagePartType = | "context-file" | "file" | "file-reference" + | "hook-context" + | "hook-notice" | "reasoning" | "skill" | "source" @@ -2691,6 +2716,8 @@ export const ChatMessagePartTypes: ChatMessagePartType[] = [ "context-file", "file", "file-reference", + "hook-context", + "hook-notice", "reasoning", "skill", "source", @@ -3886,6 +3913,12 @@ export interface CreateChatMessageRequest { */ export interface CreateChatMessageResponse { readonly message?: ChatMessage; + /** + * Messages contains all user-visible messages inserted by the send, in + * insertion order. A queued send on an errored chat may promote the + * previous queue head, so clients must upsert the full batch. + */ + readonly messages?: readonly ChatMessage[]; readonly queued_message?: ChatQueuedMessage; readonly queued: boolean; readonly warnings?: readonly string[]; @@ -4960,11 +4993,21 @@ export interface EditChatMessageRequest { // From codersdk/chats.go /** * EditChatMessageResponse is the response from editing a message in a chat. - * Edits are always synchronous (no queueing), so the message is returned - * directly. */ export interface EditChatMessageResponse { readonly message: ChatMessage; + /** + * Messages holds every user-visible message inserted by the edit, in + * insertion order. Hook-generated suffix messages may follow Message, + * so clients must upsert the full batch. + */ + readonly messages?: readonly ChatMessage[]; + /** + * DeletedMessageIDs holds the IDs of previously visible messages the + * edit removed, including stale hook notices from the edited turn. + * Clients should drop them from local caches. + */ + readonly deleted_message_ids?: readonly number[]; readonly warnings?: readonly string[]; } @@ -5015,6 +5058,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = | "ai-gateway-cost-control" + | "agent-lifecycle-hooks" | "auto-fill-parameters" | "chat-advisor" | "chat-virtual-desktop" @@ -5029,6 +5073,7 @@ export type Experiment = export const Experiments: Experiment[] = [ "ai-gateway-cost-control", + "agent-lifecycle-hooks", "auto-fill-parameters", "chat-advisor", "chat-virtual-desktop", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 4c2f7072722..e58d45bdd1c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -293,6 +293,7 @@ const buildParsedReadFileEntry = ({ ], blocks: [{ type: "tool", id: toolId }], sources: [], + hookNotices: [], }, }; }; @@ -2294,6 +2295,7 @@ export const ToolDisplayModesFromPreferences: Story = { { type: "tool", id: "edit-tool" }, ], sources: [], + hookNotices: [], }, }, ] satisfies ParsedMessageEntry[], diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts index 584cff9fa4f..f59384d1b45 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.test.ts @@ -47,6 +47,7 @@ const parsed = ( tools: [], blocks: [], sources: [], + hookNotices: [], ...overrides, }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index 08a6a476578..dd401e2a363 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -209,6 +209,7 @@ const mergeReadFileMessageGroup = ( tools: group.flatMap((entry) => entry.parsed.tools), blocks: group.flatMap((entry) => entry.parsed.blocks), sources: [], + hookNotices: [], }, }; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index 3f48e6acfad..d632c6ad24a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -71,6 +71,7 @@ const emptyParsedMessageContent = (): ParsedMessageContent => ({ tools: [], blocks: [], sources: [], + hookNotices: [], }); export const ensureToolBlock = ( @@ -293,6 +294,12 @@ export const parseMessageContent = ( // they are not rendered in the conversation timeline. break; } + case "hook-notice": { + if (part.text.trim()) { + parsed.hookNotices.push(part.text); + } + break; + } default: { const _exhaustive: never = part; break; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index aa7b92b650b..9cd21d68dda 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -211,6 +211,9 @@ export const applyMessagePartToStreamState = ( // skill parts are metadata-only; no streaming render // needed. case "skill": + // hook-notice parts only appear in persisted user messages, + // never via SSE streaming. + case "hook-notice": return prev; default: { const _exhaustive: never = part; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index a0172abc4cb..bdf185fe235 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -59,6 +59,7 @@ export type ParsedMessageContent = { tools: MergedTool[]; blocks: RenderBlock[]; sources: Array<{ url: string; title: string }>; + hookNotices: string[]; }; export type ParsedMessageEntry = { From b49821a975b79f1bf088c5a9a74719c0c37dfc52 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:00:09 +0000 Subject: [PATCH 25/86] fix(codersdk): skip chat hook timeout validation when no hook URL is set Hook dispatch is disabled without a URL, so a deployment that sets CODER_CHAT_HOOK_TIMEOUT without using hooks must not fail startup validation. --- codersdk/deployment.go | 8 ++++---- codersdk/deployment_test.go | 10 ++++++++++ docs/admin/setup/chat-lifecycle-hooks.md | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index dc10160b498..6938ba19dc5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5095,11 +5095,11 @@ func (c *DeploymentValues) Validate() error { if len(c.AI.Chat.HookSecret.Value()) < 32 { return xerrors.New("chat hook secret must be at least 32 bytes of cryptographically random data; set --chat-hook-secret to a longer value") } - } - hookTimeout := c.AI.Chat.HookTimeout.Value() - if hookTimeout <= 0 || hookTimeout > 5*time.Second { - return xerrors.Errorf("chat hook timeout (%s) must be greater than zero and no more than 5s; set --chat-hook-timeout to a valid duration", hookTimeout) + hookTimeout := c.AI.Chat.HookTimeout.Value() + if hookTimeout <= 0 || hookTimeout > 5*time.Second { + return xerrors.Errorf("chat hook timeout (%s) must be greater than zero and no more than 5s; set --chat-hook-timeout to a valid duration", hookTimeout) + } } } diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 901ba9f3578..902942f11ed 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -844,19 +844,29 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) { }, { name: "ZeroTimeout", + url: "https://hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcdef", timeout: 0, wantErr: "chat hook timeout", }, { name: "NegativeTimeout", + url: "https://hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcdef", timeout: -time.Millisecond, wantErr: "chat hook timeout", }, { name: "TimeoutAboveMaximum", + url: "https://hooks.example.com/agent", + secret: "0123456789abcdef0123456789abcdef", timeout: 5*time.Second + time.Millisecond, wantErr: "chat hook timeout", }, + { + name: "NoURLSkipsTimeoutValidation", + timeout: 10 * time.Second, + }, } for _, tt := range tests { diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 62c60320a5b..50b3628d1f1 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -24,7 +24,7 @@ CODER_EXPERIMENTS=agent-lifecycle-hooks ``` Without the experiment, an enabled hook configuration is inactive: `coder server` logs a warning at startup and dispatches no hook events. -Enabled hook settings are still validated at startup. Setting `CODER_CHAT_HOOK_ENABLED=false` makes the URL, secret, and timeout inert and skips their validation. +Enabled hook settings are still validated at startup when a hook URL is set. Leaving the URL unset, or setting `CODER_CHAT_HOOK_ENABLED=false`, makes the hook settings inert and skips their validation. The experiment list is read at startup, so enabling or disabling it requires a `coder server` restart. Set the following deployment options on `coder server`. From 300b1e2f8d956e8d84043d34be5484588ed79e5a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:21:26 +0000 Subject: [PATCH 26/86] refactor(coderd/x/chatd): use FinishError for idle hook dispatch failures Follows the chatstate fold of FailIdle into FinishError. The handler gates on the waiting status so a dispatch failure for a queued send never parks a running chat. --- coderd/x/chatd/hooks.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/hooks.go b/coderd/x/chatd/hooks.go index 3ea06712018..bca2492d09a 100644 --- a/coderd/x/chatd/hooks.go +++ b/coderd/x/chatd/hooks.go @@ -12,6 +12,7 @@ import ( "charm.land/fantasy" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" @@ -499,12 +500,28 @@ func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, e if !ok { return dispatchErr } + encoded, marshalErr := json.Marshal(codersdk.ChatError{ + Message: lastError, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) + if marshalErr != nil { + return errors.Join(dispatchErr, xerrors.Errorf("encode hook dispatch error: %w", marshalErr)) + } var failedChat database.Chat machine := p.newChatMachine(chatID) err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { - if _, err := tx.FailIdle(chatstate.FailIdleInput{ - LastError: lastError, - Kind: codersdk.ChatErrorKindHookDispatchFailed, + current, err := store.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("load chat for hook failure: %w", err) + } + // Park only idle chats. FinishError is also allowed from running + // states, but a running chat keeps its active turn and the + // request error alone surfaces to the caller. + if current.Status != database.ChatStatusWaiting { + return chatstate.ErrTransitionNotAllowed + } + if _, err := tx.FinishError(chatstate.FinishErrorInput{ + LastError: pqtype.NullRawMessage{RawMessage: encoded, Valid: true}, }); err != nil { return err } From 8f5c3b75b614ee27aa95841f11f36b23bcae0f32 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:32:20 +0000 Subject: [PATCH 27/86] refactor(coderd/x/chatd): rewrite tool-call inputs through Tx.UpdateMessageContent --- coderd/x/chatd/generation.go | 4 ++-- coderd/x/chatd/hooks.go | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index add77bf0c89..1292cbf69f4 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -1159,7 +1159,7 @@ func (s *taskStarter) commitGenerationStep( if _, err := loadChatForGeneration(ctx, store, input, requireGenerationAttempt(attempt)); err != nil { return xerrors.Errorf("load chat for generation: %w", err) } - if err := replacePersistedToolCallInputs(ctx, store, input.ChatID, hooks.Overrides); err != nil { + if err := replacePersistedToolCallInputs(ctx, tx, input.ChatID, hooks.Overrides); err != nil { return err } commitResult, err := tx.CommitStep(chatstate.CommitStepInput{ @@ -1233,7 +1233,7 @@ func (s *taskStarter) enterRequiresAction( if _, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true}); err != nil { return xerrors.Errorf("load chat for task: %w", err) } - if err := replacePersistedToolCallInputs(ctx, store, input.ChatID, preflight.Overrides); err != nil { + if err := replacePersistedToolCallInputs(ctx, tx, input.ChatID, preflight.Overrides); err != nil { return err } var inserted []database.ChatMessage diff --git a/coderd/x/chatd/hooks.go b/coderd/x/chatd/hooks.go index bca2492d09a..cc9da0742f0 100644 --- a/coderd/x/chatd/hooks.go +++ b/coderd/x/chatd/hooks.go @@ -350,14 +350,14 @@ func (p *Server) preflightPendingToolCalls( func replacePersistedToolCallInputs( ctx context.Context, - store database.Store, + tx *chatstate.Tx, chatID uuid.UUID, overrides map[string]json.RawMessage, ) error { if len(overrides) == 0 { return nil } - assistant, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ + assistant, err := tx.Store().GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ ChatID: chatID, Role: database.ChatMessageRoleAssistant, }) @@ -382,10 +382,7 @@ func replacePersistedToolCallInputs( if err != nil { return xerrors.Errorf("marshal assistant message with tool override: %w", err) } - if err := store.UpdateChatMessageContentByID(ctx, database.UpdateChatMessageContentByIDParams{ - Content: content.RawMessage, - ID: assistant.ID, - }); err != nil { + if err := tx.UpdateMessageContent(assistant.ID, content.RawMessage); err != nil { return xerrors.Errorf("update assistant message with tool override: %w", err) } return nil From 4b8738af9a14fd281b768f5bbcd1bc3f50433f31 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:19:01 +0000 Subject: [PATCH 28/86] fix(coderd/x/chatd/chatprompt): keep tool results adjacent to their calls Hook effects such as pre_tool_use model context can persist rows between an assistant tool call and its result rows. Hoist matching result rows found before the next assistant message back next to the call so the prompt does not inject a synthetic interrupted result ahead of the real one. --- coderd/x/chatd/chatprompt/chatprompt.go | 45 ++++++++++++++------ coderd/x/chatd/chatprompt/chatprompt_test.go | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index fb8f168c6a4..1e50a6ac95a 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -923,9 +923,20 @@ func hasErrorField(raw json.RawMessage) bool { return ok } +// injectMissingToolResults keeps tool results adjacent to the +// assistant message that issued the calls. Hook effects, such as +// pre_tool_use model context, can persist rows between an assistant +// tool call and its result rows, so matching result rows found before +// the next assistant message are hoisted back next to the call; +// otherwise the interleaved row would make the real result look +// orphaned. Unanswered local calls get synthetic interrupted results. func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message { result := make([]fantasy.Message, 0, len(prompt)) + hoisted := make(map[int]bool) for i := 0; i < len(prompt); i++ { + if hoisted[i] { + continue + } msg := prompt[i] result = append(result, msg) @@ -936,28 +947,38 @@ func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message { if len(toolCalls) == 0 { continue } + callIDs := make(map[string]struct{}, len(toolCalls)) + for _, tc := range toolCalls { + callIDs[tc.ToolCallID] = struct{}{} + } - // Collect the tool call IDs that have results in the - // following tool message(s). + // Hoist tool rows answering this assistant's calls, in + // persisted order, from anywhere before the next assistant + // message. Interleaved non-tool rows keep their relative + // order after the results. answered := make(map[string]struct{}) - j := i + 1 - for ; j < len(prompt); j++ { - if prompt[j].Role != fantasy.MessageRoleTool { + for j := i + 1; j < len(prompt); j++ { + if prompt[j].Role == fantasy.MessageRoleAssistant { break } + if prompt[j].Role != fantasy.MessageRoleTool { + continue + } + answersThisCall := false for _, part := range prompt[j].Content { tr, ok := safeAsToolResultPart(part) if !ok { continue } - answered[tr.ToolCallID] = struct{}{} + if _, ok := callIDs[tr.ToolCallID]; ok { + answersThisCall = true + answered[tr.ToolCallID] = struct{}{} + } + } + if answersThisCall { + result = append(result, prompt[j]) + hoisted[j] = true } - } - if i+1 < j { - // Preserve persisted tool result ordering and inject any - // synthetic results after the existing contiguous tool messages. - result = append(result, prompt[i+1:j]...) - i = j - 1 } // Build synthetic results for any unanswered tool calls. diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index 17a22c144f7..6599c18a546 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -927,6 +927,50 @@ func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) { } } +// TestInjectMissingToolResults_HookContextBetweenCallAndResult +// verifies that a model-visible hook context row persisted between an +// assistant tool call and its result rows does not break tool-result +// adjacency or trigger a synthetic interrupted result. +func TestInjectMissingToolResults_HookContextBetweenCallAndResult(t *testing.T) { + t.Parallel() + + assistantContent := mustMarshalContent(t, []fantasy.Content{ + fantasy.ToolCallContent{ + ToolCallID: "toolu_gated", + ToolName: "execute", + Input: `{"command":"ls"}`, + }, + }) + hookContext := mustMarshalContent(t, []fantasy.Content{ + fantasy.TextContent{Text: "hook approval context"}, + }) + result := mustMarshalToolResult(t, + "toolu_gated", "execute", + json.RawMessage(`{"output":"ok"}`), + false, false, false, + ) + + prompt := convertMessagesWithoutFiles(t, []database.ChatMessage{ + {Role: database.ChatMessageRoleAssistant, Visibility: database.ChatMessageVisibilityBoth, Content: assistantContent}, + {Role: database.ChatMessageRoleUser, Visibility: database.ChatMessageVisibilityModel, Content: hookContext}, + {Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: result}, + }) + + // The result is hoisted next to the call and the hook context + // follows it. + require.Len(t, prompt, 3) + require.Equal(t, fantasy.MessageRoleAssistant, prompt[0].Role) + require.Equal(t, fantasy.MessageRoleTool, prompt[1].Role) + require.Equal(t, fantasy.MessageRoleUser, prompt[2].Role) + require.Equal(t, []string{"toolu_gated"}, extractToolResultIDs(t, prompt[1])) + for _, part := range prompt[1].Content { + tr, ok := asToolResultPartForTest(part) + require.True(t, ok) + _, isError := tr.Output.(fantasy.ToolResultOutputContentError) + require.False(t, isError, "expected no synthetic interrupted result") + } +} + // TestInjectMissingToolUses_DropsOnlyProviderExecutedMessage verifies // that a tool message containing only a provider-executed result is // entirely dropped. From 77e8f4efb2c823500cf962047db1a34e307b0f6a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:05:20 +0000 Subject: [PATCH 29/86] refactor(coderd/x/chatd): decode hook payloads directly in tests The agenthooks SDK dropped Request.Decode; tests now unmarshal the typed payload with a small generic helper. Also corrects the hook secret comment: the SDK, not go-jose, enforces the 32-byte minimum. --- coderd/x/chatd/create_hooks_test.go | 5 +--- coderd/x/chatd/hooks_internal_test.go | 8 +++--- coderd/x/chatd/hooks_test.go | 37 ++++++++++----------------- coderd/x/chatd/post_tool_use_test.go | 23 ++++++----------- coderd/x/chatd/pre_tool_use_test.go | 16 +++++------- codersdk/deployment.go | 2 +- 6 files changed, 33 insertions(+), 58 deletions(-) diff --git a/coderd/x/chatd/create_hooks_test.go b/coderd/x/chatd/create_hooks_test.go index 7ab6d60f06a..8fb5d0c058b 100644 --- a/coderd/x/chatd/create_hooks_test.go +++ b/coderd/x/chatd/create_hooks_test.go @@ -39,10 +39,7 @@ func TestCreateChatUserPromptSubmitHook(t *testing.T) { require.Equal(t, chat.ID, request.Meta.ChatID) require.Equal(t, user.ID, request.Meta.OwnerID) require.NotNil(t, request.Meta.TurnID) - decoded, err := request.Decode() - require.NoError(t, err) - data, ok := decoded.(*agenthooks.UserPromptSubmitData) - require.True(t, ok) + data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) require.Equal(t, "passthrough", data.Prompt) var hookParts []codersdk.ChatMessagePart require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go index 072efd99108..4e592640574 100644 --- a/coderd/x/chatd/hooks_internal_test.go +++ b/coderd/x/chatd/hooks_internal_test.go @@ -32,7 +32,7 @@ func TestSessionStartDispatchSources(t *testing.T) { type received struct { request agenthooks.Request claims agenthooks.Claims - data *agenthooks.SessionStartData + data agenthooks.SessionStartData } receivedCh := make(chan received, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -40,10 +40,8 @@ func TestSessionStartDispatchSources(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte(secret)) require.NoError(t, err) - decoded, err := request.Decode() - require.NoError(t, err) - data, ok := decoded.(*agenthooks.SessionStartData) - require.True(t, ok) + var data agenthooks.SessionStartData + require.NoError(t, json.Unmarshal(request.Data, &data)) receivedCh <- received{request: request, claims: claims, data: data} _, err = w.Write([]byte(`{}`)) require.NoError(t, err) diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index c727cdbb292..52012594dcb 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -51,16 +51,13 @@ func TestSendMessageUserPromptSubmitHook(t *testing.T) { 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)) - decoded, err := request.Decode() - require.NoError(t, err) - data, ok := decoded.(*agenthooks.UserPromptSubmitData) - require.True(t, ok) + data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) require.Equal(t, "before", data.Prompt) var hookParts []codersdk.ChatMessagePart require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) require.Equal(t, submitted, hookParts, "hook payload must carry non-text parts") require.NotNil(t, request.Meta.TurnID) - _, err = w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"prompt":"after"}},"model_context":"model only","user_message":"user only"}`)) + _, err := w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"prompt":"after"}},"model_context":"model only","user_message":"user only"}`)) require.NoError(t, err) })) t.Cleanup(consumer.Close) @@ -200,10 +197,7 @@ func TestSendMessageUserPromptSubmitPassthrough(t *testing.T) { require.NoError(t, err) require.Equal(t, "passthrough", hookMessageText(t, result.Message)) require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) - data, err := received.Decode() - require.NoError(t, err) - promptData, ok := data.(*agenthooks.UserPromptSubmitData) - require.True(t, ok) + promptData := decodeHookData[agenthooks.UserPromptSubmitData](t, received) require.Equal(t, "passthrough", promptData.Prompt) // The persisted content is jsonb-normalized, so compare JSON // semantics rather than raw bytes. @@ -263,9 +257,7 @@ func TestSendMessageUserPromptSubmitQueue(t *testing.T) { require.NoError(t, err) require.Equal(t, wantQueuedParts, persistedParts) require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) - data, err := received.Decode() - require.NoError(t, err) - require.Equal(t, "queued original", data.(*agenthooks.UserPromptSubmitData).Prompt) + require.Equal(t, "queued original", decodeHookData[agenthooks.UserPromptSubmitData](t, received).Prompt) } func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { @@ -366,10 +358,7 @@ func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { require.NoError(t, json.Unmarshal(updated.LastError.RawMessage, &chatErr)) require.Equal(t, "hook dispatch failed: user_prompt_submit: http_error (dispatch "+dispatchErr.DispatchID.String()+")", chatErr.Message) require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) - data, err := received.Decode() - require.NoError(t, err) - prompt, ok := data.(*agenthooks.UserPromptSubmitData) - require.True(t, ok) + prompt := decodeHookData[agenthooks.UserPromptSubmitData](t, received) require.Equal(t, "fails", prompt.Prompt) messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) require.NoError(t, err) @@ -439,15 +428,10 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) { receivedMu.Unlock() require.Len(t, received, 2) require.Equal(t, agenthooks.EventSessionStart, received[0].request.Type) - data, err := received[0].request.Decode() - require.NoError(t, err) - require.Equal(t, &agenthooks.SessionStartData{Source: "clear"}, data) + require.Equal(t, agenthooks.SessionStartData{Source: "clear"}, decodeHookData[agenthooks.SessionStartData](t, received[0].request)) require.Equal(t, received[0].request.Meta.DispatchID, received[0].claims.JTI) require.Equal(t, agenthooks.EventUserPromptSubmit, received[1].request.Type) - promptData, err := received[1].request.Decode() - require.NoError(t, err) - prompt, ok := promptData.(*agenthooks.UserPromptSubmitData) - require.True(t, ok) + prompt := decodeHookData[agenthooks.UserPromptSubmitData](t, received[1].request) require.Equal(t, "edited original", prompt.Prompt) require.NotNil(t, received[0].request.Meta.TurnID) require.Equal(t, received[0].request.Meta.TurnID, received[1].request.Meta.TurnID) @@ -655,3 +639,10 @@ func hookMessageText(t *testing.T, message database.ChatMessage) string { require.Len(t, parts, 1) return parts[0].Text } + +func decodeHookData[T any](t *testing.T, request agenthooks.Request) T { + t.Helper() + var data T + require.NoError(t, json.Unmarshal(request.Data, &data)) + return data +} diff --git a/coderd/x/chatd/post_tool_use_test.go b/coderd/x/chatd/post_tool_use_test.go index 30c64b312c2..ee3719f8168 100644 --- a/coderd/x/chatd/post_tool_use_test.go +++ b/coderd/x/chatd/post_tool_use_test.go @@ -74,11 +74,9 @@ func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { require.NoError(t, err) return } - decoded, err := request.Decode() - require.NoError(t, err) - data := decoded.(*agenthooks.PostToolUseData) + data := decodeHookData[agenthooks.PostToolUseData](t, request) mu.Lock() - received = append(received, *data) + received = append(received, data) index := len(received) mu.Unlock() @@ -86,6 +84,7 @@ func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { for _, message := range messages { require.NotEqual(t, database.ChatMessageRoleTool, message.Role) } + var err error if index == 1 { _, err = w.Write([]byte(`{"model_context":"lint feedback","user_message":"tool notice"}`)) } else { @@ -205,13 +204,11 @@ func TestPostToolUseHookDynamicResult(t *testing.T) { return } postCalls.Add(1) - decoded, err := request.Decode() - require.NoError(t, err) - data := decoded.(*agenthooks.PostToolUseData) + data := decodeHookData[agenthooks.PostToolUseData](t, request) require.Equal(t, "call_dynamic_result", data.ToolUseID) require.Equal(t, "my_dynamic_tool", data.ToolName) require.JSONEq(t, `{"answer":42}`, string(data.ToolResponse)) - _, err = w.Write([]byte(`{"model_context":"dynamic feedback","user_message":"dynamic notice"}`)) + _, err := w.Write([]byte(`{"model_context":"dynamic feedback","user_message":"dynamic notice"}`)) require.NoError(t, err) })) t.Cleanup(consumer.Close) @@ -377,9 +374,7 @@ func TestPostToolUseHookFailureCommitsResultThenErrors(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) if request.Type == agenthooks.EventPostToolUse { postCalls.Add(1) - decoded, err := request.Decode() - require.NoError(t, err) - data := decoded.(*agenthooks.PostToolUseData) + data := decodeHookData[agenthooks.PostToolUseData](t, request) require.Equal(t, "call_failure", data.ToolUseID) w.WriteHeader(http.StatusInternalServerError) return @@ -456,9 +451,7 @@ func TestPostToolUseHookFailureDispatchesRemainingResults(t *testing.T) { require.NoError(t, err) return } - decoded, err := request.Decode() - require.NoError(t, err) - data := decoded.(*agenthooks.PostToolUseData) + data := decodeHookData[agenthooks.PostToolUseData](t, request) result := "ok" if data.ToolUseID == "call_first" { result = "http_error" @@ -470,7 +463,7 @@ func TestPostToolUseHookFailureDispatchesRemainingResults(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) return } - _, err = w.Write([]byte(`{}`)) + _, err := w.Write([]byte(`{}`)) require.NoError(t, err) })) t.Cleanup(consumer.Close) diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index 28fde7f7b9b..aa6f98c92bf 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -512,13 +512,11 @@ func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { require.NoError(t, err) return } - decoded, err := request.Decode() - require.NoError(t, err) - data := decoded.(*agenthooks.PreToolUseData) + data := decodeHookData[agenthooks.PreToolUseData](t, request) switch data.ToolUseID { case "call_first": firstCalls.Add(1) - _, err = w.Write([]byte(`{"model_context":"first context","user_message":"first notice"}`)) + _, err := w.Write([]byte(`{"model_context":"first context","user_message":"first notice"}`)) require.NoError(t, err) case "call_second": secondCalls.Add(1) @@ -526,7 +524,7 @@ func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) return } - _, err = w.Write([]byte(`{}`)) + _, err := w.Write([]byte(`{}`)) require.NoError(t, err) default: t.Fatalf("unexpected tool use ID %q", data.ToolUseID) @@ -853,11 +851,9 @@ func preToolUseConsumer(t *testing.T, response func(agenthooks.PreToolUseData) s require.NoError(t, err) return } - decoded, err := request.Decode() - require.NoError(t, err) - data, ok := decoded.(*agenthooks.PreToolUseData) - require.True(t, ok) - _, err = w.Write([]byte(response(*data))) + data := decodeHookData[agenthooks.PreToolUseData](t, request) + var err error + _, err = w.Write([]byte(response(data))) require.NoError(t, err) })) t.Cleanup(consumer.Close) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 6938ba19dc5..2f047c6ee4a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5091,7 +5091,7 @@ func (c *DeploymentValues) Validate() error { if c.AI.Chat.HookSecret.Value() == "" { return xerrors.New("chat hook secret is required when chat hook URL is set; set --chat-hook-secret") } - // go-jose requires HS256 secrets to be at least 32 bytes. + // The hook SDK rejects HS256 secrets shorter than 32 bytes. if len(c.AI.Chat.HookSecret.Value()) < 32 { return xerrors.New("chat hook secret must be at least 32 bytes of cryptographically random data; set --chat-hook-secret to a longer value") } From b5907c4d0aa234d261888085fb9915929e2d235a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:54:57 +0000 Subject: [PATCH 30/86] refactor: dispatch a fresh pre_tool_use hook for every tool call Remove the process-local hook decision cache and the streamed-step preflight. Every non-provider-executed tool call now dispatches pre_tool_use at execution time and is validated from that response; Coder never reuses an earlier decision on the consumer's behalf. --- coderd/x/chatd/chatd.go | 7 - coderd/x/chatd/generation.go | 100 +------------ coderd/x/chatd/generation_internal_test.go | 66 --------- coderd/x/chatd/hookreplay.go | 163 --------------------- coderd/x/chatd/hooks.go | 139 ++---------------- coderd/x/chatd/pre_tool_use_test.go | 16 +- coderd/x/chatd/tasks.go | 1 - docs/admin/setup/chat-lifecycle-hooks.md | 5 +- 8 files changed, 31 insertions(+), 466 deletions(-) delete mode 100644 coderd/x/chatd/hookreplay.go diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 420c6b55e8a..46120fa5191 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -179,7 +179,6 @@ type Server struct { pubsub pubsub.Pubsub webpushDispatcher webpush.Dispatcher hookDispatcher *chathooks.Dispatcher - hookDecisions *hookDecisionCache providerAPIKeys chatprovider.ProviderAPIKeys allowBYOK bool oidcTokenSource mcpclient.UserOIDCTokenSource @@ -2351,11 +2350,6 @@ func (p *Server) SubmitToolResults( return translateToolResultValidationError(updateErr) } - settled := make([]string, 0, len(opts.Results)) - for _, result := range opts.Results { - settled = append(settled, result.ToolCallID) - } - p.hookDecisions.evict(opts.ChatID, settled) if refreshedOK { p.publishChatPubsubEvent(refreshChat, codersdk.ChatWatchEventKindStatusChange, nil) } @@ -3295,7 +3289,6 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { pubsub: ps, webpushDispatcher: cfg.WebpushDispatcher, hookDispatcher: hookDispatcher, - hookDecisions: newHookDecisionCache(), providerAPIKeys: cfg.ProviderAPIKeys, allowBYOK: allowBYOK, oidcTokenSource: cfg.OIDCTokenSource, diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 1292cbf69f4..124ce8dbd95 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -318,37 +318,6 @@ func unresolvedToolCallsFromHistory( return localCalls, dynamicCalls, nil } -// priorToolCallIDsInTurn returns tool call IDs from assistant steps -// before the latest one in the current turn. Their recorded -// pre_tool_use decisions must not be replayed for a repeated call. -func priorToolCallIDsInTurn(messages []database.ChatMessage) (map[string]bool, error) { - assistantIndex := lastMessageIndex(messages, func(msg database.ChatMessage) bool { - return msg.Role == database.ChatMessageRoleAssistant - }) - prior := make(map[string]bool) - // Assistant steps lack turn IDs, so user-visible prompts bound the turn. - // Including earlier turns would duplicate hook effects on retry. - start := currentTurnStartIndex(messages) - if assistantIndex < start { - return prior, nil - } - for _, msg := range messages[start:assistantIndex] { - if msg.Deleted || msg.Compressed || msg.Role != database.ChatMessageRoleAssistant { - continue - } - parts, err := chatprompt.ParseContent(msg) - if err != nil { - return nil, xerrors.Errorf("parse assistant message: %w", err) - } - for _, part := range parts { - if part.Type == codersdk.ChatMessagePartTypeToolCall && part.ToolCallID != "" { - prior[part.ToolCallID] = true - } - } - } - return prior, nil -} - func hasExclusiveToolCall(toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool) bool { if len(exclusiveToolNames) == 0 { return false @@ -479,15 +448,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS Input: toolCall.Args, }) } - var priorToolCallIDs map[string]bool - if s.server.hookDispatcher.Enabled() { - priorToolCallIDs, err = priorToolCallIDsInTurn(prepared.Messages) - if err != nil { - cleanup() - return s.finishGenerationError(ctx, machine, input, err, generationAttemptNotRequired) - } - } - preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), toolCalls, priorToolCallIDs) + preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), toolCalls) if err != nil { cleanup() return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventPreToolUse, err), generationAttemptNotRequired) @@ -747,21 +708,10 @@ func (s *taskStarter) generateAssistant( if len(outcome.Step.Content) == 0 { return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt.number)) } - var priorToolCallIDs map[string]bool - if s.server.hookDispatcher.Enabled() { - priorToolCallIDs, err = priorToolCallIDsInTurn(prepared.Messages) - if err != nil { - return err - } - } - preflight, err := s.server.preflightToolCalls(ctx, prepared.Chat, input.hookTurnID(), outcome.Step, outcome.ToolCalls, priorToolCallIDs) - if err != nil { - return generationHookDispatchError(agenthooks.EventPreToolUse, err) - } messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{ modelConfigID: prepared.ModelConfigID, modelCallConfig: prepared.ModelConfig, - step: stepDataFromPersisted(preflight.Step), + step: stepDataFromPersisted(outcome.Step), toolNameToConfigID: prepared.ToolNameToConfigID, logger: s.opts.Logger, contentVersion: chatprompt.CurrentContentVersion, @@ -769,13 +719,7 @@ func (s *taskStarter) generateAssistant( if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - messages, err = applyHookResponseMessages(messages, preflight.Responses, prepared.ModelConfigID) - if err != nil { - return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) - } - return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionGenerateAssistant, messages, generationCommitHooks{ - EffectToolUseIDs: preflight.EffectToolUseIDs, - }) + return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionGenerateAssistant, messages, generationCommitHooks{}) } func (s *taskStarter) commitPreToolUseDeniedResults( @@ -809,14 +753,8 @@ func (s *taskStarter) commitPreToolUseDeniedResults( if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - settled := make([]string, 0, len(preflight.Denied)) - for _, denied := range preflight.Denied { - settled = append(settled, denied.ToolCallID) - } return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ - Overrides: preflight.Overrides, - EffectToolUseIDs: preflight.EffectToolUseIDs, - SettledToolUseIDs: settled, + Overrides: preflight.Overrides, }) } @@ -827,15 +765,7 @@ func (s *taskStarter) executeLocalTools( prepared generationPrepared, decision generationDecision, ) error { - var priorToolCallIDs map[string]bool - if s.server.hookDispatcher.Enabled() { - ids, err := priorToolCallIDsInTurn(prepared.Messages) - if err != nil { - return err - } - priorToolCallIDs = ids - } - preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), decision.localToolCalls, priorToolCallIDs) + preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), decision.localToolCalls) if err != nil { return generationHookDispatchError(agenthooks.EventPreToolUse, err) } @@ -900,15 +830,9 @@ func (s *taskStarter) executeLocalTools( if postDispatchErr != nil { postCommitErr = generationHookDispatchError(agenthooks.EventPostToolUse, postDispatchErr) } - settled := make([]string, 0, len(decision.localToolCalls)) - for _, toolCall := range decision.localToolCalls { - settled = append(settled, toolCall.ToolCallID) - } return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ - Overrides: preflight.Overrides, - PostCommitError: postCommitErr, - EffectToolUseIDs: preflight.EffectToolUseIDs, - SettledToolUseIDs: settled, + Overrides: preflight.Overrides, + PostCommitError: postCommitErr, }) } @@ -1113,12 +1037,6 @@ func (s *taskStarter) beginGenerationAttempt( type generationCommitHooks struct { Overrides map[string]json.RawMessage PostCommitError error - // EffectToolUseIDs marks banked pre_tool_use decisions whose - // transcript effects land in this commit. - EffectToolUseIDs []string - // SettledToolUseIDs are tool calls whose results land in this - // commit; their banked decisions are evicted after it succeeds. - SettledToolUseIDs []string } func (s *taskStarter) commitGenerationStep( @@ -1192,8 +1110,6 @@ func (s *taskStarter) commitGenerationStep( if err != nil { return normalizeTaskTransitionError(err, "commit generation step") } - s.server.hookDecisions.markEffectsApplied(input.ChatID, hooks.EffectToolUseIDs) - s.server.hookDecisions.evict(input.ChatID, hooks.SettledToolUseIDs) if failClosed { input.DebugTurn.RecordOutcome(chatdebug.StatusError) postCommitCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout) @@ -1260,7 +1176,6 @@ func (s *taskStarter) enterRequiresAction( if err != nil { return normalizeTaskTransitionError(err, "enter requires action") } - s.server.hookDecisions.markEffectsApplied(input.ChatID, preflight.EffectToolUseIDs) if err := s.publishWatchAndRoute(ctx, committed, codersdk.ChatWatchEventKindActionRequired); err != nil { return xerrors.Errorf("publish watch and route: %w", err) } @@ -1327,7 +1242,6 @@ func (s *taskStarter) completeGenerationTurn( promotedMessageID int64, ) error { input.StopNudges.reset() - s.server.hookDecisions.evictChat(input.ChatID) input.DebugTurn.RecordOutcome(chatdebug.StatusCompleted) watchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), postCommitWatchPublishTimeout) defer cancel() diff --git a/coderd/x/chatd/generation_internal_test.go b/coderd/x/chatd/generation_internal_test.go index c84ea68de4d..aa8e93a3d96 100644 --- a/coderd/x/chatd/generation_internal_test.go +++ b/coderd/x/chatd/generation_internal_test.go @@ -1,18 +1,15 @@ package chatd //nolint:testpackage // Exercises unexported generation helpers. import ( - "encoding/json" "testing" "github.com/stretchr/testify/require" "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -90,66 +87,3 @@ func TestRecordGenerationFinishFailure(t *testing.T) { }) } } - -func TestPriorToolCallIDsInTurn(t *testing.T) { - t.Parallel() - - t.Run("ExcludesEarlierTurns", func(t *testing.T) { - t.Parallel() - - messages := []database.ChatMessage{ - dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("first prompt")), - dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("reused-1", "run_command", json.RawMessage(`{}`))), - dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("reused-1", "run_command", json.RawMessage(`{}`), false, false)), - dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("second prompt")), - dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("reused-1", "run_command", json.RawMessage(`{}`))), - } - prior, err := priorToolCallIDsInTurn(messages) - require.NoError(t, err) - require.Empty(t, prior) - }) - - t.Run("IncludesEarlierStepsInTurn", func(t *testing.T) { - t.Parallel() - - messages := []database.ChatMessage{ - dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("prompt")), - dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), - dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("call-1", "run_command", json.RawMessage(`{}`), false, false)), - dbMessage(t, 4, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), - } - prior, err := priorToolCallIDsInTurn(messages) - require.NoError(t, err) - require.Equal(t, map[string]bool{"call-1": true}, prior) - }) - - t.Run("HookContextDoesNotSplitTurn", func(t *testing.T) { - t.Parallel() - - hookContext := dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("hook context")) - hookContext.Visibility = database.ChatMessageVisibilityModel - messages := []database.ChatMessage{ - dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("prompt")), - dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), - dbMessage(t, 3, database.ChatMessageRoleTool, false, codersdk.ChatMessageToolResult("call-1", "run_command", json.RawMessage(`{}`), false, false)), - hookContext, - dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("call-1", "run_command", json.RawMessage(`{}`))), - } - prior, err := priorToolCallIDsInTurn(messages) - require.NoError(t, err) - require.Equal(t, map[string]bool{"call-1": true}, prior) - }) - - t.Run("NoAssistantInCurrentTurn", func(t *testing.T) { - t.Parallel() - - messages := []database.ChatMessage{ - dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("first prompt")), - dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("old-1", "run_command", json.RawMessage(`{}`))), - dbMessage(t, 3, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("second prompt")), - } - prior, err := priorToolCallIDsInTurn(messages) - require.NoError(t, err) - require.Empty(t, prior) - }) -} diff --git a/coderd/x/chatd/hookreplay.go b/coderd/x/chatd/hookreplay.go deleted file mode 100644 index 775c29ecb4c..00000000000 --- a/coderd/x/chatd/hookreplay.go +++ /dev/null @@ -1,163 +0,0 @@ -package chatd - -import ( - "encoding/json" - "sync" - "time" - - "github.com/google/uuid" - - "github.com/coder/coder/v2/codersdk/agenthooks" -) - -const ( - // hookDecisionCacheMaxEntries bounds process memory; overflow evicts - // the oldest entries. A miss only widens the duplicate-dispatch - // window, so the bound is a resource guard, not a correctness limit. - hookDecisionCacheMaxEntries = 4096 - hookDecisionCacheTTL = 2 * time.Hour -) - -// hookDecisionCache banks successful pre_tool_use decisions so -// same-process turn re-drives (interruptions, transient errors, -// requires-action recovery, retry after a hook-caused chat error) reuse -// the consumer's decision instead of re-consulting it. The cache is -// best-effort only: correctness and the documented at-least-once -// contract never depend on it. A miss (process loss, failover, -// eviction) dispatches fresh and the consumer's latest decision wins. -type hookDecisionCache struct { - mu sync.Mutex - entries map[hookDecisionKey]*hookDecisionEntry -} - -type hookDecisionKey struct { - chatID uuid.UUID - toolUseID string -} - -type hookDecisionEntry struct { - toolName string - toolInput string - response agenthooks.Response - // effectsApplied is set once transcript effects commit; replay - // then applies only the permission decision. - effectsApplied bool - addedAt time.Time -} - -func newHookDecisionCache() *hookDecisionCache { - return &hookDecisionCache{entries: make(map[hookDecisionKey]*hookDecisionEntry)} -} - -// Replayed lookups derive input from jsonb-round-tripped content whose -// whitespace and key order differ from the streamed original. -func canonicalHookInput(input string) string { - var value any - if err := json.Unmarshal([]byte(input), &value); err != nil { - return input - } - out, err := json.Marshal(value) - if err != nil { - return input - } - return string(out) -} - -func (c *hookDecisionCache) put(chatID uuid.UUID, toolUseID, toolName, toolInput string, response agenthooks.Response) { - if c == nil { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.pruneLocked() - c.entries[hookDecisionKey{chatID: chatID, toolUseID: toolUseID}] = &hookDecisionEntry{ - toolName: toolName, - toolInput: canonicalHookInput(toolInput), - response: response, - addedAt: time.Now(), - } -} - -func (c *hookDecisionCache) lookup(chatID uuid.UUID, toolUseID, toolName, toolInput string) (response agenthooks.Response, effectsApplied bool, ok bool) { - if c == nil { - return agenthooks.Response{}, false, false - } - c.mu.Lock() - defer c.mu.Unlock() - entry, ok := c.entries[hookDecisionKey{chatID: chatID, toolUseID: toolUseID}] - if !ok || entry.toolName != toolName || time.Since(entry.addedAt) > hookDecisionCacheTTL { - return agenthooks.Response{}, false, false - } - input := canonicalHookInput(toolInput) - if entry.toolInput != input && !entry.matchesOverride(input) { - return agenthooks.Response{}, false, false - } - return entry.response, entry.effectsApplied, true -} - -func (e *hookDecisionEntry) matchesOverride(canonicalInput string) bool { - permission := e.response.Permission - return permission != nil && - permission.Decision == agenthooks.PermissionAllow && - len(permission.InputOverride) > 0 && - canonicalHookInput(string(permission.InputOverride)) == canonicalInput -} - -func (c *hookDecisionCache) markEffectsApplied(chatID uuid.UUID, toolUseIDs []string) { - if c == nil || len(toolUseIDs) == 0 { - return - } - c.mu.Lock() - defer c.mu.Unlock() - for _, toolUseID := range toolUseIDs { - if entry, ok := c.entries[hookDecisionKey{chatID: chatID, toolUseID: toolUseID}]; ok { - entry.effectsApplied = true - } - } -} - -func (c *hookDecisionCache) evict(chatID uuid.UUID, toolUseIDs []string) { - if c == nil || len(toolUseIDs) == 0 { - return - } - c.mu.Lock() - defer c.mu.Unlock() - for _, toolUseID := range toolUseIDs { - delete(c.entries, hookDecisionKey{chatID: chatID, toolUseID: toolUseID}) - } -} - -// Entries deliberately survive chat error state so a retry reuses -// decisions banked for the step's other tool calls. -func (c *hookDecisionCache) evictChat(chatID uuid.UUID) { - if c == nil { - return - } - c.mu.Lock() - defer c.mu.Unlock() - for key := range c.entries { - if key.chatID == chatID { - delete(c.entries, key) - } - } -} - -func (c *hookDecisionCache) pruneLocked() { - now := time.Now() - for key, entry := range c.entries { - if now.Sub(entry.addedAt) > hookDecisionCacheTTL { - delete(c.entries, key) - } - } - for len(c.entries) >= hookDecisionCacheMaxEntries { - var oldestKey hookDecisionKey - var oldest time.Time - first := true - for key, entry := range c.entries { - if first || entry.addedAt.Before(oldest) { - oldestKey, oldest, first = key, entry.addedAt, false - } - } - delete(c.entries, oldestKey) - } -} diff --git a/coderd/x/chatd/hooks.go b/coderd/x/chatd/hooks.go index cc9da0742f0..1bf8a9f3461 100644 --- a/coderd/x/chatd/hooks.go +++ b/coderd/x/chatd/hooks.go @@ -17,7 +17,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" - "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chathooks" @@ -77,16 +76,6 @@ func (p *Server) dispatchLifecycleHook( return resp, err } -type preToolUseResult struct { - Step chatloop.PersistedStep - // Responses carries the responses whose transcript effects still - // need to be committed with the step. - Responses []agenthooks.Response - // EffectToolUseIDs identifies the banked decisions whose transcript - // effects commit with the step, for post-commit cache marking. - EffectToolUseIDs []string -} - func (p *Server) dispatchPreToolUse( ctx context.Context, chat database.Chat, @@ -168,74 +157,6 @@ func (p *Server) dispatchPostToolUseResults( return responses, firstErr } -func (p *Server) resolvePreToolUseDecision( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - toolCall fantasy.ToolCallContent, - priorToolCallIDs map[string]bool, -) (agenthooks.Response, bool, error) { - // Re-consult invalid JSON or IDs reused earlier in the turn because - // no banked decision can safely authorize those calls. - if json.Valid([]byte(toolCall.Input)) && !priorToolCallIDs[toolCall.ToolCallID] { - response, effectsApplied, ok := p.hookDecisions.lookup(chat.ID, toolCall.ToolCallID, toolCall.ToolName, toolCall.Input) - if ok { - return response, effectsApplied, nil - } - } - response, err := p.dispatchPreToolUse(ctx, chat, turnID, toolCall) - if err != nil { - return agenthooks.Response{}, false, err - } - p.bankPreToolUseDecision(chat.ID, toolCall, response) - return response, false, nil -} - -func (p *Server) preflightToolCalls( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - step chatloop.PersistedStep, - toolCalls []fantasy.ToolCallContent, - priorToolCallIDs map[string]bool, -) (preToolUseResult, error) { - result := preToolUseResult{Step: step} - if !p.hookDispatcher.Enabled() { - return result, nil - } - if err := rejectDuplicateToolUseIDs(toolCalls); err != nil { - return preToolUseResult{}, err - } - - for _, toolCall := range toolCalls { - if toolCall.ProviderExecuted { - continue - } - banked, effectsApplied, err := p.resolvePreToolUseDecision(ctx, chat, turnID, toolCall, priorToolCallIDs) - if err != nil { - return preToolUseResult{}, err - } - if err := applyPreToolUsePermission(&result.Step, toolCall, banked); err != nil { - return preToolUseResult{}, err - } - if !effectsApplied { - result.Responses = append(result.Responses, transcriptHookResponse(banked)) - result.EffectToolUseIDs = append(result.EffectToolUseIDs, toolCall.ToolCallID) - } - } - return result, nil -} - -// bankPreToolUseDecision caches a successful decision for same-process -// replay. Invalid JSON input is never banked because replay identity -// requires the exact input. -func (p *Server) bankPreToolUseDecision(chatID uuid.UUID, toolCall fantasy.ToolCallContent, response agenthooks.Response) { - if !json.Valid([]byte(toolCall.Input)) { - return - } - p.hookDecisions.put(chatID, toolCall.ToolCallID, toolCall.ToolName, toolCall.Input, response) -} - // transcriptHookResponse returns the response with denial model context // cleared: a denied call's model_context is folded into the synthetic // tool result, so persisting it again as a row would duplicate it. @@ -275,8 +196,9 @@ func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallCon } } -// rejectDuplicateToolUseIDs fails closed because banked replay decisions -// are keyed by tool-use ID within a turn. +// rejectDuplicateToolUseIDs fails closed because hook consumers key +// decisions by tool-use ID; a duplicated ID in one step makes decisions +// unattributable. func rejectDuplicateToolUseIDs(toolCalls []fantasy.ToolCallContent) error { seen := make(map[string]struct{}, len(toolCalls)) for _, toolCall := range toolCalls { @@ -296,9 +218,6 @@ type preToolUseExecutionResult struct { Denied []fantasy.ToolResultContent Responses []agenthooks.Response Overrides map[string]json.RawMessage - // EffectToolUseIDs identifies the banked decisions whose transcript - // effects commit with this step, for post-commit cache marking. - EffectToolUseIDs []string } func (p *Server) preflightPendingToolCalls( @@ -306,7 +225,6 @@ func (p *Server) preflightPendingToolCalls( chat database.Chat, turnID *uuid.UUID, toolCalls []fantasy.ToolCallContent, - priorToolCallIDs map[string]bool, ) (preToolUseExecutionResult, error) { if !p.hookDispatcher.Enabled() { return preToolUseExecutionResult{Allowed: toolCalls}, nil @@ -319,30 +237,27 @@ func (p *Server) preflightPendingToolCalls( } for _, toolCall := range toolCalls { - banked, effectsApplied, err := p.resolvePreToolUseDecision(ctx, chat, turnID, toolCall, priorToolCallIDs) + response, err := p.dispatchPreToolUse(ctx, chat, turnID, toolCall) if err != nil { return preToolUseExecutionResult{}, err } - if !effectsApplied { - result.Responses = append(result.Responses, transcriptHookResponse(banked)) - result.EffectToolUseIDs = append(result.EffectToolUseIDs, toolCall.ToolCallID) - } - if banked.Permission == nil { + result.Responses = append(result.Responses, transcriptHookResponse(response)) + if response.Permission == nil { result.Allowed = append(result.Allowed, toolCall) continue } - switch banked.Permission.Decision { + switch response.Permission.Decision { case agenthooks.PermissionAllow: - if len(banked.Permission.InputOverride) > 0 { - toolCall.Input = string(banked.Permission.InputOverride) + if len(response.Permission.InputOverride) > 0 { + toolCall.Input = string(response.Permission.InputOverride) if result.Overrides == nil { result.Overrides = make(map[string]json.RawMessage) } - result.Overrides[toolCall.ToolCallID] = banked.Permission.InputOverride + result.Overrides[toolCall.ToolCallID] = response.Permission.InputOverride } result.Allowed = append(result.Allowed, toolCall) case agenthooks.PermissionDeny: - result.Denied = append(result.Denied, deniedToolResult(toolCall, banked.Permission.Reason, banked.ModelContext)) + result.Denied = append(result.Denied, deniedToolResult(toolCall, response.Permission.Reason, response.ModelContext)) } } return result, nil @@ -388,21 +303,6 @@ func replacePersistedToolCallInputs( return nil } -func applyPreToolUsePermission(step *chatloop.PersistedStep, toolCall fantasy.ToolCallContent, response agenthooks.Response) error { - if response.Permission == nil { - return nil - } - switch response.Permission.Decision { - case agenthooks.PermissionAllow: - if !replaceToolCallInput(step.Content, toolCall.ToolCallID, string(response.Permission.InputOverride)) { - return xerrors.Errorf("tool call %q is missing from generated step", toolCall.ToolCallID) - } - case agenthooks.PermissionDeny: - step.Content = append(step.Content, deniedToolResult(toolCall, response.Permission.Reason, response.ModelContext)) - } - return nil -} - // deniedToolResult synthesizes the denial as a tool result so the model // can replan within the same turn. The consumer's model_context rides in // the same result instead of a separate transcript row. @@ -424,23 +324,6 @@ func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext str } } -func replaceToolCallInput(content []fantasy.Content, toolCallID, input string) bool { - for i, block := range content { - if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok && toolCall.ToolCallID == toolCallID { - toolCall.Input = input - content[i] = toolCall - return true - } - if toolCall, ok := fantasy.AsContentType[*fantasy.ToolCallContent](block); ok && toolCall != nil && toolCall.ToolCallID == toolCallID { - updated := *toolCall - updated.Input = input - content[i] = updated - return true - } - } - return false -} - func sessionStartSource(messages []database.ChatMessage) string { for _, message := range messages { if message.Role == database.ChatMessageRoleAssistant { diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index aa6f98c92bf..ff974e2724b 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -470,13 +470,17 @@ func TestPreToolUseHookDispatchFailure(t *testing.T) { lastError := chatLastErrorMessage(failed.LastError) require.Contains(t, lastError, "hook dispatch failed: pre_tool_use: "+tt.result) messages := chatMessages(ctx, t, db, chat.ID) - require.Len(t, messages, 1) + require.Len(t, messages, 2) require.Equal(t, database.ChatMessageRoleUser, messages[0].Role) + require.Equal(t, database.ChatMessageRoleAssistant, messages[1].Role) }) } } -func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { +// A dispatch failure fails tool execution before any hook effect is +// committed, so a retry re-dispatches every sibling call and commits +// each transcript effect exactly once. +func TestPreToolUseHookErrorRetryRedispatchesSiblings(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -552,7 +556,7 @@ func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { OwnerID: user.ID, WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, - Title: "pre-tool-use-cache-retry", + Title: "pre-tool-use-error-retry", ModelConfigID: model.ID, InitialUserContent: []codersdk.ChatMessagePart{ codersdk.ChatMessageText("read both files"), @@ -562,7 +566,7 @@ func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) require.Equal(t, int32(1), firstCalls.Load()) require.Equal(t, int32(1), secondCalls.Load()) - require.Len(t, chatMessages(ctx, t, db, chat.ID), 1) + require.Len(t, chatMessages(ctx, t, db, chat.ID), 2) failSecond.Store(false) _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ @@ -574,7 +578,7 @@ func TestPreToolUseHookErrorRetryReusesBankedSiblingDecision(t *testing.T) { require.NoError(t, err) waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) - require.Equal(t, int32(1), firstCalls.Load()) + require.Equal(t, int32(2), firstCalls.Load()) require.Equal(t, int32(2), secondCalls.Load()) promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) require.NoError(t, err) @@ -643,7 +647,7 @@ func TestPreToolUseHookSettledDecisionDispatchesFresh(t *testing.T) { OwnerID: user.ID, WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, - Title: "pre-tool-use-settled-cache", + Title: "pre-tool-use-settled", ModelConfigID: model.ID, InitialUserContent: []codersdk.ChatMessagePart{ codersdk.ChatMessageText("read once"), diff --git a/coderd/x/chatd/tasks.go b/coderd/x/chatd/tasks.go index df02eeaa5c7..c1c0c840e4a 100644 --- a/coderd/x/chatd/tasks.go +++ b/coderd/x/chatd/tasks.go @@ -317,7 +317,6 @@ func (s *taskStarter) StartInterrupt(ctx context.Context, input chatWorkerTaskSt } return normalizeTaskTransitionError(err, "finish interruption") } - s.server.hookDecisions.evictChat(input.ChatID) input.DebugTurn.RecordOutcome(chatdebug.StatusInterrupted) if err := s.publishWatchAndRoute(ctx, committed, codersdk.ChatWatchEventKindStatusChange); err != nil { return xerrors.Errorf("publish watch and route: %w", err) diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 50b3628d1f1..c0115383fd2 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -103,7 +103,7 @@ If the response has a body, return a JSON object with these optional fields. | `user_message` | Adds a message visible to the user. | The `permission.decision` value supports `allow` and `deny`. -The `ask` value isn't supported and causes the dispatch to fail closed. +Any other value causes the dispatch to fail closed. Permission rules depend on the event: @@ -138,7 +138,8 @@ Treat events as attempt notifications rather than proof of a committed operation Delivery is at least once. Coder retries one connection failure per dispatch with the same JWT, so use `dispatch_id` to recognize a repeated HTTP attempt and return the same response. -Coder can also re-dispatch the same logical event with a new `dispatch_id`, for example when a chat recovers after a crash and retries a pending tool call. +Coder also re-dispatches the same logical event with a new `dispatch_id` whenever an operation runs again, for example when a chat recovers after a crash and retries a pending tool call, or when a user retries a turn that failed before committing. +Every tool call is validated through a fresh `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. Use event-specific identifiers for logical duplicates: From 9d4c67af51438b21f9e6b454c6bc092ae9260bec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:40:38 +0000 Subject: [PATCH 31/86] fix(coderd/x/chatd): fail the turn when subagent spawn hook dispatch fails The tool loop persists Run errors as ordinary tool results, so a user_prompt_submit dispatch failure during spawn admission degraded into a tool error the model could ignore. Detect the dispatch failure in the executed step and fail the step before commit. --- coderd/x/chatd/generation.go | 6 ++++ coderd/x/chatd/hooks.go | 29 +++++++++++++++ coderd/x/chatd/hooks_test.go | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 124ce8dbd95..90ab749edfb 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -801,6 +801,12 @@ func (s *taskStarter) executeLocalTools( if err != nil { return xerrors.Errorf("execute local tools: %w", err) } + // Subagent spawn admission dispatches user_prompt_submit inside + // the tool run; its failure surfaces as a tool result error and + // must fail the step instead of committing. + if hookErr := hookDispatchFailureFromResults(outcome.Step.Content); hookErr != nil { + return generationHookDispatchError(agenthooks.EventUserPromptSubmit, hookErr) + } } postResponses, postDispatchErr := s.server.dispatchPostToolUseResults(ctx, prepared.Chat, input.hookTurnID(), outcome.Step.Content) for _, denied := range preflight.Denied { diff --git a/coderd/x/chatd/hooks.go b/coderd/x/chatd/hooks.go index 1bf8a9f3461..cd4b7505209 100644 --- a/coderd/x/chatd/hooks.go +++ b/coderd/x/chatd/hooks.go @@ -196,6 +196,35 @@ func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallCon } } +// hookDispatchFailureFromResults returns the first tool result error +// whose chain contains a hook dispatch failure. Tools that dispatch +// lifecycle hooks inside Run (subagent spawn admission) must fail +// closed, but the tool loop persists Run errors as ordinary tool +// results the model can ignore, so the step has to be failed before +// commit instead. +func hookDispatchFailureFromResults(content []fantasy.Content) error { + for _, block := range content { + toolResult, ok := asToolResultContent(block) + if !ok { + continue + } + var resultErr error + switch output := toolResult.Result.(type) { + case fantasy.ToolResultOutputContentError: + resultErr = output.Error + case *fantasy.ToolResultOutputContentError: + if output != nil { + resultErr = output.Error + } + } + var dispatchErr *chathooks.DispatchError + if resultErr != nil && errors.As(resultErr, &dispatchErr) { + return resultErr + } + } + return nil +} + // rejectDuplicateToolUseIDs fails closed because hook consumers key // decisions by tool-use ID; a duplicated ID in one step makes decisions // unattributable. diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index 52012594dcb..bd2d56fbd0a 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agenthooks" @@ -326,6 +327,75 @@ func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { } } +// A user_prompt_submit dispatch failure during subagent spawn admission +// must fail the parent turn closed instead of committing a tool error +// the model can ignore. +func TestSubagentSpawnHookDispatchFailureFailsTurn(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + chunk := chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"general","prompt":"child admission prompt"}`) + chunk.Choices[0].ToolCalls[0].ID = "call_spawn" + return chattest.OpenAIStreamingResponse(chunk) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + + 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)) + if request.Type == agenthooks.EventUserPromptSubmit { + data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) + if data.Prompt == "child admission prompt" { + w.WriteHeader(http.StatusInternalServerError) + return + } + } + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "spawn-hook-failure", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("spawn a child"), + }, + }) + require.NoError(t, err) + failed := waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) + require.Contains(t, chatLastErrorMessage(failed.LastError), "hook dispatch failed: user_prompt_submit: http_error") + + // The assistant step with the tool call committed, but no tool + // result was persisted for the failed spawn. + messages := chatMessages(ctx, t, db, chat.ID) + require.Len(t, messages, 2) + require.Equal(t, database.ChatMessageRoleUser, messages[0].Role) + require.Equal(t, database.ChatMessageRoleAssistant, messages[1].Role) + + // The rejected child chat must not exist. + chats, err := db.GetChats(ctx, database.GetChatsParams{ + OwnedOnly: true, + ViewerID: user.ID, + AfterID: uuid.Nil, + OffsetOpt: 0, + LimitOpt: 100, + }) + require.NoError(t, err) + require.Len(t, chats, 1) +} + func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) From 5a78290004c536020a1323dca02a71d114293da3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:55:49 +0000 Subject: [PATCH 32/86] refactor(coderd/x/chatd): route all lifecycle hook dispatch through one trigger seam Split hooks.go by responsibility with no behavior or wire change: hook_trigger.go owns the single dispatch seam (hookTrigger.trigger with hookChat + hookMessage + event), hook_effects.go converts results to transcript content, hook_errors.go classifies failures, and hook_tooluse.go gates tool calls. Call sites build the two structs and hand results to the matching helper instead of duplicating payload assembly and enabled-checks. --- coderd/x/chatd/chatd.go | 163 ++---- coderd/x/chatd/generation.go | 84 ++- coderd/x/chatd/hook_effects.go | 217 ++++++++ coderd/x/chatd/hook_errors.go | 167 ++++++ coderd/x/chatd/hook_tooluse.go | 296 ++++++++++ coderd/x/chatd/hook_trigger.go | 197 +++++++ coderd/x/chatd/hooks.go | 671 ----------------------- coderd/x/chatd/hooks_internal_test.go | 138 ++++- coderd/x/chatd/subagent.go | 27 +- coderd/x/chatd/subagent_internal_test.go | 4 +- 10 files changed, 1125 insertions(+), 839 deletions(-) create mode 100644 coderd/x/chatd/hook_effects.go create mode 100644 coderd/x/chatd/hook_errors.go create mode 100644 coderd/x/chatd/hook_tooluse.go create mode 100644 coderd/x/chatd/hook_trigger.go delete mode 100644 coderd/x/chatd/hooks.go diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 46120fa5191..ca2780abdee 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -178,7 +178,7 @@ type Server struct { stopWorkspaceFn chattool.StopWorkspaceFn pubsub pubsub.Pubsub webpushDispatcher webpush.Dispatcher - hookDispatcher *chathooks.Dispatcher + hooks *hookTrigger providerAPIKeys chatprovider.ProviderAPIKeys allowBYOK bool oidcTokenSource mcpclient.UserOIDCTokenSource @@ -1318,22 +1318,26 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C chatID := uuid.New() contentParts := opts.InitialUserContent - var hookResponse agenthooks.Response - if p.hookDispatcher.Enabled() { + if p.hooks.enabled() { // Validate model admission before dispatch, matching the insert path. if err := validateCreateModelConfigID(ctx, p.db, opts.ModelConfigID); err != nil { return database.Chat{}, err } turnID := uuid.New() - hookChat := database.Chat{} - hookChat.ID = chatID - hookChat.OwnerID = opts.OwnerID - hookChat.WorkspaceID = opts.WorkspaceID - hookResponse, err = p.dispatchUserPromptSubmit(ctx, hookChat, turnID, contentParts) + promptMessage, err := userPromptHookMessage(contentParts) if err != nil { return database.Chat{}, err } - composed, overridden, err := composeUserPromptContent(contentParts, hookResponse) + promptResult, err := p.hooks.trigger(ctx, hookChat{ + ID: chatID, + OwnerID: opts.OwnerID, + WorkspaceID: opts.WorkspaceID, + TurnID: &turnID, + }, promptMessage, agenthooks.EventUserPromptSubmit) + if err != nil { + return database.Chat{}, userPromptDenial(err) + } + composed, overridden, err := composeUserPromptContent(contentParts, promptResult) if err != nil { return database.Chat{}, err } @@ -1453,8 +1457,7 @@ func (p *Server) SendMessage( } contentParts := opts.Content - var hookResponse agenthooks.Response - if p.hookDispatcher.Enabled() { + if p.hooks.enabled() { turnID := uuid.New() chat, err := p.db.GetChatByID(ctx, opts.ChatID) if err != nil { @@ -1478,11 +1481,15 @@ func (p *Server) SendMessage( if queuedCount >= chatstate.MaxQueueSize { return SendMessageResult{}, &chatstate.MessageQueueFullError{Max: chatstate.MaxQueueSize} } - hookResponse, err = p.dispatchUserPromptSubmit(ctx, chat, turnID, contentParts) + promptMessage, err := userPromptHookMessage(contentParts) + if err != nil { + return SendMessageResult{}, err + } + promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, agenthooks.EventUserPromptSubmit) if err != nil { - return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, err) + return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, userPromptDenial(err)) } - contentParts, _, err = composeUserPromptContent(contentParts, hookResponse) + contentParts, _, err = composeUserPromptContent(contentParts, promptResult) if err != nil { return SendMessageResult{}, err } @@ -1781,8 +1788,8 @@ func (p *Server) EditMessage( } contentParts := opts.Content - var sessionStartResponse, hookResponse agenthooks.Response - if p.hookDispatcher.Enabled() { + var sessionStartHookResult *hookResult + if p.hooks.enabled() { turnID := uuid.New() chat, err := p.db.GetChatByID(ctx, opts.ChatID) if err != nil { @@ -1801,15 +1808,19 @@ func (p *Server) EditMessage( if _, err := validateModelConfigOverride(ctx, p.db, opts.ModelConfigID); err != nil { return EditMessageResult{}, err } - sessionStartResponse, err = p.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSourceClear}) + sessionStartHookResult, err = p.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSourceClear}, agenthooks.EventSessionStart) if err != nil { return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, agenthooks.EventSessionStart, err) } - hookResponse, err = p.dispatchUserPromptSubmit(ctx, chat, turnID, contentParts) + promptMessage, err := userPromptHookMessage(contentParts) + if err != nil { + return EditMessageResult{}, err + } + promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, agenthooks.EventUserPromptSubmit) if err != nil { - return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, err) + return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, userPromptDenial(err)) } - contentParts, _, err = composeUserPromptContent(contentParts, hookResponse) + contentParts, _, err = composeUserPromptContent(contentParts, promptResult) if err != nil { return EditMessageResult{}, err } @@ -1887,7 +1898,7 @@ func (p *Server) EditMessage( // only the session_start(clear) response needs transcript rows. // They insert after the replacement so a later edit's suffix // truncation cleans them up. - suffixMessages, err := hookEventMessages(sessionStartResponse, modelConfigID) + suffixMessages, err := hookEventMessages(sessionStartHookResult, modelConfigID) if err != nil { return err } @@ -2162,110 +2173,6 @@ func (e *ToolResultStatusConflictError) Error() string { ) } -type dynamicPostToolUseState struct { - chat database.Chat - modelConfigID uuid.UUID - toolNames map[string]string -} - -func loadDynamicPostToolUseState( - ctx context.Context, - machine *chatstate.ChatMachine, - opts SubmitToolResultsOptions, -) (dynamicPostToolUseState, error) { - var state dynamicPostToolUseState - err := machine.ReadLock(ctx, func(store database.Store) error { - chat, err := store.GetChatByID(ctx, opts.ChatID) - if err != nil { - return xerrors.Errorf("load chat: %w", err) - } - if chat.Archived { - return ErrChatArchived - } - if chat.Status != database.ChatStatusRequiresAction { - return &ToolResultStatusConflictError{ActualStatus: chat.Status} - } - messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: opts.ChatID, - AfterID: 0, - }) - if err != nil { - return xerrors.Errorf("load chat messages: %w", err) - } - _, pending, err := unresolvedToolCallsFromHistory(messages, dynamicToolNamesFromChat(chat)) - if err != nil { - return xerrors.Errorf("load pending dynamic tool calls: %w", err) - } - toolNames := make(map[string]string, len(pending)) - for _, call := range pending { - toolNames[call.ToolCallID] = call.ToolName - } - if err := validateSubmittedToolResults(opts.Results, toolNames); err != nil { - return err - } - modelConfigID := opts.ModelConfigID - if modelConfigID == uuid.Nil { - modelConfigID = chat.LastModelConfigID - } - state = dynamicPostToolUseState{ - chat: chat, - modelConfigID: modelConfigID, - toolNames: toolNames, - } - return nil - }) - return state, err -} - -func validateSubmittedToolResults(results []codersdk.ToolResult, toolNames map[string]string) error { - submitted := make(map[string]struct{}, len(results)) - for _, result := range results { - if _, ok := submitted[result.ToolCallID]; ok { - return &ToolResultValidationError{ - Message: "Duplicate tool_call_id in results.", - Detail: fmt.Sprintf("Duplicate tool call ID %q.", result.ToolCallID), - } - } - if !json.Valid(result.Output) { - return &ToolResultValidationError{ - Message: "Tool result output must be valid JSON.", - Detail: fmt.Sprintf("Output for tool call %q is not valid JSON.", result.ToolCallID), - } - } - if _, ok := toolNames[result.ToolCallID]; !ok { - return &ToolResultValidationError{ - Message: "Unexpected tool result.", - Detail: fmt.Sprintf("No pending tool call with ID %q.", result.ToolCallID), - } - } - submitted[result.ToolCallID] = struct{}{} - } - for toolCallID := range toolNames { - if _, ok := submitted[toolCallID]; !ok { - return &ToolResultValidationError{ - Message: "Missing tool result.", - Detail: fmt.Sprintf("Missing result for tool call %q.", toolCallID), - } - } - } - return nil -} - -func dynamicPostToolUseData(result codersdk.ToolResult, toolName string) agenthooks.PostToolUseData { - data := agenthooks.PostToolUseData{ - ToolUseID: result.ToolCallID, - ToolName: toolName, - } - if result.IsError { - if err := json.Unmarshal(result.Output, &data.ToolError); err != nil { - data.ToolError = string(result.Output) - } - } else { - data.ToolResponse = append(json.RawMessage(nil), result.Output...) - } - return data -} - // SubmitToolResults dispatches hooks before completing the // requires_action transition. func (p *Server) SubmitToolResults( @@ -2274,13 +2181,13 @@ func (p *Server) SubmitToolResults( ) error { machine := p.newChatMachine(opts.ChatID) var hookSuffix []chatstate.Message - if p.hookDispatcher.Enabled() { + if p.hooks.enabled() { state, err := loadDynamicPostToolUseState(ctx, machine, opts) if err != nil { return err } for _, result := range opts.Results { - response, err := p.dispatchPostToolUseData(ctx, state.chat, nil, dynamicPostToolUseData(result, state.toolNames[result.ToolCallID])) + response, err := p.hooks.trigger(ctx, hookChatFor(state.chat, nil), dynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), agenthooks.EventPostToolUse) if err != nil { // Leave pending calls intact so the client can resubmit after recovery. return generationHookDispatchError(agenthooks.EventPostToolUse, err) @@ -3288,7 +3195,7 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { stopWorkspaceFn: cfg.StopWorkspace, pubsub: ps, webpushDispatcher: cfg.WebpushDispatcher, - hookDispatcher: hookDispatcher, + hooks: newHookTrigger(hookDispatcher), providerAPIKeys: cfg.ProviderAPIKeys, allowBYOK: allowBYOK, oidcTokenSource: cfg.OIDCTokenSource, diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 90ab749edfb..aeefb4293a4 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -330,6 +330,48 @@ func hasExclusiveToolCall(toolCalls []fantasy.ToolCallContent, exclusiveToolName return false } +type sessionStartResult struct { + Chat database.Chat +} + +func applySessionStartResponse( + ctx context.Context, + machine *chatstate.ChatMachine, + input chatWorkerTaskStartInput, + chat database.Chat, + result *hookResult, +) (sessionStartResult, error) { + if result.modelContext() == "" && result.userMessage() == "" { + return sessionStartResult{Chat: chat}, nil + } + + eventMessages, err := hookEventMessages(result, chat.LastModelConfigID) + if err != nil { + return sessionStartResult{}, err + } + + var applied sessionStartResult + err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + if _, err := loadChatForGeneration(ctx, store, input, generationAttemptNotRequired); err != nil { + return xerrors.Errorf("load chat for session_start response: %w", err) + } + if len(eventMessages) > 0 { + if _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: eventMessages}); err != nil { + return xerrors.Errorf("insert session_start response messages: %w", err) + } + } + applied.Chat, err = store.GetChatByID(ctx, input.ChatID) + if err != nil { + return xerrors.Errorf("reload chat after session_start response: %w", err) + } + return nil + }) + if err != nil { + return sessionStartResult{}, normalizeTaskTransitionError(err, "apply session_start response") + } + return applied, nil +} + func (s *taskStarter) startGenerationSession( ctx context.Context, machine *chatstate.ChatMachine, @@ -349,9 +391,9 @@ func (s *taskStarter) startGenerationSession( // Re-arm the claim until its response is applied so a replacement task // can replay session_start effects. defer func() { complete(completed) }() - response, err := s.server.dispatchLifecycleHook(ctx, chat, input.hookTurnID(), agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSource(messages)}) + response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{Source: sessionStartSource(messages)}, agenthooks.EventSessionStart) if err != nil { - return sessionStartResult{}, true, sessionStartDispatchError(err) + return sessionStartResult{}, true, generationHookDispatchError(agenthooks.EventSessionStart, err) } result, err = applySessionStartResponse(ctx, machine, input, chat, response) if err != nil { @@ -374,7 +416,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS if err != nil { return xerrors.Errorf("load generation state: %w", err) } - if s.server.hookDispatcher.Enabled() { + if s.server.hooks.enabled() { result, dispatched, err := s.startGenerationSession(ctx, machine, input, chat, messages) if err != nil { if errors.Is(err, errTaskExpectedExit) { @@ -448,7 +490,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS Input: toolCall.Args, }) } - preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), toolCalls) + preflight, err := s.server.hooks.preflightPendingToolCalls(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), toolCalls) if err != nil { cleanup() return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventPreToolUse, err), generationAttemptNotRequired) @@ -749,7 +791,7 @@ func (s *taskStarter) commitPreToolUseDeniedResults( if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - messages, err = applyHookResponseMessages(messages, preflight.Responses, prepared.ModelConfigID) + messages, err = applyHookResultMessages(messages, preflight.Results, prepared.ModelConfigID) if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } @@ -765,7 +807,7 @@ func (s *taskStarter) executeLocalTools( prepared generationPrepared, decision generationDecision, ) error { - preflight, err := s.server.preflightPendingToolCalls(ctx, prepared.Chat, input.hookTurnID(), decision.localToolCalls) + preflight, err := s.server.hooks.preflightPendingToolCalls(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) if err != nil { return generationHookDispatchError(agenthooks.EventPreToolUse, err) } @@ -808,7 +850,7 @@ func (s *taskStarter) executeLocalTools( return generationHookDispatchError(agenthooks.EventUserPromptSubmit, hookErr) } } - postResponses, postDispatchErr := s.server.dispatchPostToolUseResults(ctx, prepared.Chat, input.hookTurnID(), outcome.Step.Content) + postResults, postDispatchErr := s.server.hooks.postToolUseResults(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), outcome.Step.Content) for _, denied := range preflight.Denied { outcome.Step.Content = append(outcome.Step.Content, denied) } @@ -824,11 +866,11 @@ func (s *taskStarter) executeLocalTools( if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - messages, err = applyHookResponseMessages(messages, preflight.Responses, prepared.ModelConfigID) + messages, err = applyHookResultMessages(messages, preflight.Results, prepared.ModelConfigID) if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - messages, err = appendHookResponseMessages(messages, postResponses, prepared.ModelConfigID) + messages, err = appendHookResultMessages(messages, postResults, prepared.ModelConfigID) if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } @@ -892,11 +934,11 @@ func (s *taskStarter) generateCompaction( overrideModel.modelConfig, ) } - preResponse, err := s.server.dispatchLifecycleHook(ctx, prepared.Chat, input.hookTurnID(), agenthooks.EventPreCompact, agenthooks.PreCompactData{}) + preResult, err := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, agenthooks.EventPreCompact) if err != nil { return generationHookDispatchError(agenthooks.EventPreCompact, err) } - compactionOpts.SummaryHint = preResponse.ModelContext + compactionOpts.SummaryHint = preResult.modelContext() compactionOpts.PublishMessagePart = attempt.publish compactionOpts.Source = source compactionOpts.Force = source == chatloop.CompactionSourceManual @@ -925,13 +967,13 @@ func (s *taskStarter) generateCompaction( s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } - persistedPreResponse := preResponse - persistedPreResponse.ModelContext = "" - commitMessages, err := applyHookResponseMessages(stepMessagesForCommit{ + // The summary hint already consumed the pre_compact model context. + persistedPreResult := &hookResult{UserMessage: preResult.userMessage()} + commitMessages, err := applyHookResultMessages(stepMessagesForCommit{ Messages: messages.Messages, VisibleIndexes: visibleMessageIndexes(messages.Messages), ConsumeCompactionRequest: true, - }, []agenthooks.Response{persistedPreResponse}, prepared.ModelConfigID) + }, []*hookResult{persistedPreResult}, prepared.ModelConfigID) if err != nil { s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) @@ -939,12 +981,12 @@ func (s *taskStarter) generateCompaction( // Hook effects and fail-closed errors must commit atomically with // compaction; a separate commit races the runner and can be dropped // on crash. - postResponse, postDispatchErr := s.server.dispatchLifecycleHook(ctx, prepared.Chat, input.hookTurnID(), agenthooks.EventPostCompact, agenthooks.PostCompactData{}) + postResult, postDispatchErr := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, agenthooks.EventPostCompact) var postCommitErr error if postDispatchErr != nil { postCommitErr = generationHookDispatchError(agenthooks.EventPostCompact, postDispatchErr) } else { - commitMessages, err = appendHookResponseMessages(commitMessages, []agenthooks.Response{postResponse}, prepared.ModelConfigID) + commitMessages, err = appendHookResultMessages(commitMessages, []*hookResult{postResult}, prepared.ModelConfigID) if err != nil { s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) @@ -1145,7 +1187,7 @@ func (s *taskStarter) enterRequiresAction( prepared generationPrepared, preflight preToolUseExecutionResult, ) error { - messages, err := applyHookResponseMessages(stepMessagesForCommit{}, preflight.Responses, prepared.ModelConfigID) + messages, err := applyHookResultMessages(stepMessagesForCommit{}, preflight.Results, prepared.ModelConfigID) if err != nil { return err } @@ -1303,7 +1345,7 @@ func (s *taskStarter) finishGenerationTurn( decision generationDecision, fence generationAttemptFence, ) error { - if !s.server.hookDispatcher.Enabled() { + if !s.server.hooks.enabled() { return s.finishGenerationTurnWithoutHook(ctx, machine, input, decision, fence) } var chat database.Chat @@ -1327,7 +1369,7 @@ func (s *taskStarter) finishGenerationTurn( if err != nil { return normalizeTaskTransitionError(err, "load stop hook state") } - response, err := s.server.dispatchLifecycleHook(ctx, chat, input.hookTurnID(), agenthooks.EventStop, agenthooks.StopData{}) + response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{}, agenthooks.EventStop) if err != nil { return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventStop, err), fence) } @@ -1336,7 +1378,7 @@ func (s *taskStarter) finishGenerationTurn( return s.finishGenerationError(ctx, machine, input, err, fence) } nudgeKey := stopNudgeKey(messages) - continueTurn := response.ModelContext != "" && input.StopNudges.claim(nudgeKey) + continueTurn := response.modelContext() != "" && input.StopNudges.claim(nudgeKey) var committed database.Chat err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { diff --git a/coderd/x/chatd/hook_effects.go b/coderd/x/chatd/hook_effects.go new file mode 100644 index 00000000000..c9449a3e6ba --- /dev/null +++ b/coderd/x/chatd/hook_effects.go @@ -0,0 +1,217 @@ +package chatd + +import ( + "bytes" + "encoding/json" + "io" + "slices" + "strings" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/codersdk" +) + +// hookEventMessages converts a turn-time hook result into ordinary +// transcript rows: model context becomes a user-role, model-visible row +// and the user message becomes a system-role, user-visible notice row. +func hookEventMessages(result *hookResult, modelConfigID uuid.UUID) ([]chatstate.Message, error) { + messages := make([]chatstate.Message, 0, 2) + if result.modelContext() != "" { + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.ModelContext)}) + if err != nil { + return nil, xerrors.Errorf("marshal hook model context: %w", err) + } + messages = append(messages, chatstate.Message{ + Role: database.ChatMessageRoleUser, + Content: content, + Visibility: database.ChatMessageVisibilityModel, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + }) + } + if result.userMessage() != "" { + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.UserMessage)}) + if err != nil { + return nil, xerrors.Errorf("marshal hook user message: %w", err) + } + messages = append(messages, chatstate.Message{ + Role: database.ChatMessageRoleSystem, + Content: content, + Visibility: database.ChatMessageVisibilityUser, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + }) + } + return messages, nil +} + +func hookEventMessagesForResults( + results []*hookResult, + modelConfigID uuid.UUID, +) ([]chatstate.Message, error) { + var messages []chatstate.Message + for _, result := range results { + resultMessages, err := hookEventMessages(result, modelConfigID) + if err != nil { + return nil, err + } + messages = append(messages, resultMessages...) + } + return messages, nil +} + +// applyHookResultMessages inserts hook event rows before the step's +// own rows so injected model context precedes the assistant content it +// steers; providers require tool results to directly follow the +// assistant tool calls. +func applyHookResultMessages( + messages stepMessagesForCommit, + results []*hookResult, + modelConfigID uuid.UUID, +) (stepMessagesForCommit, error) { + rows, err := hookEventMessagesForResults(results, modelConfigID) + if err != nil { + return stepMessagesForCommit{}, err + } + if len(rows) > 0 { + messages.Messages = append(rows, messages.Messages...) + messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) + } + return messages, nil +} + +func appendHookResultMessages( + messages stepMessagesForCommit, + results []*hookResult, + modelConfigID uuid.UUID, +) (stepMessagesForCommit, error) { + suffix, err := hookEventMessagesForResults(results, modelConfigID) + if err != nil { + return stepMessagesForCommit{}, err + } + if len(suffix) > 0 { + messages.Messages = append(messages.Messages, suffix...) + messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) + } + return messages, nil +} + +// deniedToolResult synthesizes the denial as a tool result so the model +// can replan within the same turn. The consumer's model_context rides in +// the same result instead of a separate transcript row. +func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "denied by lifecycle hook" + } + message := "DENIED: " + reason + if modelContext = strings.TrimSpace(modelContext); modelContext != "" { + message += "\n\n" + modelContext + } + return fantasy.ToolResultContent{ + ToolCallID: toolCall.ToolCallID, + ToolName: toolCall.ToolName, + Result: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(message), + }, + } +} + +// restoreToolCallOrder reorders tool results to match the assistant's +// call order because providers pair results with calls positionally. +// Entries that are not tool results for the given calls keep their slots. +func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallContent) { + position := make(map[string]int, len(calls)) + for index, call := range calls { + position[call.ToolCallID] = index + } + slots := make([]int, 0, len(content)) + results := make([]fantasy.ToolResultContent, 0, len(content)) + for index, entry := range content { + result, ok := entry.(fantasy.ToolResultContent) + if !ok { + continue + } + if _, known := position[result.ToolCallID]; !known { + continue + } + slots = append(slots, index) + results = append(results, result) + } + slices.SortStableFunc(results, func(a, b fantasy.ToolResultContent) int { + return position[a.ToolCallID] - position[b.ToolCallID] + }) + for index, slot := range slots { + content[slot] = results[index] + } +} + +func userPromptOverride(result *hookResult) (string, bool, error) { + if result == nil || len(result.InputOverride) == 0 { + return "", false, nil + } + var override struct { + Prompt *string `json:"prompt"` + } + decoder := json.NewDecoder(bytes.NewReader(result.InputOverride)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&override); err != nil { + return "", false, xerrors.Errorf("decode user prompt input override: %w", err) + } + if override.Prompt == nil { + return "", false, xerrors.New("decode user prompt input override: prompt is required") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return "", false, xerrors.New("decode user prompt input override: trailing JSON value") + } + return *override.Prompt, true, nil +} + +// userPromptHookParts converts a user_prompt_submit result into the +// typed parts carried inside the submitted message: hook-context is +// model-only steering, hook-notice is a client-only notice. +func userPromptHookParts(result *hookResult) []codersdk.ChatMessagePart { + parts := make([]codersdk.ChatMessagePart, 0, 2) + if result.modelContext() != "" { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeHookContext, + Text: result.ModelContext, + }) + } + if result.userMessage() != "" { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeHookNotice, + Text: result.UserMessage, + }) + } + return parts +} + +// composeUserPromptContent applies a user_prompt_submit result to the +// submitted parts. The merge order is fixed: override-or-original user +// parts first, then hook-context, then hook-notice. The composite +// content then flows through the ordinary send, queue, and edit paths. +func composeUserPromptContent(parts []codersdk.ChatMessagePart, result *hookResult) ([]codersdk.ChatMessagePart, bool, error) { + override, overridden, err := userPromptOverride(result) + if err != nil { + return nil, false, err + } + userParts := parts + if overridden { + userParts = []codersdk.ChatMessagePart{codersdk.ChatMessageText(override)} + } + hookParts := userPromptHookParts(result) + if len(hookParts) == 0 { + return userParts, overridden, nil + } + combined := make([]codersdk.ChatMessagePart, 0, len(userParts)+len(hookParts)) + combined = append(combined, userParts...) + combined = append(combined, hookParts...) + return combined, overridden, nil +} diff --git a/coderd/x/chatd/hook_errors.go b/coderd/x/chatd/hook_errors.go new file mode 100644 index 00000000000..16bf25b2fa8 --- /dev/null +++ b/coderd/x/chatd/hook_errors.go @@ -0,0 +1,167 @@ +package chatd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" +) + +// hookDeniedError is trigger's normalized form of a permission deny. +// Callers translate it per event: user_prompt_submit sites map it to +// UserPromptDeniedError, pre_tool_use sites fold it into a synthetic +// tool result. +type hookDeniedError struct { + Event agenthooks.EventType + Reason string + ModelContext string + UserMessage string +} + +func (e *hookDeniedError) Error() string { + if e.Reason == "" { + return fmt.Sprintf("%s denied by lifecycle hook", e.Event) + } + return fmt.Sprintf("%s denied by lifecycle hook: %s", e.Event, e.Reason) +} + +// UserPromptDeniedError reports that a lifecycle hook rejected a prompt. +type UserPromptDeniedError struct { + UserMessage string +} + +// Error includes UserMessage so callers that only surface the error +// string, such as subagent tool responses, still expose the hook's +// reason. The HTTP handlers unwrap the typed error instead. +func (e *UserPromptDeniedError) Error() string { + if e.UserMessage == "" { + return "user prompt denied by lifecycle hook" + } + return "user prompt denied by lifecycle hook: " + e.UserMessage +} + +// userPromptDenial maps a hook denial to the exported error consumed +// by the API handlers; every other error passes through unchanged. +func userPromptDenial(err error) error { + var denied *hookDeniedError + if errors.As(err, &denied) { + return &UserPromptDeniedError{UserMessage: denied.UserMessage} + } + return err +} + +func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { + return p.handleAPIDispatchError(ctx, chatID, agenthooks.EventUserPromptSubmit, dispatchErr) +} + +func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType agenthooks.EventType, dispatchErr error) error { + lastError, ok := hookDispatchErrorMessage(eventType, dispatchErr) + if !ok { + return dispatchErr + } + encoded, marshalErr := json.Marshal(codersdk.ChatError{ + Message: lastError, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) + if marshalErr != nil { + return errors.Join(dispatchErr, xerrors.Errorf("encode hook dispatch error: %w", marshalErr)) + } + var failedChat database.Chat + machine := p.newChatMachine(chatID) + err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + current, err := store.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("load chat for hook failure: %w", err) + } + // Park only idle chats. FinishError is also allowed from running + // states, but a running chat keeps its active turn and the + // request error alone surfaces to the caller. + if current.Status != database.ChatStatusWaiting { + return chatstate.ErrTransitionNotAllowed + } + if _, err := tx.FinishError(chatstate.FinishErrorInput{ + LastError: pqtype.NullRawMessage{RawMessage: encoded, Valid: true}, + }); err != nil { + return err + } + chat, err := store.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("reload chat after hook failure: %w", err) + } + failedChat = chat + return nil + }) + if errors.Is(err, chatstate.ErrTransitionNotAllowed) { + return dispatchErr + } + if err != nil { + return errors.Join(dispatchErr, xerrors.Errorf("fail idle chat after hook dispatch: %w", err)) + } + p.publishChatPubsubEvent(failedChat, codersdk.ChatWatchEventKindStatusChange, nil) + return dispatchErr +} + +func hookDispatchErrorMessage(eventType agenthooks.EventType, dispatchErr error) (string, bool) { + var structured *chathooks.DispatchError + if !errors.As(dispatchErr, &structured) { + return "", false + } + return fmt.Sprintf( + "hook dispatch failed: %s: %s (dispatch %s)", + eventType, + structured.Class, + structured.DispatchID, + ), true +} + +func generationHookDispatchError(eventType agenthooks.EventType, dispatchErr error) error { + message, ok := hookDispatchErrorMessage(eventType, dispatchErr) + if !ok { + message = dispatchErr.Error() + } + return chaterror.WithClassification(dispatchErr, chaterror.ClassifiedError{ + Message: message, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) +} + +// hookDispatchFailureFromResults returns the first tool result error +// whose chain contains a hook dispatch failure. Tools that dispatch +// lifecycle hooks inside Run (subagent spawn admission) must fail +// closed, but the tool loop persists Run errors as ordinary tool +// results the model can ignore, so the step has to be failed before +// commit instead. +func hookDispatchFailureFromResults(content []fantasy.Content) error { + for _, block := range content { + toolResult, ok := asToolResultContent(block) + if !ok { + continue + } + var resultErr error + switch output := toolResult.Result.(type) { + case fantasy.ToolResultOutputContentError: + resultErr = output.Error + case *fantasy.ToolResultOutputContentError: + if output != nil { + resultErr = output.Error + } + } + var dispatchErr *chathooks.DispatchError + if resultErr != nil && errors.As(resultErr, &dispatchErr) { + return resultErr + } + } + return nil +} diff --git a/coderd/x/chatd/hook_tooluse.go b/coderd/x/chatd/hook_tooluse.go new file mode 100644 index 00000000000..9d81c1dbb65 --- /dev/null +++ b/coderd/x/chatd/hook_tooluse.go @@ -0,0 +1,296 @@ +package chatd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" +) + +// rejectDuplicateToolUseIDs fails closed because hook consumers key +// decisions by tool-use ID; a duplicated ID in one step makes decisions +// unattributable. +func rejectDuplicateToolUseIDs(toolCalls []fantasy.ToolCallContent) error { + seen := make(map[string]struct{}, len(toolCalls)) + for _, toolCall := range toolCalls { + if toolCall.ProviderExecuted { + continue + } + if _, ok := seen[toolCall.ToolCallID]; ok { + return xerrors.Errorf("duplicate tool use ID %q in one step; lifecycle hook decisions cannot be attributed unambiguously", toolCall.ToolCallID) + } + seen[toolCall.ToolCallID] = struct{}{} + } + return nil +} + +// preToolUseExecutionResult partitions a step's tool calls by the +// pre_tool_use decisions: Allowed calls run (with overridden inputs +// applied), Denied calls become synthetic results, and Results carries +// the per-call injected content for the transcript in call order. +type preToolUseExecutionResult struct { + Allowed []fantasy.ToolCallContent + Denied []fantasy.ToolResultContent + Results []*hookResult + Overrides map[string]json.RawMessage +} + +func (t *hookTrigger) preflightPendingToolCalls( + ctx context.Context, + chat hookChat, + toolCalls []fantasy.ToolCallContent, +) (preToolUseExecutionResult, error) { + if !t.enabled() { + return preToolUseExecutionResult{Allowed: toolCalls}, nil + } + result := preToolUseExecutionResult{ + Allowed: make([]fantasy.ToolCallContent, 0, len(toolCalls)), + } + if err := rejectDuplicateToolUseIDs(toolCalls); err != nil { + return preToolUseExecutionResult{}, err + } + + for _, toolCall := range toolCalls { + callResult, err := t.trigger(ctx, chat, hookMessage{ + ToolUseID: toolCall.ToolCallID, + ToolName: toolCall.ToolName, + ToolInput: json.RawMessage(toolCall.Input), + }, agenthooks.EventPreToolUse) + if err != nil { + var denied *hookDeniedError + if !errors.As(err, &denied) { + return preToolUseExecutionResult{}, err + } + // The denial's model context folds into the synthetic tool + // result; only the user notice needs a transcript row. + result.Results = append(result.Results, &hookResult{UserMessage: denied.UserMessage}) + result.Denied = append(result.Denied, deniedToolResult(toolCall, denied.Reason, denied.ModelContext)) + continue + } + result.Results = append(result.Results, callResult) + if len(callResult.InputOverride) > 0 { + toolCall.Input = string(callResult.InputOverride) + if result.Overrides == nil { + result.Overrides = make(map[string]json.RawMessage) + } + result.Overrides[toolCall.ToolCallID] = callResult.InputOverride + } + result.Allowed = append(result.Allowed, toolCall) + } + return result, nil +} + +func postToolUseMessage(toolResult fantasy.ToolResultContent) (hookMessage, error) { + msg := hookMessage{ + ToolUseID: toolResult.ToolCallID, + ToolName: toolResult.ToolName, + } + switch output := toolResult.Result.(type) { + case fantasy.ToolResultOutputContentError: + if output.Error != nil { + msg.ToolError = output.Error.Error() + } + case *fantasy.ToolResultOutputContentError: + if output != nil && output.Error != nil { + msg.ToolError = output.Error.Error() + } + default: + encoded, err := json.Marshal(toolResult.Result) + if err != nil { + return hookMessage{}, xerrors.Errorf("marshal post_tool_use response: %w", err) + } + msg.ToolResponse = encoded + } + return msg, nil +} + +func (t *hookTrigger) postToolUseResults( + ctx context.Context, + chat hookChat, + content []fantasy.Content, +) ([]*hookResult, error) { + if !t.enabled() { + return nil, nil + } + results := make([]*hookResult, 0, len(content)) + // Dispatch every completed non-provider-executed tool result. + // Preserve only the first failure. + var firstErr error + for _, block := range content { + toolResult, ok := asToolResultContent(block) + if !ok || toolResult.ProviderExecuted { + continue + } + msg, err := postToolUseMessage(toolResult) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + result, err := t.trigger(ctx, chat, msg, agenthooks.EventPostToolUse) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + results = append(results, result) + } + return results, firstErr +} + +func replacePersistedToolCallInputs( + ctx context.Context, + tx *chatstate.Tx, + chatID uuid.UUID, + overrides map[string]json.RawMessage, +) error { + if len(overrides) == 0 { + return nil + } + assistant, err := tx.Store().GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ + ChatID: chatID, + Role: database.ChatMessageRoleAssistant, + }) + if err != nil { + return xerrors.Errorf("get assistant message for tool override: %w", err) + } + parts, err := chatprompt.ParseContent(assistant) + if err != nil { + return xerrors.Errorf("parse assistant message for tool override: %w", err) + } + changed := false + for i := range parts { + if override, ok := overrides[parts[i].ToolCallID]; ok && parts[i].Type == codersdk.ChatMessagePartTypeToolCall && !bytes.Equal(parts[i].Args, override) { + parts[i].Args = override + changed = true + } + } + if !changed { + return nil + } + content, err := chatprompt.MarshalParts(parts) + if err != nil { + return xerrors.Errorf("marshal assistant message with tool override: %w", err) + } + if err := tx.UpdateMessageContent(assistant.ID, content.RawMessage); err != nil { + return xerrors.Errorf("update assistant message with tool override: %w", err) + } + return nil +} + +type dynamicPostToolUseState struct { + chat database.Chat + modelConfigID uuid.UUID + toolNames map[string]string +} + +func loadDynamicPostToolUseState( + ctx context.Context, + machine *chatstate.ChatMachine, + opts SubmitToolResultsOptions, +) (dynamicPostToolUseState, error) { + var state dynamicPostToolUseState + err := machine.ReadLock(ctx, func(store database.Store) error { + chat, err := store.GetChatByID(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("load chat: %w", err) + } + if chat.Archived { + return ErrChatArchived + } + if chat.Status != database.ChatStatusRequiresAction { + return &ToolResultStatusConflictError{ActualStatus: chat.Status} + } + messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: opts.ChatID, + AfterID: 0, + }) + if err != nil { + return xerrors.Errorf("load chat messages: %w", err) + } + _, pending, err := unresolvedToolCallsFromHistory(messages, dynamicToolNamesFromChat(chat)) + if err != nil { + return xerrors.Errorf("load pending dynamic tool calls: %w", err) + } + toolNames := make(map[string]string, len(pending)) + for _, call := range pending { + toolNames[call.ToolCallID] = call.ToolName + } + if err := validateSubmittedToolResults(opts.Results, toolNames); err != nil { + return err + } + modelConfigID := opts.ModelConfigID + if modelConfigID == uuid.Nil { + modelConfigID = chat.LastModelConfigID + } + state = dynamicPostToolUseState{ + chat: chat, + modelConfigID: modelConfigID, + toolNames: toolNames, + } + return nil + }) + return state, err +} + +func validateSubmittedToolResults(results []codersdk.ToolResult, toolNames map[string]string) error { + submitted := make(map[string]struct{}, len(results)) + for _, result := range results { + if _, ok := submitted[result.ToolCallID]; ok { + return &ToolResultValidationError{ + Message: "Duplicate tool_call_id in results.", + Detail: fmt.Sprintf("Duplicate tool call ID %q.", result.ToolCallID), + } + } + if !json.Valid(result.Output) { + return &ToolResultValidationError{ + Message: "Tool result output must be valid JSON.", + Detail: fmt.Sprintf("Output for tool call %q is not valid JSON.", result.ToolCallID), + } + } + if _, ok := toolNames[result.ToolCallID]; !ok { + return &ToolResultValidationError{ + Message: "Unexpected tool result.", + Detail: fmt.Sprintf("No pending tool call with ID %q.", result.ToolCallID), + } + } + submitted[result.ToolCallID] = struct{}{} + } + for toolCallID := range toolNames { + if _, ok := submitted[toolCallID]; !ok { + return &ToolResultValidationError{ + Message: "Missing tool result.", + Detail: fmt.Sprintf("Missing result for tool call %q.", toolCallID), + } + } + } + return nil +} + +func dynamicPostToolUseMessage(result codersdk.ToolResult, toolName string) hookMessage { + msg := hookMessage{ + ToolUseID: result.ToolCallID, + ToolName: toolName, + } + if result.IsError { + if err := json.Unmarshal(result.Output, &msg.ToolError); err != nil { + msg.ToolError = string(result.Output) + } + } else { + msg.ToolResponse = append(json.RawMessage(nil), result.Output...) + } + return msg +} diff --git a/coderd/x/chatd/hook_trigger.go b/coderd/x/chatd/hook_trigger.go new file mode 100644 index 00000000000..c026e706bfe --- /dev/null +++ b/coderd/x/chatd/hook_trigger.go @@ -0,0 +1,197 @@ +package chatd + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agenthooks" +) + +const ( + sessionStartSourceStartup = "startup" + sessionStartSourceResume = "resume" + sessionStartSourceClear = "clear" +) + +func sessionStartSource(messages []database.ChatMessage) string { + for _, message := range messages { + if message.Role == database.ChatMessageRoleAssistant { + return sessionStartSourceResume + } + } + return sessionStartSourceStartup +} + +// hookTrigger is the only component that talks to the hook dispatcher. +// Every lifecycle event flows through trigger, which builds the wire +// envelope, dispatches, and normalizes the outcome. +type hookTrigger struct { + dispatcher *chathooks.Dispatcher +} + +func newHookTrigger(dispatcher *chathooks.Dispatcher) *hookTrigger { + return &hookTrigger{dispatcher: dispatcher} +} + +func (t *hookTrigger) enabled() bool { + return t != nil && t.dispatcher.Enabled() +} + +// hookChat identifies the chat and turn an event belongs to. Admission +// events for chats that do not exist yet (create, subagent spawn) fill +// the fields directly instead of loading a row. +type hookChat struct { + ID uuid.UUID + OwnerID uuid.UUID + WorkspaceID uuid.NullUUID + ParentChatID uuid.NullUUID + RootChatID uuid.NullUUID + TurnID *uuid.UUID +} + +func hookChatFor(chat database.Chat, turnID *uuid.UUID) hookChat { + return hookChat{ + ID: chat.ID, + OwnerID: chat.OwnerID, + WorkspaceID: chat.WorkspaceID, + ParentChatID: chat.ParentChatID, + RootChatID: chat.RootChatID, + TurnID: turnID, + } +} + +func (c hookChat) ref() agenthooks.ChatRef { + ref := agenthooks.ChatRef{ + ChatID: c.ID, + OwnerID: c.OwnerID, + TurnID: c.TurnID, + } + if c.WorkspaceID.Valid { + ref.WorkspaceID = &c.WorkspaceID.UUID + } + if c.ParentChatID.Valid { + ref.ParentChatID = &c.ParentChatID.UUID + } + if c.RootChatID.Valid { + ref.RootChatID = &c.RootChatID.UUID + } + return ref +} + +// hookMessage carries the message details for an event. Each event +// reads the fields relevant to it and ignores the rest. +type hookMessage struct { + // Source is the session_start trigger: startup, resume, or clear. + Source string + // Prompt and Parts describe the user_prompt_submit submission. + Prompt string + Parts json.RawMessage + // ToolUseID and ToolName identify pre_tool_use and post_tool_use + // calls; ToolInput rides on pre_tool_use, ToolResponse and + // ToolError on post_tool_use. + ToolUseID string + ToolName string + ToolInput json.RawMessage + ToolResponse json.RawMessage + ToolError string +} + +func userPromptHookMessage(parts []codersdk.ChatMessagePart) (hookMessage, error) { + encoded, err := chatprompt.MarshalParts(parts) + if err != nil { + return hookMessage{}, xerrors.Errorf("marshal prompt parts for hook: %w", err) + } + return hookMessage{ + Prompt: textFromParts(parts), + Parts: encoded.RawMessage, + }, nil +} + +// hookResult is a consumer response normalized for callers: a non-empty +// InputOverride means the permission decision was allow with a +// replacement input (the wire contract rejects allow without one). +// Denials surface as *hookDeniedError instead. +type hookResult struct { + InputOverride json.RawMessage + ModelContext string + UserMessage string +} + +var emptyHookResult = &hookResult{} + +func (r *hookResult) modelContext() string { + if r == nil { + return "" + } + return r.ModelContext +} + +func (r *hookResult) userMessage() string { + if r == nil { + return "" + } + return r.UserMessage +} + +// trigger dispatches one lifecycle event. A disabled dispatcher returns +// an empty result; interpretation of the result stays with the caller. +func (t *hookTrigger) trigger( + ctx context.Context, + chat hookChat, + msg hookMessage, + event agenthooks.EventType, +) (*hookResult, error) { + if !t.enabled() { + return emptyHookResult, nil + } + var data any + switch event { + case agenthooks.EventSessionStart: + data = agenthooks.SessionStartData{Source: msg.Source} + case agenthooks.EventUserPromptSubmit: + data = agenthooks.UserPromptSubmitData{Prompt: msg.Prompt, Parts: msg.Parts} + case agenthooks.EventPreToolUse: + data = agenthooks.PreToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolInput: msg.ToolInput} + case agenthooks.EventPostToolUse: + data = agenthooks.PostToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolResponse: msg.ToolResponse, ToolError: msg.ToolError} + case agenthooks.EventPreCompact: + data = agenthooks.PreCompactData{} + case agenthooks.EventPostCompact: + data = agenthooks.PostCompactData{} + case agenthooks.EventStop: + data = agenthooks.StopData{} + default: + return nil, xerrors.Errorf("unsupported hook event %q", event) + } + response, _, err := t.dispatcher.Dispatch(ctx, chathooks.Event{ + Type: event, + ChatRef: chat.ref(), + Data: data, + }) + if err != nil { + return nil, err + } + if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { + return nil, &hookDeniedError{ + Event: event, + Reason: response.Permission.Reason, + ModelContext: response.ModelContext, + UserMessage: response.UserMessage, + } + } + result := &hookResult{ + ModelContext: response.ModelContext, + UserMessage: response.UserMessage, + } + if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionAllow { + result.InputOverride = response.Permission.InputOverride + } + return result, nil +} diff --git a/coderd/x/chatd/hooks.go b/coderd/x/chatd/hooks.go deleted file mode 100644 index cd4b7505209..00000000000 --- a/coderd/x/chatd/hooks.go +++ /dev/null @@ -1,671 +0,0 @@ -package chatd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "slices" - "strings" - - "charm.land/fantasy" - "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/x/chatd/chaterror" - "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/chathooks" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" -) - -const ( - sessionStartSourceStartup = "startup" - sessionStartSourceResume = "resume" - sessionStartSourceClear = "clear" -) - -func lifecycleHookEvent( - chat database.Chat, - turnID *uuid.UUID, - eventType agenthooks.EventType, - data any, -) chathooks.Event { - var workspaceID *uuid.UUID - if chat.WorkspaceID.Valid { - workspaceID = &chat.WorkspaceID.UUID - } - var parentChatID *uuid.UUID - if chat.ParentChatID.Valid { - parentChatID = &chat.ParentChatID.UUID - } - var rootChatID *uuid.UUID - if chat.RootChatID.Valid { - rootChatID = &chat.RootChatID.UUID - } - return chathooks.Event{ - Type: eventType, - ChatRef: agenthooks.ChatRef{ - ChatID: chat.ID, - OwnerID: chat.OwnerID, - WorkspaceID: workspaceID, - TurnID: turnID, - ParentChatID: parentChatID, - RootChatID: rootChatID, - }, - Data: data, - } -} - -func (p *Server) dispatchLifecycleHook( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - eventType agenthooks.EventType, - data any, -) (agenthooks.Response, error) { - if !p.hookDispatcher.Enabled() { - return agenthooks.Response{}, nil - } - resp, _, err := p.hookDispatcher.Dispatch(ctx, lifecycleHookEvent(chat, turnID, eventType, data)) - return resp, err -} - -func (p *Server) dispatchPreToolUse( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - toolCall fantasy.ToolCallContent, -) (agenthooks.Response, error) { - return p.dispatchLifecycleHook(ctx, chat, turnID, agenthooks.EventPreToolUse, agenthooks.PreToolUseData{ - ToolUseID: toolCall.ToolCallID, - ToolName: toolCall.ToolName, - ToolInput: json.RawMessage(toolCall.Input), - }) -} - -func (p *Server) dispatchPostToolUseData( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - data agenthooks.PostToolUseData, -) (agenthooks.Response, error) { - return p.dispatchLifecycleHook(ctx, chat, turnID, agenthooks.EventPostToolUse, data) -} - -func (p *Server) dispatchPostToolUse( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - toolResult fantasy.ToolResultContent, -) (agenthooks.Response, error) { - data := agenthooks.PostToolUseData{ - ToolUseID: toolResult.ToolCallID, - ToolName: toolResult.ToolName, - } - switch output := toolResult.Result.(type) { - case fantasy.ToolResultOutputContentError: - if output.Error != nil { - data.ToolError = output.Error.Error() - } - case *fantasy.ToolResultOutputContentError: - if output != nil && output.Error != nil { - data.ToolError = output.Error.Error() - } - default: - encoded, err := json.Marshal(toolResult.Result) - if err != nil { - return agenthooks.Response{}, xerrors.Errorf("marshal post_tool_use response: %w", err) - } - data.ToolResponse = encoded - } - return p.dispatchPostToolUseData(ctx, chat, turnID, data) -} - -func (p *Server) dispatchPostToolUseResults( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - content []fantasy.Content, -) ([]agenthooks.Response, error) { - if !p.hookDispatcher.Enabled() { - return nil, nil - } - responses := make([]agenthooks.Response, 0, len(content)) - // Dispatch every completed non-provider-executed tool result. - // Preserve only the first failure. - var firstErr error - for _, block := range content { - toolResult, ok := asToolResultContent(block) - if !ok || toolResult.ProviderExecuted { - continue - } - response, err := p.dispatchPostToolUse(ctx, chat, turnID, toolResult) - if err != nil { - if firstErr == nil { - firstErr = err - } - continue - } - responses = append(responses, response) - } - return responses, firstErr -} - -// transcriptHookResponse returns the response with denial model context -// cleared: a denied call's model_context is folded into the synthetic -// tool result, so persisting it again as a row would duplicate it. -func transcriptHookResponse(response agenthooks.Response) agenthooks.Response { - if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { - response.ModelContext = "" - } - return response -} - -// restoreToolCallOrder reorders tool results to match the assistant's -// call order because providers pair results with calls positionally. -// Entries that are not tool results for the given calls keep their slots. -func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallContent) { - position := make(map[string]int, len(calls)) - for index, call := range calls { - position[call.ToolCallID] = index - } - slots := make([]int, 0, len(content)) - results := make([]fantasy.ToolResultContent, 0, len(content)) - for index, entry := range content { - result, ok := entry.(fantasy.ToolResultContent) - if !ok { - continue - } - if _, known := position[result.ToolCallID]; !known { - continue - } - slots = append(slots, index) - results = append(results, result) - } - slices.SortStableFunc(results, func(a, b fantasy.ToolResultContent) int { - return position[a.ToolCallID] - position[b.ToolCallID] - }) - for index, slot := range slots { - content[slot] = results[index] - } -} - -// hookDispatchFailureFromResults returns the first tool result error -// whose chain contains a hook dispatch failure. Tools that dispatch -// lifecycle hooks inside Run (subagent spawn admission) must fail -// closed, but the tool loop persists Run errors as ordinary tool -// results the model can ignore, so the step has to be failed before -// commit instead. -func hookDispatchFailureFromResults(content []fantasy.Content) error { - for _, block := range content { - toolResult, ok := asToolResultContent(block) - if !ok { - continue - } - var resultErr error - switch output := toolResult.Result.(type) { - case fantasy.ToolResultOutputContentError: - resultErr = output.Error - case *fantasy.ToolResultOutputContentError: - if output != nil { - resultErr = output.Error - } - } - var dispatchErr *chathooks.DispatchError - if resultErr != nil && errors.As(resultErr, &dispatchErr) { - return resultErr - } - } - return nil -} - -// rejectDuplicateToolUseIDs fails closed because hook consumers key -// decisions by tool-use ID; a duplicated ID in one step makes decisions -// unattributable. -func rejectDuplicateToolUseIDs(toolCalls []fantasy.ToolCallContent) error { - seen := make(map[string]struct{}, len(toolCalls)) - for _, toolCall := range toolCalls { - if toolCall.ProviderExecuted { - continue - } - if _, ok := seen[toolCall.ToolCallID]; ok { - return xerrors.Errorf("duplicate tool use ID %q in one step; lifecycle hook decisions cannot be attributed unambiguously", toolCall.ToolCallID) - } - seen[toolCall.ToolCallID] = struct{}{} - } - return nil -} - -type preToolUseExecutionResult struct { - Allowed []fantasy.ToolCallContent - Denied []fantasy.ToolResultContent - Responses []agenthooks.Response - Overrides map[string]json.RawMessage -} - -func (p *Server) preflightPendingToolCalls( - ctx context.Context, - chat database.Chat, - turnID *uuid.UUID, - toolCalls []fantasy.ToolCallContent, -) (preToolUseExecutionResult, error) { - if !p.hookDispatcher.Enabled() { - return preToolUseExecutionResult{Allowed: toolCalls}, nil - } - result := preToolUseExecutionResult{ - Allowed: make([]fantasy.ToolCallContent, 0, len(toolCalls)), - } - if err := rejectDuplicateToolUseIDs(toolCalls); err != nil { - return preToolUseExecutionResult{}, err - } - - for _, toolCall := range toolCalls { - response, err := p.dispatchPreToolUse(ctx, chat, turnID, toolCall) - if err != nil { - return preToolUseExecutionResult{}, err - } - result.Responses = append(result.Responses, transcriptHookResponse(response)) - if response.Permission == nil { - result.Allowed = append(result.Allowed, toolCall) - continue - } - switch response.Permission.Decision { - case agenthooks.PermissionAllow: - if len(response.Permission.InputOverride) > 0 { - toolCall.Input = string(response.Permission.InputOverride) - if result.Overrides == nil { - result.Overrides = make(map[string]json.RawMessage) - } - result.Overrides[toolCall.ToolCallID] = response.Permission.InputOverride - } - result.Allowed = append(result.Allowed, toolCall) - case agenthooks.PermissionDeny: - result.Denied = append(result.Denied, deniedToolResult(toolCall, response.Permission.Reason, response.ModelContext)) - } - } - return result, nil -} - -func replacePersistedToolCallInputs( - ctx context.Context, - tx *chatstate.Tx, - chatID uuid.UUID, - overrides map[string]json.RawMessage, -) error { - if len(overrides) == 0 { - return nil - } - assistant, err := tx.Store().GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ - ChatID: chatID, - Role: database.ChatMessageRoleAssistant, - }) - if err != nil { - return xerrors.Errorf("get assistant message for tool override: %w", err) - } - parts, err := chatprompt.ParseContent(assistant) - if err != nil { - return xerrors.Errorf("parse assistant message for tool override: %w", err) - } - changed := false - for i := range parts { - if override, ok := overrides[parts[i].ToolCallID]; ok && parts[i].Type == codersdk.ChatMessagePartTypeToolCall && !bytes.Equal(parts[i].Args, override) { - parts[i].Args = override - changed = true - } - } - if !changed { - return nil - } - content, err := chatprompt.MarshalParts(parts) - if err != nil { - return xerrors.Errorf("marshal assistant message with tool override: %w", err) - } - if err := tx.UpdateMessageContent(assistant.ID, content.RawMessage); err != nil { - return xerrors.Errorf("update assistant message with tool override: %w", err) - } - return nil -} - -// deniedToolResult synthesizes the denial as a tool result so the model -// can replan within the same turn. The consumer's model_context rides in -// the same result instead of a separate transcript row. -func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { - reason = strings.TrimSpace(reason) - if reason == "" { - reason = "denied by lifecycle hook" - } - message := "DENIED: " + reason - if modelContext = strings.TrimSpace(modelContext); modelContext != "" { - message += "\n\n" + modelContext - } - return fantasy.ToolResultContent{ - ToolCallID: toolCall.ToolCallID, - ToolName: toolCall.ToolName, - Result: fantasy.ToolResultOutputContentError{ - Error: xerrors.New(message), - }, - } -} - -func sessionStartSource(messages []database.ChatMessage) string { - for _, message := range messages { - if message.Role == database.ChatMessageRoleAssistant { - return sessionStartSourceResume - } - } - return sessionStartSourceStartup -} - -// UserPromptDeniedError reports that a lifecycle hook rejected a prompt. -type UserPromptDeniedError struct { - UserMessage string -} - -// Error includes UserMessage so callers that only surface the error -// string, such as subagent tool responses, still expose the hook's -// reason. The HTTP handlers unwrap the typed error instead. -func (e *UserPromptDeniedError) Error() string { - if e.UserMessage == "" { - return "user prompt denied by lifecycle hook" - } - return "user prompt denied by lifecycle hook: " + e.UserMessage -} - -func (p *Server) dispatchUserPromptSubmit( - ctx context.Context, - chat database.Chat, - turnID uuid.UUID, - parts []codersdk.ChatMessagePart, -) (agenthooks.Response, error) { - encodedParts, err := chatprompt.MarshalParts(parts) - if err != nil { - return agenthooks.Response{}, xerrors.Errorf("marshal prompt parts for hook: %w", err) - } - response, err := p.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventUserPromptSubmit, agenthooks.UserPromptSubmitData{ - Prompt: textFromParts(parts), - Parts: encodedParts.RawMessage, - }) - if err != nil { - return agenthooks.Response{}, err - } - if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { - return response, &UserPromptDeniedError{UserMessage: response.UserMessage} - } - return response, nil -} - -func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { - return p.handleAPIDispatchError(ctx, chatID, agenthooks.EventUserPromptSubmit, dispatchErr) -} - -func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType agenthooks.EventType, dispatchErr error) error { - lastError, ok := hookDispatchErrorMessage(eventType, dispatchErr) - if !ok { - return dispatchErr - } - encoded, marshalErr := json.Marshal(codersdk.ChatError{ - Message: lastError, - Kind: codersdk.ChatErrorKindHookDispatchFailed, - }) - if marshalErr != nil { - return errors.Join(dispatchErr, xerrors.Errorf("encode hook dispatch error: %w", marshalErr)) - } - var failedChat database.Chat - machine := p.newChatMachine(chatID) - err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { - current, err := store.GetChatByID(ctx, chatID) - if err != nil { - return xerrors.Errorf("load chat for hook failure: %w", err) - } - // Park only idle chats. FinishError is also allowed from running - // states, but a running chat keeps its active turn and the - // request error alone surfaces to the caller. - if current.Status != database.ChatStatusWaiting { - return chatstate.ErrTransitionNotAllowed - } - if _, err := tx.FinishError(chatstate.FinishErrorInput{ - LastError: pqtype.NullRawMessage{RawMessage: encoded, Valid: true}, - }); err != nil { - return err - } - chat, err := store.GetChatByID(ctx, chatID) - if err != nil { - return xerrors.Errorf("reload chat after hook failure: %w", err) - } - failedChat = chat - return nil - }) - if errors.Is(err, chatstate.ErrTransitionNotAllowed) { - return dispatchErr - } - if err != nil { - return errors.Join(dispatchErr, xerrors.Errorf("fail idle chat after hook dispatch: %w", err)) - } - p.publishChatPubsubEvent(failedChat, codersdk.ChatWatchEventKindStatusChange, nil) - return dispatchErr -} - -func hookDispatchErrorMessage(eventType agenthooks.EventType, dispatchErr error) (string, bool) { - var structured *chathooks.DispatchError - if !errors.As(dispatchErr, &structured) { - return "", false - } - return fmt.Sprintf( - "hook dispatch failed: %s: %s (dispatch %s)", - eventType, - structured.Class, - structured.DispatchID, - ), true -} - -func sessionStartDispatchError(dispatchErr error) error { - return generationHookDispatchError(agenthooks.EventSessionStart, dispatchErr) -} - -func generationHookDispatchError(eventType agenthooks.EventType, dispatchErr error) error { - message, ok := hookDispatchErrorMessage(eventType, dispatchErr) - if !ok { - message = dispatchErr.Error() - } - return chaterror.WithClassification(dispatchErr, chaterror.ClassifiedError{ - Message: message, - Kind: codersdk.ChatErrorKindHookDispatchFailed, - }) -} - -type sessionStartResult struct { - Chat database.Chat -} - -func applySessionStartResponse( - ctx context.Context, - machine *chatstate.ChatMachine, - input chatWorkerTaskStartInput, - chat database.Chat, - response agenthooks.Response, -) (sessionStartResult, error) { - if response.ModelContext == "" && response.UserMessage == "" { - return sessionStartResult{Chat: chat}, nil - } - - eventMessages, err := hookEventMessages(response, chat.LastModelConfigID) - if err != nil { - return sessionStartResult{}, err - } - - var result sessionStartResult - err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { - if _, err := loadChatForGeneration(ctx, store, input, generationAttemptNotRequired); err != nil { - return xerrors.Errorf("load chat for session_start response: %w", err) - } - if len(eventMessages) > 0 { - if _, err := tx.CommitStep(chatstate.CommitStepInput{Messages: eventMessages}); err != nil { - return xerrors.Errorf("insert session_start response messages: %w", err) - } - } - result.Chat, err = store.GetChatByID(ctx, input.ChatID) - if err != nil { - return xerrors.Errorf("reload chat after session_start response: %w", err) - } - return nil - }) - if err != nil { - return sessionStartResult{}, normalizeTaskTransitionError(err, "apply session_start response") - } - return result, nil -} - -// hookEventMessages converts a turn-time hook response into ordinary -// transcript rows: model context becomes a user-role, model-visible row -// and the user message becomes a system-role, user-visible notice row. -func hookEventMessages(response agenthooks.Response, modelConfigID uuid.UUID) ([]chatstate.Message, error) { - messages := make([]chatstate.Message, 0, 2) - if response.ModelContext != "" { - content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(response.ModelContext)}) - if err != nil { - return nil, xerrors.Errorf("marshal hook model context: %w", err) - } - messages = append(messages, chatstate.Message{ - Role: database.ChatMessageRoleUser, - Content: content, - Visibility: database.ChatMessageVisibilityModel, - ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, - ContentVersion: chatprompt.CurrentContentVersion, - }) - } - if response.UserMessage != "" { - content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(response.UserMessage)}) - if err != nil { - return nil, xerrors.Errorf("marshal hook user message: %w", err) - } - messages = append(messages, chatstate.Message{ - Role: database.ChatMessageRoleSystem, - Content: content, - Visibility: database.ChatMessageVisibilityUser, - ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, - ContentVersion: chatprompt.CurrentContentVersion, - }) - } - return messages, nil -} - -func hookEventMessagesForResponses( - responses []agenthooks.Response, - modelConfigID uuid.UUID, -) ([]chatstate.Message, error) { - var messages []chatstate.Message - for _, response := range responses { - responseMessages, err := hookEventMessages(response, modelConfigID) - if err != nil { - return nil, err - } - messages = append(messages, responseMessages...) - } - return messages, nil -} - -// applyHookResponseMessages inserts hook event rows before the step's -// own rows so injected model context precedes the assistant content it -// steers; providers require tool results to directly follow the -// assistant tool calls. -func applyHookResponseMessages( - messages stepMessagesForCommit, - responses []agenthooks.Response, - modelConfigID uuid.UUID, -) (stepMessagesForCommit, error) { - rows, err := hookEventMessagesForResponses(responses, modelConfigID) - if err != nil { - return stepMessagesForCommit{}, err - } - if len(rows) > 0 { - messages.Messages = append(rows, messages.Messages...) - messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) - } - return messages, nil -} - -func appendHookResponseMessages( - messages stepMessagesForCommit, - responses []agenthooks.Response, - modelConfigID uuid.UUID, -) (stepMessagesForCommit, error) { - suffix, err := hookEventMessagesForResponses(responses, modelConfigID) - if err != nil { - return stepMessagesForCommit{}, err - } - if len(suffix) > 0 { - messages.Messages = append(messages.Messages, suffix...) - messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) - } - return messages, nil -} - -func userPromptOverride(response agenthooks.Response) (string, bool, error) { - if response.Permission == nil || response.Permission.Decision != agenthooks.PermissionAllow { - return "", false, nil - } - var override struct { - Prompt *string `json:"prompt"` - } - decoder := json.NewDecoder(bytes.NewReader(response.Permission.InputOverride)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&override); err != nil { - return "", false, xerrors.Errorf("decode user prompt input override: %w", err) - } - if override.Prompt == nil { - return "", false, xerrors.New("decode user prompt input override: prompt is required") - } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - return "", false, xerrors.New("decode user prompt input override: trailing JSON value") - } - return *override.Prompt, true, nil -} - -// userPromptHookParts converts a user_prompt_submit response into the -// typed parts carried inside the submitted message: hook-context is -// model-only steering, hook-notice is a client-only notice. -func userPromptHookParts(response agenthooks.Response) []codersdk.ChatMessagePart { - parts := make([]codersdk.ChatMessagePart, 0, 2) - if response.ModelContext != "" { - parts = append(parts, codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeHookContext, - Text: response.ModelContext, - }) - } - if response.UserMessage != "" { - parts = append(parts, codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeHookNotice, - Text: response.UserMessage, - }) - } - return parts -} - -// composeUserPromptContent applies a user_prompt_submit response to the -// submitted parts. The merge order is fixed: override-or-original user -// parts first, then hook-context, then hook-notice. The composite -// content then flows through the ordinary send, queue, and edit paths. -func composeUserPromptContent(parts []codersdk.ChatMessagePart, response agenthooks.Response) ([]codersdk.ChatMessagePart, bool, error) { - override, overridden, err := userPromptOverride(response) - if err != nil { - return nil, false, err - } - userParts := parts - if overridden { - userParts = []codersdk.ChatMessagePart{codersdk.ChatMessageText(override)} - } - hookParts := userPromptHookParts(response) - if len(hookParts) == 0 { - return userParts, overridden, nil - } - combined := make([]codersdk.ChatMessagePart, 0, len(userParts)+len(hookParts)) - combined = append(combined, userParts...) - combined = append(combined, hookParts...) - return combined, overridden, nil -} diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go index 4e592640574..56c4c2d1d35 100644 --- a/coderd/x/chatd/hooks_internal_test.go +++ b/coderd/x/chatd/hooks_internal_test.go @@ -11,6 +11,7 @@ import ( "charm.land/fantasy" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog/v3/sloggers/slogtest" @@ -59,7 +60,7 @@ func TestSessionStartDispatchSources(t *testing.T) { "test-version", prometheus.NewRegistry(), ) - server := &Server{hookDispatcher: dispatcher} + server := &Server{hooks: newHookTrigger(dispatcher)} user := dbgen.User(t, db, database.User{}) org := dbgen.Organization(t, db, database.Organization{}) model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) @@ -67,9 +68,9 @@ func TestSessionStartDispatchSources(t *testing.T) { turnID := uuid.New() ctx := testutil.Context(t, testutil.WaitLong) - _, err := server.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSource(nil)}) + _, err := server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource(nil)}, agenthooks.EventSessionStart) require.NoError(t, err) - _, err = server.dispatchLifecycleHook(ctx, chat, &turnID, agenthooks.EventSessionStart, agenthooks.SessionStartData{Source: sessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}) + _, err = server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, agenthooks.EventSessionStart) require.NoError(t, err) startup := <-receivedCh @@ -127,7 +128,7 @@ func TestSessionStartDispatchFailureFinishesGeneration(t *testing.T) { prometheus.NewRegistry(), ) starter := newTestTaskStarter(t, f, newTaskSideEffectRecorder()) - starter.server.hookDispatcher = dispatcher + starter.server.hooks = newHookTrigger(dispatcher) ctx := testutil.Context(t, testutil.WaitLong) debugTurn := newRunnerDebugTurn(ctx, starter.opts.Logger) defer debugTurn.Finalize(ctx) @@ -172,7 +173,7 @@ func TestApplySessionStartResponse(t *testing.T) { chatstate.NewChatMachine(f.db, f.pubsub, chat.ID), input, chat, - agenthooks.Response{ + &hookResult{ ModelContext: "model context", UserMessage: "user notice", }, @@ -201,7 +202,7 @@ func TestApplySessionStartResponseNoOp(t *testing.T) { chatstate.NewChatMachine(f.db, f.pubsub, chat.ID), chatWorkerTaskStartInput{}, chat, - agenthooks.Response{}, + nil, ) require.NoError(t, err) require.Equal(t, chat.SnapshotVersion, result.Chat.SnapshotVersion) @@ -229,6 +230,131 @@ func TestRejectDuplicateToolUseIDs(t *testing.T) { }), "duplicate tool use ID") } +func newTestHookTrigger(t *testing.T, handler http.Handler) *hookTrigger { + t.Helper() + consumer := httptest.NewServer(handler) + t.Cleanup(consumer.Close) + return newHookTrigger(chathooks.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + "test-hook-secret-32-bytes-minimum!!", + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + )) +} + +func TestHookTriggerDisabled(t *testing.T) { + t.Parallel() + + for name, trigger := range map[string]*hookTrigger{ + "NilTrigger": nil, + "NilDispatcher": newHookTrigger(nil), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.False(t, trigger.enabled()) + result, err := trigger.trigger(t.Context(), hookChat{ID: uuid.New()}, hookMessage{}, agenthooks.EventStop) + require.NoError(t, err) + require.Empty(t, result.modelContext()) + require.Empty(t, result.userMessage()) + require.Empty(t, result.InputOverride) + }) + } +} + +func TestHookTriggerDeny(t *testing.T) { + t.Parallel() + + trigger := newTestHookTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{ + "permission": {"decision": "deny", "reason": "policy"}, + "model_context": "try another tool", + "user_message": "blocked by policy" + }`)) + assert.NoError(t, err) + })) + ctx := testutil.Context(t, testutil.WaitShort) + result, err := trigger.trigger(ctx, hookChat{ID: uuid.New(), OwnerID: uuid.New()}, hookMessage{ + ToolUseID: "call_1", + ToolName: "execute", + ToolInput: json.RawMessage(`{}`), + }, agenthooks.EventPreToolUse) + require.Nil(t, result) + var denied *hookDeniedError + require.ErrorAs(t, err, &denied) + require.Equal(t, agenthooks.EventPreToolUse, denied.Event) + require.Equal(t, "policy", denied.Reason) + require.Equal(t, "try another tool", denied.ModelContext) + require.Equal(t, "blocked by policy", denied.UserMessage) +} + +func TestHookTriggerEventPayloads(t *testing.T) { + t.Parallel() + + requests := make(chan agenthooks.Request, 1) + trigger := newTestHookTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request agenthooks.Request + assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + requests <- request + _, err := w.Write([]byte(`{}`)) + assert.NoError(t, err) + })) + chat := hookChat{ + ID: uuid.New(), + OwnerID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + ctx := testutil.Context(t, testutil.WaitShort) + dispatch := func(t *testing.T, msg hookMessage, event agenthooks.EventType) agenthooks.Request { + t.Helper() + _, err := trigger.trigger(ctx, chat, msg, event) + require.NoError(t, err) + request := <-requests + require.Equal(t, event, request.Type) + require.Equal(t, chat.ID, request.Meta.ChatID) + require.Equal(t, chat.OwnerID, request.Meta.OwnerID) + require.NotNil(t, request.Meta.WorkspaceID) + require.Equal(t, chat.WorkspaceID.UUID, *request.Meta.WorkspaceID) + return request + } + + sessionStart := dispatch(t, hookMessage{Source: sessionStartSourceClear}, agenthooks.EventSessionStart) + var sessionStartData agenthooks.SessionStartData + require.NoError(t, json.Unmarshal(sessionStart.Data, &sessionStartData)) + require.Equal(t, sessionStartSourceClear, sessionStartData.Source) + + prompt := dispatch(t, hookMessage{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, agenthooks.EventUserPromptSubmit) + var promptData agenthooks.UserPromptSubmitData + require.NoError(t, json.Unmarshal(prompt.Data, &promptData)) + require.Equal(t, "hello", promptData.Prompt) + require.JSONEq(t, `[{"type":"text","text":"hello"}]`, string(promptData.Parts)) + + preToolUse := dispatch(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, agenthooks.EventPreToolUse) + var preToolUseData agenthooks.PreToolUseData + require.NoError(t, json.Unmarshal(preToolUse.Data, &preToolUseData)) + require.Equal(t, "call_1", preToolUseData.ToolUseID) + require.Equal(t, "execute", preToolUseData.ToolName) + require.JSONEq(t, `{"cmd":"ls"}`, string(preToolUseData.ToolInput)) + + postToolUse := dispatch(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, agenthooks.EventPostToolUse) + var postToolUseData agenthooks.PostToolUseData + require.NoError(t, json.Unmarshal(postToolUse.Data, &postToolUseData)) + require.Equal(t, "call_1", postToolUseData.ToolUseID) + require.Equal(t, "execute", postToolUseData.ToolName) + require.JSONEq(t, `{"ok":true}`, string(postToolUseData.ToolResponse)) + require.Equal(t, "boom", postToolUseData.ToolError) + + for _, event := range []agenthooks.EventType{agenthooks.EventPreCompact, agenthooks.EventPostCompact, agenthooks.EventStop} { + dispatch(t, hookMessage{}, event) + } + + _, err := trigger.trigger(ctx, chat, hookMessage{}, agenthooks.EventType("bogus")) + require.ErrorContains(t, err, "unsupported hook event") +} + func TestRestoreToolCallOrder(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 4d72bc60373..1ab0bce56b6 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -1288,20 +1288,25 @@ func (p *Server) createChildSubagentChatWithOptions( // Review before persistence so spawned chats cannot bypass prompt policy. childChatID := uuid.New() - var hookResponse agenthooks.Response - if p.hookDispatcher.Enabled() { + var promptResult *hookResult + if p.hooks.enabled() { mintedTurnID := uuid.New() - hookChat := database.Chat{} - hookChat.ID = childChatID - hookChat.OwnerID = parent.OwnerID - hookChat.WorkspaceID = parent.WorkspaceID - hookChat.ParentChatID = uuid.NullUUID{UUID: parent.ID, Valid: true} - hookChat.RootChatID = uuid.NullUUID{UUID: rootChatID, Valid: true} - hookResponse, err = p.dispatchUserPromptSubmit(ctx, hookChat, mintedTurnID, []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}) + promptMessage, err := userPromptHookMessage([]codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}) if err != nil { return database.Chat{}, err } - override, overridden, overrideErr := userPromptOverride(hookResponse) + promptResult, err = p.hooks.trigger(ctx, hookChat{ + ID: childChatID, + OwnerID: parent.OwnerID, + WorkspaceID: parent.WorkspaceID, + ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, + RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}, + TurnID: &mintedTurnID, + }, promptMessage, agenthooks.EventUserPromptSubmit) + if err != nil { + return database.Chat{}, userPromptDenial(err) + } + override, overridden, overrideErr := userPromptOverride(promptResult) if overrideErr != nil { return database.Chat{}, overrideErr } @@ -1325,7 +1330,7 @@ func (p *Server) createChildSubagentChatWithOptions( return database.Chat{}, xerrors.Errorf("marshal workspace awareness: %w", err) } childUserParts := []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)} - childUserParts = append(childUserParts, userPromptHookParts(hookResponse)...) + childUserParts = append(childUserParts, userPromptHookParts(promptResult)...) userContent, err := chatprompt.MarshalParts(childUserParts) if err != nil { return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", err) diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 827ed62efd8..3b5292017e5 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -293,7 +293,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { server := &Server{ db: db, logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - hookDispatcher: chathooks.New( + hooks: newHookTrigger(chathooks.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -302,7 +302,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { "test-deployment", "test-version", prometheus.NewRegistry(), - ), + )), } return ctx, db, parent, server } From 92f52759a12fff0ef95381ee7a26a6a6850c80aa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:07:36 +0000 Subject: [PATCH 33/86] refactor(coderd/x/chatd): remove redundant hook comment --- coderd/x/chatd/hook_trigger.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/x/chatd/hook_trigger.go b/coderd/x/chatd/hook_trigger.go index c026e706bfe..5a22867b3ea 100644 --- a/coderd/x/chatd/hook_trigger.go +++ b/coderd/x/chatd/hook_trigger.go @@ -85,8 +85,6 @@ func (c hookChat) ref() agenthooks.ChatRef { return ref } -// hookMessage carries the message details for an event. Each event -// reads the fields relevant to it and ignores the rest. type hookMessage struct { // Source is the session_start trigger: startup, resume, or clear. Source string From d82370ba2fa08f17881127cf84d536b5ebf211d9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:16:59 +0000 Subject: [PATCH 34/86] style(coderd/x/chatd): trim redundant hook comments --- coderd/x/chatd/hook_errors.go | 2 -- coderd/x/chatd/hook_tooluse.go | 6 ++---- coderd/x/chatd/hook_trigger.go | 11 +++-------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/coderd/x/chatd/hook_errors.go b/coderd/x/chatd/hook_errors.go index 16bf25b2fa8..a9c714d0091 100644 --- a/coderd/x/chatd/hook_errors.go +++ b/coderd/x/chatd/hook_errors.go @@ -52,8 +52,6 @@ func (e *UserPromptDeniedError) Error() string { return "user prompt denied by lifecycle hook: " + e.UserMessage } -// userPromptDenial maps a hook denial to the exported error consumed -// by the API handlers; every other error passes through unchanged. func userPromptDenial(err error) error { var denied *hookDeniedError if errors.As(err, &denied) { diff --git a/coderd/x/chatd/hook_tooluse.go b/coderd/x/chatd/hook_tooluse.go index 9d81c1dbb65..f1351d0f4ed 100644 --- a/coderd/x/chatd/hook_tooluse.go +++ b/coderd/x/chatd/hook_tooluse.go @@ -35,10 +35,8 @@ func rejectDuplicateToolUseIDs(toolCalls []fantasy.ToolCallContent) error { return nil } -// preToolUseExecutionResult partitions a step's tool calls by the -// pre_tool_use decisions: Allowed calls run (with overridden inputs -// applied), Denied calls become synthetic results, and Results carries -// the per-call injected content for the transcript in call order. +// preToolUseExecutionResult preserves hook results in tool-call order for +// transcript injection. type preToolUseExecutionResult struct { Allowed []fantasy.ToolCallContent Denied []fantasy.ToolResultContent diff --git a/coderd/x/chatd/hook_trigger.go b/coderd/x/chatd/hook_trigger.go index 5a22867b3ea..a17a8c9191a 100644 --- a/coderd/x/chatd/hook_trigger.go +++ b/coderd/x/chatd/hook_trigger.go @@ -86,14 +86,9 @@ func (c hookChat) ref() agenthooks.ChatRef { } type hookMessage struct { - // Source is the session_start trigger: startup, resume, or clear. - Source string - // Prompt and Parts describe the user_prompt_submit submission. - Prompt string - Parts json.RawMessage - // ToolUseID and ToolName identify pre_tool_use and post_tool_use - // calls; ToolInput rides on pre_tool_use, ToolResponse and - // ToolError on post_tool_use. + Source string + Prompt string + Parts json.RawMessage ToolUseID string ToolName string ToolInput json.RawMessage From 953caff41ee12e9ede2a36cc94c6572ec9ac64ed Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:40:40 +0000 Subject: [PATCH 35/86] style: remove redundant hook comments --- coderd/x/chatd/chatprompt/chatprompt_test.go | 6 ------ coderd/x/chatd/compaction_hooks_test.go | 1 - coderd/x/chatd/hook_effects.go | 8 ++------ coderd/x/chatd/hook_errors.go | 4 ++-- coderd/x/chatd/hook_tooluse.go | 2 -- coderd/x/chatd/hook_trigger.go | 2 -- coderd/x/chatd/hooks_test.go | 6 ------ coderd/x/chatd/subagent.go | 4 ++-- .../AgentsPage/components/ChatConversation/streamState.ts | 4 ++-- 9 files changed, 8 insertions(+), 29 deletions(-) diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index 6599c18a546..8529356babe 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -927,10 +927,6 @@ func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) { } } -// TestInjectMissingToolResults_HookContextBetweenCallAndResult -// verifies that a model-visible hook context row persisted between an -// assistant tool call and its result rows does not break tool-result -// adjacency or trigger a synthetic interrupted result. func TestInjectMissingToolResults_HookContextBetweenCallAndResult(t *testing.T) { t.Parallel() @@ -956,8 +952,6 @@ func TestInjectMissingToolResults_HookContextBetweenCallAndResult(t *testing.T) {Role: database.ChatMessageRoleTool, Visibility: database.ChatMessageVisibilityBoth, Content: result}, }) - // The result is hoisted next to the call and the hook context - // follows it. require.Len(t, prompt, 3) require.Equal(t, fantasy.MessageRoleAssistant, prompt[0].Role) require.Equal(t, fantasy.MessageRoleTool, prompt[1].Role) diff --git a/coderd/x/chatd/compaction_hooks_test.go b/coderd/x/chatd/compaction_hooks_test.go index 6bf3764f919..de5f10cd447 100644 --- a/coderd/x/chatd/compaction_hooks_test.go +++ b/coderd/x/chatd/compaction_hooks_test.go @@ -104,7 +104,6 @@ func TestPostCompactHookFailureKeepsCompaction(t *testing.T) { ) waitCtx := testutil.Context(t, testutil.WaitLong) failed := waitForChatStatus(waitCtx, t, fixture.db, fixture.chat.ID, database.ChatStatusError) - // The hook error commits atomically with the compaction step. require.False(t, postSawCommitted.Load()) require.Equal(t, int32(1), fixture.compactionCalls.Load()) require.True(t, hasCompactionRows(t, fixture.db, fixture.chat.ID)) diff --git a/coderd/x/chatd/hook_effects.go b/coderd/x/chatd/hook_effects.go index c9449a3e6ba..fc335550b60 100644 --- a/coderd/x/chatd/hook_effects.go +++ b/coderd/x/chatd/hook_effects.go @@ -123,9 +123,8 @@ func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext str } } -// restoreToolCallOrder reorders tool results to match the assistant's -// call order because providers pair results with calls positionally. -// Entries that are not tool results for the given calls keep their slots. +// restoreToolCallOrder reorders known tool results to match the assistant's +// call order while preserving slots for unrelated entries. func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallContent) { position := make(map[string]int, len(calls)) for index, call := range calls { @@ -173,9 +172,6 @@ func userPromptOverride(result *hookResult) (string, bool, error) { return *override.Prompt, true, nil } -// userPromptHookParts converts a user_prompt_submit result into the -// typed parts carried inside the submitted message: hook-context is -// model-only steering, hook-notice is a client-only notice. func userPromptHookParts(result *hookResult) []codersdk.ChatMessagePart { parts := make([]codersdk.ChatMessagePart, 0, 2) if result.modelContext() != "" { diff --git a/coderd/x/chatd/hook_errors.go b/coderd/x/chatd/hook_errors.go index a9c714d0091..df15d90dd08 100644 --- a/coderd/x/chatd/hook_errors.go +++ b/coderd/x/chatd/hook_errors.go @@ -43,8 +43,8 @@ type UserPromptDeniedError struct { } // Error includes UserMessage so callers that only surface the error -// string, such as subagent tool responses, still expose the hook's -// reason. The HTTP handlers unwrap the typed error instead. +// string, such as subagent tool responses, still expose the user-facing +// denial message. The HTTP handlers unwrap the typed error instead. func (e *UserPromptDeniedError) Error() string { if e.UserMessage == "" { return "user prompt denied by lifecycle hook" diff --git a/coderd/x/chatd/hook_tooluse.go b/coderd/x/chatd/hook_tooluse.go index f1351d0f4ed..90bce9ba8c3 100644 --- a/coderd/x/chatd/hook_tooluse.go +++ b/coderd/x/chatd/hook_tooluse.go @@ -122,8 +122,6 @@ func (t *hookTrigger) postToolUseResults( return nil, nil } results := make([]*hookResult, 0, len(content)) - // Dispatch every completed non-provider-executed tool result. - // Preserve only the first failure. var firstErr error for _, block := range content { toolResult, ok := asToolResultContent(block) diff --git a/coderd/x/chatd/hook_trigger.go b/coderd/x/chatd/hook_trigger.go index a17a8c9191a..58196ce6f7e 100644 --- a/coderd/x/chatd/hook_trigger.go +++ b/coderd/x/chatd/hook_trigger.go @@ -133,8 +133,6 @@ func (r *hookResult) userMessage() string { return r.UserMessage } -// trigger dispatches one lifecycle event. A disabled dispatcher returns -// an empty result; interpretation of the result stays with the caller. func (t *hookTrigger) trigger( ctx context.Context, chat hookChat, diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index bd2d56fbd0a..d9c75fbe6ba 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -327,9 +327,6 @@ func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { } } -// A user_prompt_submit dispatch failure during subagent spawn admission -// must fail the parent turn closed instead of committing a tool error -// the model can ignore. func TestSubagentSpawnHookDispatchFailureFailsTurn(t *testing.T) { t.Parallel() @@ -377,14 +374,11 @@ func TestSubagentSpawnHookDispatchFailureFailsTurn(t *testing.T) { failed := waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusError) require.Contains(t, chatLastErrorMessage(failed.LastError), "hook dispatch failed: user_prompt_submit: http_error") - // The assistant step with the tool call committed, but no tool - // result was persisted for the failed spawn. messages := chatMessages(ctx, t, db, chat.ID) require.Len(t, messages, 2) require.Equal(t, database.ChatMessageRoleUser, messages[0].Role) require.Equal(t, database.ChatMessageRoleAssistant, messages[1].Role) - // The rejected child chat must not exist. chats, err := db.GetChats(ctx, database.GetChatsParams{ OwnedOnly: true, ViewerID: user.ID, diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 1ab0bce56b6..f3b80beb527 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -777,8 +777,8 @@ func (p *Server) subagentTools( if errors.As(err, &hookErr) { return fantasy.ToolResponse{}, err } - // UserPromptDeniedError.Error() carries the hook's - // reason, so the model can adjust its prompt. + // UserPromptDeniedError.Error() carries the user-facing + // denial message, so the model can adjust its prompt. return fantasy.NewTextErrorResponse(err.Error()), nil } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index 9cd21d68dda..ec266aadc41 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -211,8 +211,8 @@ export const applyMessagePartToStreamState = ( // skill parts are metadata-only; no streaming render // needed. case "skill": - // hook-notice parts only appear in persisted user messages, - // never via SSE streaming. + // Hook notices may arrive in durable message events, but not in + // streaming part deltas. case "hook-notice": return prev; default: { From b7627beb6cae7095a750076a0409f69b5ef73911 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:31:39 +0000 Subject: [PATCH 36/86] refactor: adopt coderd/x/hooks import paths Rewrite chatd, coderd, and docs references from codersdk/agenthooks and coderd/x/chathooks to coderd/x/hooks and coderd/x/hooks/dispatch, including the dispatch.Error and dispatch.Result renames. --- coderd/coderd.go | 6 +- coderd/exp_chats.go | 12 ++-- coderd/exp_chats_hooks_test.go | 90 ++++++++++++------------ coderd/x/chatd/chatd.go | 20 +++--- coderd/x/chatd/compaction_hooks_test.go | 24 +++---- coderd/x/chatd/create_hooks_test.go | 18 ++--- coderd/x/chatd/generation.go | 44 ++++++------ coderd/x/chatd/hook_errors.go | 18 ++--- coderd/x/chatd/hook_tooluse.go | 6 +- coderd/x/chatd/hook_trigger.go | 48 ++++++------- coderd/x/chatd/hooks_internal_test.go | 64 ++++++++--------- coderd/x/chatd/hooks_test.go | 66 ++++++++--------- coderd/x/chatd/post_tool_use_test.go | 38 +++++----- coderd/x/chatd/pre_tool_use_test.go | 36 +++++----- coderd/x/chatd/stop_test.go | 6 +- coderd/x/chatd/subagent.go | 8 +-- coderd/x/chatd/subagent_internal_test.go | 6 +- docs/admin/setup/chat-lifecycle-hooks.md | 8 +-- 18 files changed, 259 insertions(+), 259 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index b53bd7298cd..07bf4031834 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -100,8 +100,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" - "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/coderd/x/gitsync" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/codersdk/healthsdk" @@ -879,7 +879,7 @@ func New(options *Options) *API { // the chat daemon stays nil and chat HTTP handlers return a // service-unavailable error with a clear remediation message. if options.DeploymentValues.AI.BridgeConfig.Enabled.Value() { - var hookDispatcher *chathooks.Dispatcher + var hookDispatcher *dispatch.Dispatcher chatConfig := options.DeploymentValues.AI.Chat hooksConfigured := chatConfig.HookURL.String() != "" && chatConfig.HookEnabled.Value() hooksExperimentEnabled := experiments.Enabled(codersdk.ExperimentAgentLifecycleHooks) @@ -889,7 +889,7 @@ func New(options *Options) *API { ) } if hooksConfigured && hooksExperimentEnabled { - hookDispatcher = chathooks.New( + hookDispatcher = dispatch.New( options.Logger, nil, chatConfig.HookURL.String(), diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 4e01e83ecd7..8cdab0ef63b 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -55,8 +55,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatfiles" - "github.com/coder/coder/v2/coderd/x/chathooks" "github.com/coder/coder/v2/coderd/x/gitsync" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/websocket" @@ -112,7 +112,7 @@ func writeChatUsageLimitExceeded( } // Avoid returning raw dispatch errors, which may expose deployment internals. -func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, hookErr *chathooks.DispatchError) { +func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, hookErr *dispatch.Error) { httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.ChatHookDispatchFailedResponse{ Response: codersdk.Response{ Message: "Chat lifecycle hook dispatch failed.", @@ -1462,7 +1462,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) return } - var hookErr *chathooks.DispatchError + var hookErr *dispatch.Error if errors.As(err, &hookErr) { writeChatHookDispatchFailed(ctx, rw, hookErr) return @@ -3428,7 +3428,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) return } - var hookErr *chathooks.DispatchError + var hookErr *dispatch.Error if errors.As(sendErr, &hookErr) { writeChatHookDispatchFailed(ctx, rw, hookErr) return @@ -3629,7 +3629,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) return } - var hookErr *chathooks.DispatchError + var hookErr *dispatch.Error if errors.As(editErr, &hookErr) { writeChatHookDispatchFailed(ctx, rw, hookErr) return @@ -8271,7 +8271,7 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) { if err != nil { var validationErr *chatd.ToolResultValidationError var conflictErr *chatd.ToolResultStatusConflictError - var hookErr *chathooks.DispatchError + var hookErr *dispatch.Error switch { case errors.As(err, &hookErr): writeChatHookDispatchFailed(ctx, rw, hookErr) diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index 2828569e53e..a0ae82a1550 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -19,8 +19,8 @@ import ( "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/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" ) @@ -51,9 +51,9 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - requests := make(chan agenthooks.Request, 2) + requests := make(chan hooks.Request, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) requests <- request w.WriteHeader(test.statusCode) @@ -89,7 +89,7 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) { require.Equal(t, test.wantMessage, sdkErr.Message) } request := testutil.RequireReceive(ctx, t, requests) - require.Equal(t, agenthooks.EventUserPromptSubmit, request.Type) + require.Equal(t, hooks.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) @@ -139,9 +139,9 @@ func TestChatPromptHookContextHiddenFromAPI(t *testing.T) { t.Parallel() const secret = "test-hook-secret-32-bytes-minimum!!" - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ - UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { - return agenthooks.Response{ + consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ + UserPromptSubmit: func(context.Context, hooks.Meta, hooks.UserPromptSubmitData) (hooks.Response, error) { + return hooks.Response{ ModelContext: "prompt context", UserMessage: "prompt notice", }, nil @@ -213,46 +213,46 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { } }) - hookEvents := make(chan agenthooks.EventType, 16) - recordHook := func(event agenthooks.EventType) { + hookEvents := make(chan hooks.EventType, 16) + recordHook := func(event hooks.EventType) { hookEvents <- event } - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ - SessionStart: func(context.Context, agenthooks.Meta, agenthooks.SessionStartData) (agenthooks.Response, error) { - recordHook(agenthooks.EventSessionStart) - return agenthooks.Response{}, nil + consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ + SessionStart: func(context.Context, hooks.Meta, hooks.SessionStartData) (hooks.Response, error) { + recordHook(hooks.EventSessionStart) + return hooks.Response{}, nil }, - UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { - recordHook(agenthooks.EventUserPromptSubmit) - return agenthooks.Response{}, nil + UserPromptSubmit: func(context.Context, hooks.Meta, hooks.UserPromptSubmitData) (hooks.Response, error) { + recordHook(hooks.EventUserPromptSubmit) + return hooks.Response{}, nil }, - PreToolUse: func(_ context.Context, _ agenthooks.Meta, tool agenthooks.PreToolUseData) (agenthooks.Response, error) { - recordHook(agenthooks.EventPreToolUse) + PreToolUse: func(_ context.Context, _ hooks.Meta, tool hooks.PreToolUseData) (hooks.Response, error) { + recordHook(hooks.EventPreToolUse) switch tool.ToolUseID { case deniedToolCallID: - return agenthooks.Response{Permission: &agenthooks.Permission{ - Decision: agenthooks.PermissionDeny, + return hooks.Response{Permission: &hooks.Permission{ + Decision: hooks.PermissionDeny, Reason: "secret reads are blocked", }}, nil case allowedToolCallID: - return agenthooks.Response{Permission: &agenthooks.Permission{ - Decision: agenthooks.PermissionAllow, + return hooks.Response{Permission: &hooks.Permission{ + Decision: hooks.PermissionAllow, InputOverride: json.RawMessage(`{"query":"public documentation"}`), }}, nil default: - return agenthooks.Response{}, nil + return hooks.Response{}, nil } }, - PostToolUse: func(context.Context, agenthooks.Meta, agenthooks.PostToolUseData) (agenthooks.Response, error) { - recordHook(agenthooks.EventPostToolUse) - return agenthooks.Response{ + PostToolUse: func(context.Context, hooks.Meta, hooks.PostToolUseData) (hooks.Response, error) { + recordHook(hooks.EventPostToolUse) + return hooks.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 + Stop: func(context.Context, hooks.Meta, hooks.StopData) (hooks.Response, error) { + recordHook(hooks.EventStop) + return hooks.Response{}, nil }, })) t.Cleanup(consumer.Close) @@ -339,24 +339,24 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { } require.True(t, foundPostToolNotice) - var seenEvents []agenthooks.EventType + var seenEvents []hooks.EventType for { event := testutil.RequireReceive(ctx, t, hookEvents) seenEvents = append(seenEvents, event) - if event == agenthooks.EventStop { + if event == hooks.EventStop { break } } - require.Contains(t, seenEvents, agenthooks.EventUserPromptSubmit) - require.Contains(t, seenEvents, agenthooks.EventSessionStart) + require.Contains(t, seenEvents, hooks.EventUserPromptSubmit) + require.Contains(t, seenEvents, hooks.EventSessionStart) var preToolUseEvents int for _, event := range seenEvents { - if event == agenthooks.EventPreToolUse { + if event == hooks.EventPreToolUse { preToolUseEvents++ } } require.GreaterOrEqual(t, preToolUseEvents, 2) - require.Contains(t, seenEvents, agenthooks.EventPostToolUse) + require.Contains(t, seenEvents, hooks.EventPostToolUse) } func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { @@ -370,15 +370,15 @@ func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { } return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) }) - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ - UserPromptSubmit: func(_ context.Context, _ agenthooks.Meta, data agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { + consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ + UserPromptSubmit: func(_ context.Context, _ hooks.Meta, data hooks.UserPromptSubmitData) (hooks.Response, error) { if strings.Contains(data.Prompt, "REDACTME") { - return agenthooks.Response{Permission: &agenthooks.Permission{ - Decision: agenthooks.PermissionAllow, + return hooks.Response{Permission: &hooks.Permission{ + Decision: hooks.PermissionAllow, InputOverride: json.RawMessage(`{"prompt":"redacted"}`), }}, nil } - return agenthooks.Response{}, nil + return hooks.Response{}, nil }, })) t.Cleanup(consumer.Close) @@ -457,12 +457,12 @@ func TestChatHookNoticeMessagesInResponses(t *testing.T) { return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) }) - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ - SessionStart: func(context.Context, agenthooks.Meta, agenthooks.SessionStartData) (agenthooks.Response, error) { - return agenthooks.Response{UserMessage: "session notice"}, nil + consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ + SessionStart: func(context.Context, hooks.Meta, hooks.SessionStartData) (hooks.Response, error) { + return hooks.Response{UserMessage: "session notice"}, nil }, - UserPromptSubmit: func(_ context.Context, _ agenthooks.Meta, data agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { - response := agenthooks.Response{UserMessage: "prompt notice"} + UserPromptSubmit: func(_ context.Context, _ hooks.Meta, data hooks.UserPromptSubmitData) (hooks.Response, error) { + response := hooks.Response{UserMessage: "prompt notice"} if data.Prompt == "edited prompt" { response.ModelContext = "prompt context" } diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ca2780abdee..c6365ca5e4b 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -51,10 +51,10 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" skillspkg "github.com/coder/coder/v2/coderd/x/skills" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/quartz" ) @@ -1333,7 +1333,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C OwnerID: opts.OwnerID, WorkspaceID: opts.WorkspaceID, TurnID: &turnID, - }, promptMessage, agenthooks.EventUserPromptSubmit) + }, promptMessage, hooks.EventUserPromptSubmit) if err != nil { return database.Chat{}, userPromptDenial(err) } @@ -1485,7 +1485,7 @@ func (p *Server) SendMessage( if err != nil { return SendMessageResult{}, err } - promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, agenthooks.EventUserPromptSubmit) + promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) if err != nil { return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, userPromptDenial(err)) } @@ -1808,15 +1808,15 @@ func (p *Server) EditMessage( if _, err := validateModelConfigOverride(ctx, p.db, opts.ModelConfigID); err != nil { return EditMessageResult{}, err } - sessionStartHookResult, err = p.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSourceClear}, agenthooks.EventSessionStart) + sessionStartHookResult, err = p.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSourceClear}, hooks.EventSessionStart) if err != nil { - return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, agenthooks.EventSessionStart, err) + return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, hooks.EventSessionStart, err) } promptMessage, err := userPromptHookMessage(contentParts) if err != nil { return EditMessageResult{}, err } - promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, agenthooks.EventUserPromptSubmit) + promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) if err != nil { return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, userPromptDenial(err)) } @@ -2187,10 +2187,10 @@ func (p *Server) SubmitToolResults( return err } for _, result := range opts.Results { - response, err := p.hooks.trigger(ctx, hookChatFor(state.chat, nil), dynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), agenthooks.EventPostToolUse) + response, err := p.hooks.trigger(ctx, hookChatFor(state.chat, nil), dynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), hooks.EventPostToolUse) if err != nil { // Leave pending calls intact so the client can resubmit after recovery. - return generationHookDispatchError(agenthooks.EventPostToolUse, err) + return generationHookDispatchError(hooks.EventPostToolUse, err) } responseMessages, err := hookEventMessages(response, state.modelConfigID) if err != nil { @@ -3105,7 +3105,7 @@ type Config struct { AllowBYOKSet bool AlwaysEnableDebugLogs bool WebpushDispatcher webpush.Dispatcher - HookDispatcher *chathooks.Dispatcher + HookDispatcher *dispatch.Dispatcher UsageTracker *workspacestats.UsageTracker Clock quartz.Clock AIBridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] diff --git a/coderd/x/chatd/compaction_hooks_test.go b/coderd/x/chatd/compaction_hooks_test.go index de5f10cd447..819b3617440 100644 --- a/coderd/x/chatd/compaction_hooks_test.go +++ b/coderd/x/chatd/compaction_hooks_test.go @@ -18,8 +18,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" "github.com/coder/coder/v2/testutil" @@ -30,11 +30,11 @@ func TestCompactionHooksHintAndPostCommitResponses(t *testing.T) { var postSawCommitted atomic.Bool fixture := startCompactionHookChat(t, - func(t *testing.T, db database.Store, request agenthooks.Request) (int, string) { + func(t *testing.T, db database.Store, request hooks.Request) (int, string) { switch request.Type { - case agenthooks.EventPreCompact: + case hooks.EventPreCompact: return http.StatusOK, `{"model_context":"preserve deployment constraints","user_message":"compaction starting"}` - case agenthooks.EventPostCompact: + case hooks.EventPostCompact: postSawCommitted.Store(hasCompactionRows(t, db, request.Meta.ChatID)) return http.StatusOK, `{"model_context":"post compact context","user_message":"compaction complete"}` default: @@ -68,8 +68,8 @@ func TestPreCompactHookFailureAbortsCompaction(t *testing.T) { t.Parallel() fixture := startCompactionHookChat(t, - func(_ *testing.T, _ database.Store, request agenthooks.Request) (int, string) { - if request.Type == agenthooks.EventPreCompact { + func(_ *testing.T, _ database.Store, request hooks.Request) (int, string) { + if request.Type == hooks.EventPreCompact { return http.StatusInternalServerError, "" } return http.StatusOK, `{}` @@ -93,8 +93,8 @@ func TestPostCompactHookFailureKeepsCompaction(t *testing.T) { var postSawCommitted atomic.Bool fixture := startCompactionHookChat(t, - func(t *testing.T, db database.Store, request agenthooks.Request) (int, string) { - if request.Type == agenthooks.EventPostCompact { + func(t *testing.T, db database.Store, request hooks.Request) (int, string) { + if request.Type == hooks.EventPostCompact { postSawCommitted.Store(hasCompactionRows(t, db, request.Meta.ChatID)) return http.StatusInternalServerError, "" } @@ -124,7 +124,7 @@ type compactionHookFixture struct { func startCompactionHookChat( t *testing.T, - hookResponse func(*testing.T, database.Store, agenthooks.Request) (int, string), + hookResponse func(*testing.T, database.Store, hooks.Request) (int, string), inspectCompaction func(*testing.T, string), ) compactionHookFixture { t.Helper() @@ -161,12 +161,12 @@ func startCompactionHookChat( model = updateChatModelCompressionThreshold(t, db, model, contextLimit, thresholdPercent) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) switch request.Type { - case agenthooks.EventPreCompact: + case hooks.EventPreCompact: preCompactCalls.Add(1) - case agenthooks.EventPostCompact: + case hooks.EventPostCompact: postCompactCalls.Add(1) } status, body := hookResponse(t, db, request) diff --git a/coderd/x/chatd/create_hooks_test.go b/coderd/x/chatd/create_hooks_test.go index 8fb5d0c058b..15a004afdfb 100644 --- a/coderd/x/chatd/create_hooks_test.go +++ b/coderd/x/chatd/create_hooks_test.go @@ -16,9 +16,9 @@ import ( dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -35,11 +35,11 @@ func TestCreateChatUserPromptSubmitHook(t *testing.T) { chat, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "passthrough")) require.NoError(t, err) request := testutil.RequireReceive(ctx, t, requests) - require.Equal(t, agenthooks.EventUserPromptSubmit, request.Type) + require.Equal(t, hooks.EventUserPromptSubmit, request.Type) require.Equal(t, chat.ID, request.Meta.ChatID) require.Equal(t, user.ID, request.Meta.OwnerID) require.NotNil(t, request.Meta.TurnID) - data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) + data := decodeHookData[hooks.UserPromptSubmitData](t, request) require.Equal(t, "passthrough", data.Prompt) var hookParts []codersdk.ChatMessagePart require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) @@ -165,9 +165,9 @@ func TestCreateChatUserPromptSubmitHook(t *testing.T) { server, requests := newCreateHookTestServer(t, db, ps, http.StatusInternalServerError, "") _, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "prompt")) - var dispatchErr *chathooks.DispatchError + var dispatchErr *dispatch.Error require.ErrorAs(t, err, &dispatchErr) - require.Equal(t, chathooks.ResultHTTPError, dispatchErr.Class) + require.Equal(t, dispatch.ResultHTTPError, dispatchErr.Class) request := testutil.RequireReceive(ctx, t, requests) requireCreateHookChatMissing(ctx, t, db, request.Meta.ChatID) }) @@ -193,11 +193,11 @@ func newCreateHookTestServer( ps dbpubsub.Pubsub, statusCode int, response string, -) (*chatd.Server, <-chan agenthooks.Request) { +) (*chatd.Server, <-chan hooks.Request) { t.Helper() - requests := make(chan agenthooks.Request, 2) + requests := make(chan hooks.Request, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) requests <- request w.WriteHeader(statusCode) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index aeefb4293a4..60056f91f38 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -21,8 +21,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" + "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" ) // generationPrepareInput contains the committed state used to prepare one @@ -391,9 +391,9 @@ func (s *taskStarter) startGenerationSession( // Re-arm the claim until its response is applied so a replacement task // can replay session_start effects. defer func() { complete(completed) }() - response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{Source: sessionStartSource(messages)}, agenthooks.EventSessionStart) + response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{Source: sessionStartSource(messages)}, hooks.EventSessionStart) if err != nil { - return sessionStartResult{}, true, generationHookDispatchError(agenthooks.EventSessionStart, err) + return sessionStartResult{}, true, generationHookDispatchError(hooks.EventSessionStart, err) } result, err = applySessionStartResponse(ctx, machine, input, chat, response) if err != nil { @@ -493,7 +493,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS preflight, err := s.server.hooks.preflightPendingToolCalls(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), toolCalls) if err != nil { cleanup() - return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventPreToolUse, err), generationAttemptNotRequired) + return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(hooks.EventPreToolUse, err), generationAttemptNotRequired) } if len(preflight.Denied) == 0 { cleanup() @@ -809,7 +809,7 @@ func (s *taskStarter) executeLocalTools( ) error { preflight, err := s.server.hooks.preflightPendingToolCalls(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) if err != nil { - return generationHookDispatchError(agenthooks.EventPreToolUse, err) + return generationHookDispatchError(hooks.EventPreToolUse, err) } attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { @@ -847,7 +847,7 @@ func (s *taskStarter) executeLocalTools( // the tool run; its failure surfaces as a tool result error and // must fail the step instead of committing. if hookErr := hookDispatchFailureFromResults(outcome.Step.Content); hookErr != nil { - return generationHookDispatchError(agenthooks.EventUserPromptSubmit, hookErr) + return generationHookDispatchError(hooks.EventUserPromptSubmit, hookErr) } } postResults, postDispatchErr := s.server.hooks.postToolUseResults(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), outcome.Step.Content) @@ -876,7 +876,7 @@ func (s *taskStarter) executeLocalTools( } var postCommitErr error if postDispatchErr != nil { - postCommitErr = generationHookDispatchError(agenthooks.EventPostToolUse, postDispatchErr) + postCommitErr = generationHookDispatchError(hooks.EventPostToolUse, postDispatchErr) } return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ Overrides: preflight.Overrides, @@ -934,9 +934,9 @@ func (s *taskStarter) generateCompaction( overrideModel.modelConfig, ) } - preResult, err := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, agenthooks.EventPreCompact) + preResult, err := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, hooks.EventPreCompact) if err != nil { - return generationHookDispatchError(agenthooks.EventPreCompact, err) + return generationHookDispatchError(hooks.EventPreCompact, err) } compactionOpts.SummaryHint = preResult.modelContext() compactionOpts.PublishMessagePart = attempt.publish @@ -981,10 +981,10 @@ func (s *taskStarter) generateCompaction( // Hook effects and fail-closed errors must commit atomically with // compaction; a separate commit races the runner and can be dropped // on crash. - postResult, postDispatchErr := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, agenthooks.EventPostCompact) + postResult, postDispatchErr := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, hooks.EventPostCompact) var postCommitErr error if postDispatchErr != nil { - postCommitErr = generationHookDispatchError(agenthooks.EventPostCompact, postDispatchErr) + postCommitErr = generationHookDispatchError(hooks.EventPostCompact, postDispatchErr) } else { commitMessages, err = appendHookResultMessages(commitMessages, []*hookResult{postResult}, prepared.ModelConfigID) if err != nil { @@ -1094,19 +1094,19 @@ func (s *taskStarter) commitGenerationStep( attempt int64, kind generationActionKind, messages stepMessagesForCommit, - hooks generationCommitHooks, + commitHooks generationCommitHooks, ) error { if len(messages.Messages) == 0 { - if hooks.PostCommitError != nil { - return s.finishGenerationError(ctx, machine, input, hooks.PostCommitError, requireGenerationAttempt(attempt)) + if commitHooks.PostCommitError != nil { + return s.finishGenerationError(ctx, machine, input, commitHooks.PostCommitError, requireGenerationAttempt(attempt)) } return s.finishGenerationTurn(ctx, machine, input, generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonComplete}, requireGenerationAttempt(attempt)) } - failClosed := hooks.PostCommitError != nil + failClosed := commitHooks.PostCommitError != nil var postCommitLastError pqtype.NullRawMessage var postCommitMessage string - if hooks.PostCommitError != nil { - classified := chaterror.Classify(hooks.PostCommitError) + if commitHooks.PostCommitError != nil { + classified := chaterror.Classify(commitHooks.PostCommitError) s.opts.Logger.Warn(ctx, "chat generation failed", slog.F("chat_id", input.ChatID), slog.F("worker_id", input.WorkerID), @@ -1115,9 +1115,9 @@ func (s *taskStarter) commitGenerationStep( slog.F("provider", classified.Provider), slog.F("status_code", classified.StatusCode), slog.F("retryable", classified.Retryable), - slog.Error(hooks.PostCommitError), + slog.Error(commitHooks.PostCommitError), ) - postCommitLastError, postCommitMessage = generationLastError(hooks.PostCommitError) + postCommitLastError, postCommitMessage = generationLastError(commitHooks.PostCommitError) } var committed database.Chat insertedMessages := []runnerActionMessage{} @@ -1125,7 +1125,7 @@ func (s *taskStarter) commitGenerationStep( if _, err := loadChatForGeneration(ctx, store, input, requireGenerationAttempt(attempt)); err != nil { return xerrors.Errorf("load chat for generation: %w", err) } - if err := replacePersistedToolCallInputs(ctx, tx, input.ChatID, hooks.Overrides); err != nil { + if err := replacePersistedToolCallInputs(ctx, tx, input.ChatID, commitHooks.Overrides); err != nil { return err } commitResult, err := tx.CommitStep(chatstate.CommitStepInput{ @@ -1369,9 +1369,9 @@ func (s *taskStarter) finishGenerationTurn( if err != nil { return normalizeTaskTransitionError(err, "load stop hook state") } - response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{}, agenthooks.EventStop) + response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{}, hooks.EventStop) if err != nil { - return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(agenthooks.EventStop, err), fence) + return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(hooks.EventStop, err), fence) } stopMessages, err := hookEventMessages(response, chat.LastModelConfigID) if err != nil { diff --git a/coderd/x/chatd/hook_errors.go b/coderd/x/chatd/hook_errors.go index df15d90dd08..343bbe52490 100644 --- a/coderd/x/chatd/hook_errors.go +++ b/coderd/x/chatd/hook_errors.go @@ -14,9 +14,9 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" ) // hookDeniedError is trigger's normalized form of a permission deny. @@ -24,7 +24,7 @@ import ( // UserPromptDeniedError, pre_tool_use sites fold it into a synthetic // tool result. type hookDeniedError struct { - Event agenthooks.EventType + Event hooks.EventType Reason string ModelContext string UserMessage string @@ -61,10 +61,10 @@ func userPromptDenial(err error) error { } func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { - return p.handleAPIDispatchError(ctx, chatID, agenthooks.EventUserPromptSubmit, dispatchErr) + return p.handleAPIDispatchError(ctx, chatID, hooks.EventUserPromptSubmit, dispatchErr) } -func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType agenthooks.EventType, dispatchErr error) error { +func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType hooks.EventType, dispatchErr error) error { lastError, ok := hookDispatchErrorMessage(eventType, dispatchErr) if !ok { return dispatchErr @@ -111,8 +111,8 @@ func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, e return dispatchErr } -func hookDispatchErrorMessage(eventType agenthooks.EventType, dispatchErr error) (string, bool) { - var structured *chathooks.DispatchError +func hookDispatchErrorMessage(eventType hooks.EventType, dispatchErr error) (string, bool) { + var structured *dispatch.Error if !errors.As(dispatchErr, &structured) { return "", false } @@ -124,7 +124,7 @@ func hookDispatchErrorMessage(eventType agenthooks.EventType, dispatchErr error) ), true } -func generationHookDispatchError(eventType agenthooks.EventType, dispatchErr error) error { +func generationHookDispatchError(eventType hooks.EventType, dispatchErr error) error { message, ok := hookDispatchErrorMessage(eventType, dispatchErr) if !ok { message = dispatchErr.Error() @@ -156,7 +156,7 @@ func hookDispatchFailureFromResults(content []fantasy.Content) error { resultErr = output.Error } } - var dispatchErr *chathooks.DispatchError + var dispatchErr *dispatch.Error if resultErr != nil && errors.As(resultErr, &dispatchErr) { return resultErr } diff --git a/coderd/x/chatd/hook_tooluse.go b/coderd/x/chatd/hook_tooluse.go index 90bce9ba8c3..5eaa3af8112 100644 --- a/coderd/x/chatd/hook_tooluse.go +++ b/coderd/x/chatd/hook_tooluse.go @@ -14,8 +14,8 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" ) // rejectDuplicateToolUseIDs fails closed because hook consumers key @@ -64,7 +64,7 @@ func (t *hookTrigger) preflightPendingToolCalls( ToolUseID: toolCall.ToolCallID, ToolName: toolCall.ToolName, ToolInput: json.RawMessage(toolCall.Input), - }, agenthooks.EventPreToolUse) + }, hooks.EventPreToolUse) if err != nil { var denied *hookDeniedError if !errors.As(err, &denied) { @@ -135,7 +135,7 @@ func (t *hookTrigger) postToolUseResults( } continue } - result, err := t.trigger(ctx, chat, msg, agenthooks.EventPostToolUse) + result, err := t.trigger(ctx, chat, msg, hooks.EventPostToolUse) if err != nil { if firstErr == nil { firstErr = err diff --git a/coderd/x/chatd/hook_trigger.go b/coderd/x/chatd/hook_trigger.go index 58196ce6f7e..0185779f3aa 100644 --- a/coderd/x/chatd/hook_trigger.go +++ b/coderd/x/chatd/hook_trigger.go @@ -9,9 +9,9 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" ) const ( @@ -33,10 +33,10 @@ func sessionStartSource(messages []database.ChatMessage) string { // Every lifecycle event flows through trigger, which builds the wire // envelope, dispatches, and normalizes the outcome. type hookTrigger struct { - dispatcher *chathooks.Dispatcher + dispatcher *dispatch.Dispatcher } -func newHookTrigger(dispatcher *chathooks.Dispatcher) *hookTrigger { +func newHookTrigger(dispatcher *dispatch.Dispatcher) *hookTrigger { return &hookTrigger{dispatcher: dispatcher} } @@ -67,8 +67,8 @@ func hookChatFor(chat database.Chat, turnID *uuid.UUID) hookChat { } } -func (c hookChat) ref() agenthooks.ChatRef { - ref := agenthooks.ChatRef{ +func (c hookChat) ref() hooks.ChatRef { + ref := hooks.ChatRef{ ChatID: c.ID, OwnerID: c.OwnerID, TurnID: c.TurnID, @@ -137,31 +137,31 @@ func (t *hookTrigger) trigger( ctx context.Context, chat hookChat, msg hookMessage, - event agenthooks.EventType, + event hooks.EventType, ) (*hookResult, error) { if !t.enabled() { return emptyHookResult, nil } var data any switch event { - case agenthooks.EventSessionStart: - data = agenthooks.SessionStartData{Source: msg.Source} - case agenthooks.EventUserPromptSubmit: - data = agenthooks.UserPromptSubmitData{Prompt: msg.Prompt, Parts: msg.Parts} - case agenthooks.EventPreToolUse: - data = agenthooks.PreToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolInput: msg.ToolInput} - case agenthooks.EventPostToolUse: - data = agenthooks.PostToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolResponse: msg.ToolResponse, ToolError: msg.ToolError} - case agenthooks.EventPreCompact: - data = agenthooks.PreCompactData{} - case agenthooks.EventPostCompact: - data = agenthooks.PostCompactData{} - case agenthooks.EventStop: - data = agenthooks.StopData{} + case hooks.EventSessionStart: + data = hooks.SessionStartData{Source: msg.Source} + case hooks.EventUserPromptSubmit: + data = hooks.UserPromptSubmitData{Prompt: msg.Prompt, Parts: msg.Parts} + case hooks.EventPreToolUse: + data = hooks.PreToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolInput: msg.ToolInput} + case hooks.EventPostToolUse: + data = hooks.PostToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolResponse: msg.ToolResponse, ToolError: msg.ToolError} + case hooks.EventPreCompact: + data = hooks.PreCompactData{} + case hooks.EventPostCompact: + data = hooks.PostCompactData{} + case hooks.EventStop: + data = hooks.StopData{} default: return nil, xerrors.Errorf("unsupported hook event %q", event) } - response, _, err := t.dispatcher.Dispatch(ctx, chathooks.Event{ + response, _, err := t.dispatcher.Dispatch(ctx, dispatch.Event{ Type: event, ChatRef: chat.ref(), Data: data, @@ -169,7 +169,7 @@ func (t *hookTrigger) trigger( if err != nil { return nil, err } - if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { + if response.Permission != nil && response.Permission.Decision == hooks.PermissionDeny { return nil, &hookDeniedError{ Event: event, Reason: response.Permission.Reason, @@ -181,7 +181,7 @@ func (t *hookTrigger) trigger( ModelContext: response.ModelContext, UserMessage: response.UserMessage, } - if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionAllow { + if response.Permission != nil && response.Permission.Decision == hooks.PermissionAllow { result.InputOverride = response.Permission.InputOverride } return result, nil diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go index 56c4c2d1d35..7232e475aad 100644 --- a/coderd/x/chatd/hooks_internal_test.go +++ b/coderd/x/chatd/hooks_internal_test.go @@ -20,9 +20,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -31,17 +31,17 @@ func TestSessionStartDispatchSources(t *testing.T) { const secret = "test-hook-secret-32-bytes-minimum!!" type received struct { - request agenthooks.Request - claims agenthooks.Claims - data agenthooks.SessionStartData + request hooks.Request + claims hooks.Claims + data hooks.SessionStartData } receivedCh := make(chan received, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte(secret)) + claims, err := hooks.Verify(r.Header.Get("Authorization"), []byte(secret)) require.NoError(t, err) - var data agenthooks.SessionStartData + var data hooks.SessionStartData require.NoError(t, json.Unmarshal(request.Data, &data)) receivedCh <- received{request: request, claims: claims, data: data} _, err = w.Write([]byte(`{}`)) @@ -50,7 +50,7 @@ func TestSessionStartDispatchSources(t *testing.T) { t.Cleanup(consumer.Close) db, _ := dbtestutil.NewDB(t) - dispatcher := chathooks.New( + dispatcher := dispatch.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -68,17 +68,17 @@ func TestSessionStartDispatchSources(t *testing.T) { turnID := uuid.New() ctx := testutil.Context(t, testutil.WaitLong) - _, err := server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource(nil)}, agenthooks.EventSessionStart) + _, err := server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource(nil)}, hooks.EventSessionStart) require.NoError(t, err) - _, err = server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, agenthooks.EventSessionStart) + _, err = server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, hooks.EventSessionStart) require.NoError(t, err) startup := <-receivedCh resume := <-receivedCh - require.Equal(t, agenthooks.EventSessionStart, startup.request.Type) + require.Equal(t, hooks.EventSessionStart, startup.request.Type) require.Equal(t, sessionStartSourceStartup, startup.data.Source) require.Equal(t, startup.request.Meta.DispatchID, startup.claims.JTI) - require.Equal(t, agenthooks.EventSessionStart, resume.request.Type) + require.Equal(t, hooks.EventSessionStart, resume.request.Type) require.Equal(t, sessionStartSourceResume, resume.data.Source) require.Equal(t, resume.request.Meta.DispatchID, resume.claims.JTI) require.NotEqual(t, startup.claims.JTI, resume.claims.JTI) @@ -117,7 +117,7 @@ func TestSessionStartDispatchFailureFinishesGeneration(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) })) t.Cleanup(consumer.Close) - dispatcher := chathooks.New( + dispatcher := dispatch.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -234,7 +234,7 @@ func newTestHookTrigger(t *testing.T, handler http.Handler) *hookTrigger { t.Helper() consumer := httptest.NewServer(handler) t.Cleanup(consumer.Close) - return newHookTrigger(chathooks.New( + return newHookTrigger(dispatch.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -256,7 +256,7 @@ func TestHookTriggerDisabled(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() require.False(t, trigger.enabled()) - result, err := trigger.trigger(t.Context(), hookChat{ID: uuid.New()}, hookMessage{}, agenthooks.EventStop) + result, err := trigger.trigger(t.Context(), hookChat{ID: uuid.New()}, hookMessage{}, hooks.EventStop) require.NoError(t, err) require.Empty(t, result.modelContext()) require.Empty(t, result.userMessage()) @@ -281,11 +281,11 @@ func TestHookTriggerDeny(t *testing.T) { ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{}`), - }, agenthooks.EventPreToolUse) + }, hooks.EventPreToolUse) require.Nil(t, result) var denied *hookDeniedError require.ErrorAs(t, err, &denied) - require.Equal(t, agenthooks.EventPreToolUse, denied.Event) + require.Equal(t, hooks.EventPreToolUse, denied.Event) require.Equal(t, "policy", denied.Reason) require.Equal(t, "try another tool", denied.ModelContext) require.Equal(t, "blocked by policy", denied.UserMessage) @@ -294,9 +294,9 @@ func TestHookTriggerDeny(t *testing.T) { func TestHookTriggerEventPayloads(t *testing.T) { t.Parallel() - requests := make(chan agenthooks.Request, 1) + requests := make(chan hooks.Request, 1) trigger := newTestHookTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) requests <- request _, err := w.Write([]byte(`{}`)) @@ -308,7 +308,7 @@ func TestHookTriggerEventPayloads(t *testing.T) { WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, } ctx := testutil.Context(t, testutil.WaitShort) - dispatch := func(t *testing.T, msg hookMessage, event agenthooks.EventType) agenthooks.Request { + dispatchEvent := func(t *testing.T, msg hookMessage, event hooks.EventType) hooks.Request { t.Helper() _, err := trigger.trigger(ctx, chat, msg, event) require.NoError(t, err) @@ -321,37 +321,37 @@ func TestHookTriggerEventPayloads(t *testing.T) { return request } - sessionStart := dispatch(t, hookMessage{Source: sessionStartSourceClear}, agenthooks.EventSessionStart) - var sessionStartData agenthooks.SessionStartData + sessionStart := dispatchEvent(t, hookMessage{Source: sessionStartSourceClear}, hooks.EventSessionStart) + var sessionStartData hooks.SessionStartData require.NoError(t, json.Unmarshal(sessionStart.Data, &sessionStartData)) require.Equal(t, sessionStartSourceClear, sessionStartData.Source) - prompt := dispatch(t, hookMessage{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, agenthooks.EventUserPromptSubmit) - var promptData agenthooks.UserPromptSubmitData + prompt := dispatchEvent(t, hookMessage{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, hooks.EventUserPromptSubmit) + var promptData hooks.UserPromptSubmitData require.NoError(t, json.Unmarshal(prompt.Data, &promptData)) require.Equal(t, "hello", promptData.Prompt) require.JSONEq(t, `[{"type":"text","text":"hello"}]`, string(promptData.Parts)) - preToolUse := dispatch(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, agenthooks.EventPreToolUse) - var preToolUseData agenthooks.PreToolUseData + preToolUse := dispatchEvent(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, hooks.EventPreToolUse) + var preToolUseData hooks.PreToolUseData require.NoError(t, json.Unmarshal(preToolUse.Data, &preToolUseData)) require.Equal(t, "call_1", preToolUseData.ToolUseID) require.Equal(t, "execute", preToolUseData.ToolName) require.JSONEq(t, `{"cmd":"ls"}`, string(preToolUseData.ToolInput)) - postToolUse := dispatch(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, agenthooks.EventPostToolUse) - var postToolUseData agenthooks.PostToolUseData + postToolUse := dispatchEvent(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, hooks.EventPostToolUse) + var postToolUseData hooks.PostToolUseData require.NoError(t, json.Unmarshal(postToolUse.Data, &postToolUseData)) require.Equal(t, "call_1", postToolUseData.ToolUseID) require.Equal(t, "execute", postToolUseData.ToolName) require.JSONEq(t, `{"ok":true}`, string(postToolUseData.ToolResponse)) require.Equal(t, "boom", postToolUseData.ToolError) - for _, event := range []agenthooks.EventType{agenthooks.EventPreCompact, agenthooks.EventPostCompact, agenthooks.EventStop} { - dispatch(t, hookMessage{}, event) + for _, event := range []hooks.EventType{hooks.EventPreCompact, hooks.EventPostCompact, hooks.EventStop} { + dispatchEvent(t, hookMessage{}, event) } - _, err := trigger.trigger(ctx, chat, hookMessage{}, agenthooks.EventType("bogus")) + _, err := trigger.trigger(ctx, chat, hookMessage{}, hooks.EventType("bogus")) require.ErrorContains(t, err, "unsupported hook event") } diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index d9c75fbe6ba..790172455f7 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -25,9 +25,9 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -50,9 +50,9 @@ func TestSendMessageUserPromptSubmitHook(t *testing.T) { codersdk.ChatMessageFileReference("main.go", 1, 3, "package main"), } consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) + data := decodeHookData[hooks.UserPromptSubmitData](t, request) require.Equal(t, "before", data.Prompt) var hookParts []codersdk.ChatMessagePart require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) @@ -114,9 +114,9 @@ func TestSendMessageUserPromptSubmitHook(t *testing.T) { }) } -func newHookDispatcher(t *testing.T, _ database.Store, consumer *httptest.Server) *chathooks.Dispatcher { +func newHookDispatcher(t *testing.T, _ database.Store, consumer *httptest.Server) *dispatch.Dispatcher { t.Helper() - return chathooks.New( + return dispatch.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -183,7 +183,7 @@ func TestSendMessageUserPromptSubmitPassthrough(t *testing.T) { OwnerID: user.ID, LastModelConfigID: model.ID, }) - var received agenthooks.Request + var received hooks.Request consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) _, err := w.Write([]byte(`{}`)) @@ -197,8 +197,8 @@ func TestSendMessageUserPromptSubmitPassthrough(t *testing.T) { }) require.NoError(t, err) require.Equal(t, "passthrough", hookMessageText(t, result.Message)) - require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) - promptData := decodeHookData[agenthooks.UserPromptSubmitData](t, received) + require.Equal(t, hooks.EventUserPromptSubmit, received.Type) + promptData := decodeHookData[hooks.UserPromptSubmitData](t, received) require.Equal(t, "passthrough", promptData.Prompt) // The persisted content is jsonb-normalized, so compare JSON // semantics rather than raw bytes. @@ -218,7 +218,7 @@ func TestSendMessageUserPromptSubmitQueue(t *testing.T) { InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("running")}, }) require.NoError(t, err) - var received agenthooks.Request + var received hooks.Request consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) _, err := w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"prompt":"queued override"}},"model_context":"queued context","user_message":"queued notice"}`)) @@ -257,8 +257,8 @@ func TestSendMessageUserPromptSubmitQueue(t *testing.T) { }) require.NoError(t, err) require.Equal(t, wantQueuedParts, persistedParts) - require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) - require.Equal(t, "queued original", decodeHookData[agenthooks.UserPromptSubmitData](t, received).Prompt) + require.Equal(t, hooks.EventUserPromptSubmit, received.Type) + require.Equal(t, "queued original", decodeHookData[hooks.UserPromptSubmitData](t, received).Prompt) } func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { @@ -282,7 +282,7 @@ func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { name: "dispatch failure", statusCode: http.StatusInternalServerError, assertErr: func(t *testing.T, err error) { - var dispatchErr *chathooks.DispatchError + var dispatchErr *dispatch.Error require.ErrorAs(t, err, &dispatchErr) }, }, @@ -343,10 +343,10 @@ func TestSubagentSpawnHookDispatchFailureFailsTurn(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type == agenthooks.EventUserPromptSubmit { - data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) + if request.Type == hooks.EventUserPromptSubmit { + data := decodeHookData[hooks.UserPromptSubmitData](t, request) if data.Prompt == "child admission prompt" { w.WriteHeader(http.StatusInternalServerError) return @@ -400,7 +400,7 @@ func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { OwnerID: user.ID, LastModelConfigID: model.ID, }) - var received agenthooks.Request + var received hooks.Request consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) w.WriteHeader(http.StatusInternalServerError) @@ -412,17 +412,17 @@ func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { ChatID: chat.ID, Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("fails")}, }) - var dispatchErr *chathooks.DispatchError + var dispatchErr *dispatch.Error require.ErrorAs(t, err, &dispatchErr) - require.Equal(t, chathooks.ResultHTTPError, dispatchErr.Class) + require.Equal(t, dispatch.ResultHTTPError, dispatchErr.Class) updated, err := db.GetChatByID(ctx, chat.ID) require.NoError(t, err) require.Equal(t, database.ChatStatusError, updated.Status) var chatErr codersdk.ChatError require.NoError(t, json.Unmarshal(updated.LastError.RawMessage, &chatErr)) require.Equal(t, "hook dispatch failed: user_prompt_submit: http_error (dispatch "+dispatchErr.DispatchID.String()+")", chatErr.Message) - require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) - prompt := decodeHookData[agenthooks.UserPromptSubmitData](t, received) + require.Equal(t, hooks.EventUserPromptSubmit, received.Type) + prompt := decodeHookData[hooks.UserPromptSubmitData](t, received) require.Equal(t, "fails", prompt.Prompt) messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) require.NoError(t, err) @@ -450,21 +450,21 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) { require.NoError(t, err) require.Len(t, inserted, 1) type receivedHook struct { - request agenthooks.Request - claims agenthooks.Claims + request hooks.Request + claims hooks.Claims } var receivedMu sync.Mutex received := make([]receivedHook, 0, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte("test-hook-secret-32-bytes-minimum!!")) + claims, err := hooks.Verify(r.Header.Get("Authorization"), []byte("test-hook-secret-32-bytes-minimum!!")) require.NoError(t, err) receivedMu.Lock() received = append(received, receivedHook{request: request, claims: claims}) receivedMu.Unlock() response := `{"model_context":"clear context","user_message":"clear notice"}` - if request.Type == agenthooks.EventUserPromptSubmit { + if request.Type == hooks.EventUserPromptSubmit { response = `{"permission":{"decision":"allow","input_override":{"prompt":"edited override"}},"model_context":"edit context","user_message":"edit notice"}` } _, err = w.Write([]byte(response)) @@ -491,11 +491,11 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) { received = slices.Clone(received) receivedMu.Unlock() require.Len(t, received, 2) - require.Equal(t, agenthooks.EventSessionStart, received[0].request.Type) - require.Equal(t, agenthooks.SessionStartData{Source: "clear"}, decodeHookData[agenthooks.SessionStartData](t, received[0].request)) + require.Equal(t, hooks.EventSessionStart, received[0].request.Type) + require.Equal(t, hooks.SessionStartData{Source: "clear"}, decodeHookData[hooks.SessionStartData](t, received[0].request)) require.Equal(t, received[0].request.Meta.DispatchID, received[0].claims.JTI) - require.Equal(t, agenthooks.EventUserPromptSubmit, received[1].request.Type) - prompt := decodeHookData[agenthooks.UserPromptSubmitData](t, received[1].request) + require.Equal(t, hooks.EventUserPromptSubmit, received[1].request.Type) + prompt := decodeHookData[hooks.UserPromptSubmitData](t, received[1].request) require.Equal(t, "edited original", prompt.Prompt) require.NotNil(t, received[0].request.Meta.TurnID) require.Equal(t, received[0].request.Meta.TurnID, received[1].request.Meta.TurnID) @@ -577,9 +577,9 @@ func TestPromptHooksAdmissionPreflight(t *testing.T) { db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) user, org, model := seedChatDependencies(t, db) - received := make(chan agenthooks.Request, 8) + received := make(chan hooks.Request, 8) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) received <- request _, err := w.Write([]byte(`{}`)) @@ -704,7 +704,7 @@ func hookMessageText(t *testing.T, message database.ChatMessage) string { return parts[0].Text } -func decodeHookData[T any](t *testing.T, request agenthooks.Request) T { +func decodeHookData[T any](t *testing.T, request hooks.Request) T { t.Helper() var data T require.NoError(t, json.Unmarshal(request.Data, &data)) diff --git a/coderd/x/chatd/post_tool_use_test.go b/coderd/x/chatd/post_tool_use_test.go index ee3719f8168..43c68f0fc69 100644 --- a/coderd/x/chatd/post_tool_use_test.go +++ b/coderd/x/chatd/post_tool_use_test.go @@ -19,9 +19,9 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" "github.com/coder/coder/v2/testutil" @@ -65,16 +65,16 @@ func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var mu sync.Mutex - var received []agenthooks.PostToolUseData + var received []hooks.PostToolUseData consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventPostToolUse { + if request.Type != hooks.EventPostToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[agenthooks.PostToolUseData](t, request) + data := decodeHookData[hooks.PostToolUseData](t, request) mu.Lock() received = append(received, data) index := len(received) @@ -126,7 +126,7 @@ func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) mu.Lock() - receivedSnapshot := append([]agenthooks.PostToolUseData(nil), received...) + receivedSnapshot := append([]hooks.PostToolUseData(nil), received...) mu.Unlock() require.Len(t, receivedSnapshot, 2) require.Equal(t, "call_first", receivedSnapshot[0].ToolUseID) @@ -196,15 +196,15 @@ func TestPostToolUseHookDynamicResult(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) var postCalls atomic.Int32 consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventPostToolUse { + if request.Type != hooks.EventPostToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } postCalls.Add(1) - data := decodeHookData[agenthooks.PostToolUseData](t, request) + data := decodeHookData[hooks.PostToolUseData](t, request) require.Equal(t, "call_dynamic_result", data.ToolUseID) require.Equal(t, "my_dynamic_tool", data.ToolName) require.JSONEq(t, `{"answer":42}`, string(data.ToolResponse)) @@ -284,9 +284,9 @@ func TestPostToolUseHookDynamicFailureRejectsSubmission(t *testing.T) { var failPostToolUse atomic.Bool failPostToolUse.Store(true) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type == agenthooks.EventPostToolUse { + if request.Type == hooks.EventPostToolUse { postCalls.Add(1) if failPostToolUse.Load() { w.WriteHeader(http.StatusInternalServerError) @@ -328,7 +328,7 @@ func TestPostToolUseHookDynamicFailureRejectsSubmission(t *testing.T) { ModelConfigID: model.ID, Results: results, }) - var dispatchErr *chathooks.DispatchError + var dispatchErr *dispatch.Error require.ErrorAs(t, err, &dispatchErr) unchanged, err := db.GetChatByID(ctx, chat.ID) @@ -370,11 +370,11 @@ func TestPostToolUseHookFailureCommitsResultThenErrors(t *testing.T) { ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var postCalls atomic.Int32 consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type == agenthooks.EventPostToolUse { + if request.Type == hooks.EventPostToolUse { postCalls.Add(1) - data := decodeHookData[agenthooks.PostToolUseData](t, request) + data := decodeHookData[hooks.PostToolUseData](t, request) require.Equal(t, "call_failure", data.ToolUseID) w.WriteHeader(http.StatusInternalServerError) return @@ -444,14 +444,14 @@ func TestPostToolUseHookFailureDispatchesRemainingResults(t *testing.T) { var mu sync.Mutex results := map[string]string{} consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventPostToolUse { + if request.Type != hooks.EventPostToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[agenthooks.PostToolUseData](t, request) + data := decodeHookData[hooks.PostToolUseData](t, request) result := "ok" if data.ToolUseID == "call_first" { result = "http_error" diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index ff974e2724b..817fa57d35f 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -21,8 +21,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" "github.com/coder/coder/v2/testutil" @@ -69,7 +69,7 @@ func TestPreToolUseHookAllow(t *testing.T) { ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, "call_non_uuid", data.ToolUseID) require.Equal(t, "read_file", data.ToolName) @@ -140,7 +140,7 @@ func TestPreToolUseHookDeny(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { require.Equal(t, "call_denied", data.ToolUseID) return `{"permission":{"decision":"deny","reason":"blocked by policy"},"model_context":"Do not read secrets."}` }) @@ -209,7 +209,7 @@ func TestPreToolUseSkipsProviderExecutedTools(t *testing.T) { user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) model = enableAnthropicWebSearchForTest(t, db, model) var preToolCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(hooks.PreToolUseData) string { preToolCalls.Add(1) return `{}` }) @@ -240,7 +240,7 @@ func TestPreToolUseHookDynamicAllowResponse(t *testing.T) { return chattest.OpenAIStreamingResponse(chunk) }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { require.Equal(t, "call_dynamic_allow", data.ToolUseID) return `{"permission":{"decision":"allow","input_override":{"query":"redacted"}},"model_context":"dynamic context","user_message":"dynamic notice"}` }) @@ -372,7 +372,7 @@ func TestPreToolUseHookRepeatedToolCallIDDispatchesFresh(t *testing.T) { })) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, toolUseID, data.ToolUseID) return `{"permission":{"decision":"deny","reason":"repeated call"}}` @@ -432,9 +432,9 @@ func TestPreToolUseHookDispatchFailure(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventPreToolUse { + if request.Type != hooks.EventPreToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return @@ -509,14 +509,14 @@ func TestPreToolUseHookErrorRetryRedispatchesSiblings(t *testing.T) { var failSecond atomic.Bool failSecond.Store(true) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventPreToolUse { + if request.Type != hooks.EventPreToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[agenthooks.PreToolUseData](t, request) + data := decodeHookData[hooks.PreToolUseData](t, request) switch data.ToolUseID { case "call_first": firstCalls.Add(1) @@ -621,7 +621,7 @@ func TestPreToolUseHookSettledDecisionDispatchesFresh(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, "call_reused_after_settle", data.ToolUseID) return `{}` @@ -695,7 +695,7 @@ func TestPreToolUseHookResumeFallback(t *testing.T) { }) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, "call_resume_fallback", data.ToolUseID) return `{"permission":{"decision":"allow","input_override":{"path":"/tmp/resume.txt"}}}` @@ -814,7 +814,7 @@ func TestPreToolUseHookDynamicDeny(t *testing.T) { return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("replanned")...) }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { require.Equal(t, "call_dynamic_denied", data.ToolUseID) return `{"permission":{"decision":"deny","reason":"dynamic denied"}}` }) @@ -845,17 +845,17 @@ func TestPreToolUseHookDynamicDeny(t *testing.T) { require.Contains(t, string(result.Result), "DENIED: dynamic denied") } -func preToolUseConsumer(t *testing.T, response func(agenthooks.PreToolUseData) string) *httptest.Server { +func preToolUseConsumer(t *testing.T, response func(hooks.PreToolUseData) string) *httptest.Server { t.Helper() consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventPreToolUse { + if request.Type != hooks.EventPreToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[agenthooks.PreToolUseData](t, request) + data := decodeHookData[hooks.PreToolUseData](t, request) var err error _, err = w.Write([]byte(response(data))) require.NoError(t, err) diff --git a/coderd/x/chatd/stop_test.go b/coderd/x/chatd/stop_test.go index b1d57015886..a5a23c7aa90 100644 --- a/coderd/x/chatd/stop_test.go +++ b/coderd/x/chatd/stop_test.go @@ -16,8 +16,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -162,9 +162,9 @@ func TestStopHookDispatchFailureErrorsChat(t *testing.T) { func stopConsumer(t *testing.T, response func() (int, string)) *httptest.Server { t.Helper() consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request agenthooks.Request + var request hooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != agenthooks.EventStop { + if request.Type != hooks.EventStop { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index f3b80beb527..3e264f82b69 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -24,9 +24,9 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agenthooks" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -773,7 +773,7 @@ func (p *Server) subagentTools( if err != nil { // A failed hook dispatch must fail closed instead of // degrading into a tool error the model can ignore. - var hookErr *chathooks.DispatchError + var hookErr *dispatch.Error if errors.As(err, &hookErr) { return fantasy.ToolResponse{}, err } @@ -1302,7 +1302,7 @@ func (p *Server) createChildSubagentChatWithOptions( ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}, TurnID: &mintedTurnID, - }, promptMessage, agenthooks.EventUserPromptSubmit) + }, promptMessage, hooks.EventUserPromptSubmit) if err != nil { return database.Chat{}, userPromptDenial(err) } diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 3b5292017e5..05bfdb6715f 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -35,7 +35,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattool" - "github.com/coder/coder/v2/coderd/x/chathooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -293,7 +293,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { server := &Server{ db: db, logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - hooks: newHookTrigger(chathooks.New( + hooks: newHookTrigger(dispatch.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -405,7 +405,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { Name: spawnAgentToolName, Input: string(input), }) - var hookErr *chathooks.DispatchError + var hookErr *dispatch.Error require.ErrorAs(t, runErr, &hookErr, "dispatch failures must fail closed, not degrade to a tool error the model can ignore") diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index c0115383fd2..991f4fed0c2 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -85,9 +85,9 @@ A consumer must apply all of the following checks before it uses the body: - Compute SHA-256 over the exact request body bytes and compare it with `body_sha256`. - Check that the chat ID in `sub` matches `meta.chat_id`. -The Go consumer SDK in `codersdk/agenthooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. -Use `agenthooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. -Pass `agenthooks.WithExpectedIssuer` with the deployment ID associated with the secret to enforce the `iss` check. +The Go consumer SDK in `coderd/x/hooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. +Use `hooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. +Pass `hooks.WithExpectedIssuer` with the deployment ID associated with the secret to enforce the `iss` check. Without it, `NewHTTPHandler` accepts any non-empty `iss` signed with the shared secret, so use a secret dedicated to one deployment or always set the expected issuer. ### Return a response @@ -171,7 +171,7 @@ Keep the break-glass procedure available throughout the rollout. ## Start from the reference consumer -The reference consumer at `scripts/agenthooks-server` uses `agenthooks.NewHTTPHandler` and logs 1 JSON object for each event. +The reference consumer at `scripts/agenthooks-server` uses `hooks.NewHTTPHandler` and logs 1 JSON object for each event. Log-only mode returns an empty response for every verified event. With log-only mode disabled, the optional example flags can deny tool names by regular expression or replace matching prompt text before the agent loop uses it. It also demonstrates consumer-owned state: it remembers `pre_tool_use` decisions in memory keyed by chat and tool-use ID, replays them for duplicate deliveries, and marks the duplicates in its log output. From 4c8c114efdd7ba28522b6f08a681822b3023030f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:37:35 +0000 Subject: [PATCH 37/86] refactor(coderd): deduplicate chat hook error handling Fold the triplicated lifecycle hook denial and dispatch failure mapping in the chat create, send, and edit handlers into writeChatHookErr, and share one core between applyHookResultMessages and appendHookResultMessages. --- coderd/exp_chats.go | 60 ++++++++++++++-------------------- coderd/x/chatd/hook_effects.go | 36 +++++++++++++------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8cdab0ef63b..6dceae4c505 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -122,6 +122,27 @@ func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, ho }) } +// writeChatHookErr writes the response for lifecycle hook denials and +// dispatch failures, reporting whether it handled the error. The fallback +// message is used when the hook denies without a user message. +func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, deniedFallback string) bool { + var denied *chatd.UserPromptDeniedError + if errors.As(err, &denied) { + message := denied.UserMessage + if message == "" { + message = deniedFallback + } + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) + return true + } + var hookErr *dispatch.Error + if errors.As(err, &hookErr) { + writeChatHookDispatchFailed(ctx, rw, hookErr) + return true + } + return false +} + func maybeWriteLimitErr(ctx context.Context, rw http.ResponseWriter, err error) bool { var limitErr *chatd.UsageLimitExceededError if errors.As(err, &limitErr) { @@ -1453,18 +1474,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { ParentChatID: uuid.NullUUID{}, }) if err != nil { - var denied *chatd.UserPromptDeniedError - if errors.As(err, &denied) { - message := denied.UserMessage - if message == "" { - message = "Chat creation denied by lifecycle hook." - } - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) - return - } - var hookErr *dispatch.Error - if errors.As(err, &hookErr) { - writeChatHookDispatchFailed(ctx, rw, hookErr) + if writeChatHookErr(ctx, rw, err, "Chat creation denied by lifecycle hook.") { return } if maybeWriteLimitErr(ctx, rw, err) { @@ -3419,18 +3429,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { }, ) if sendErr != nil { - var denied *chatd.UserPromptDeniedError - if errors.As(sendErr, &denied) { - message := denied.UserMessage - if message == "" { - message = "Chat message denied by lifecycle hook." - } - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) - return - } - var hookErr *dispatch.Error - if errors.As(sendErr, &hookErr) { - writeChatHookDispatchFailed(ctx, rw, hookErr) + if writeChatHookErr(ctx, rw, sendErr, "Chat message denied by lifecycle hook.") { return } if maybeWriteLimitErr(ctx, rw, sendErr) { @@ -3620,18 +3619,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { ReasoningEffort: editReasoningEffort, }) if editErr != nil { - var denied *chatd.UserPromptDeniedError - if errors.As(editErr, &denied) { - message := denied.UserMessage - if message == "" { - message = "Chat message denied by lifecycle hook." - } - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) - return - } - var hookErr *dispatch.Error - if errors.As(editErr, &hookErr) { - writeChatHookDispatchFailed(ctx, rw, hookErr) + if writeChatHookErr(ctx, rw, editErr, "Chat message denied by lifecycle hook.") { return } if maybeWriteLimitErr(ctx, rw, editErr) { diff --git a/coderd/x/chatd/hook_effects.go b/coderd/x/chatd/hook_effects.go index fc335550b60..f0d87e00acd 100644 --- a/coderd/x/chatd/hook_effects.go +++ b/coderd/x/chatd/hook_effects.go @@ -75,15 +75,7 @@ func applyHookResultMessages( results []*hookResult, modelConfigID uuid.UUID, ) (stepMessagesForCommit, error) { - rows, err := hookEventMessagesForResults(results, modelConfigID) - if err != nil { - return stepMessagesForCommit{}, err - } - if len(rows) > 0 { - messages.Messages = append(rows, messages.Messages...) - messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) - } - return messages, nil + return insertHookResultMessages(messages, results, modelConfigID, hookRowsBeforeStep) } func appendHookResultMessages( @@ -91,12 +83,32 @@ func appendHookResultMessages( results []*hookResult, modelConfigID uuid.UUID, ) (stepMessagesForCommit, error) { - suffix, err := hookEventMessagesForResults(results, modelConfigID) + return insertHookResultMessages(messages, results, modelConfigID, hookRowsAfterStep) +} + +type hookRowPlacement int + +const ( + hookRowsBeforeStep hookRowPlacement = iota + hookRowsAfterStep +) + +func insertHookResultMessages( + messages stepMessagesForCommit, + results []*hookResult, + modelConfigID uuid.UUID, + placement hookRowPlacement, +) (stepMessagesForCommit, error) { + rows, err := hookEventMessagesForResults(results, modelConfigID) if err != nil { return stepMessagesForCommit{}, err } - if len(suffix) > 0 { - messages.Messages = append(messages.Messages, suffix...) + if len(rows) > 0 { + if placement == hookRowsBeforeStep { + messages.Messages = append(rows, messages.Messages...) + } else { + messages.Messages = append(messages.Messages, rows...) + } messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) } return messages, nil From f0de694b0de1a369f27519824031cf2365591cd7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:41:19 +0000 Subject: [PATCH 38/86] style: use errors.AsType for hook error matching and rewrap comments --- coderd/exp_chats.go | 17 ++++++++--------- coderd/x/chatd/chatd.go | 3 ++- coderd/x/chatd/hook_errors.go | 14 +++++++------- coderd/x/chatd/hook_tooluse.go | 4 ++-- coderd/x/chatd/subagent.go | 3 +-- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 6dceae4c505..5d6bcb17fd1 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -126,8 +126,7 @@ func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, ho // dispatch failures, reporting whether it handled the error. The fallback // message is used when the hook denies without a user message. func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, deniedFallback string) bool { - var denied *chatd.UserPromptDeniedError - if errors.As(err, &denied) { + if denied, ok := errors.AsType[*chatd.UserPromptDeniedError](err); ok { message := denied.UserMessage if message == "" { message = deniedFallback @@ -135,8 +134,7 @@ func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, de httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) return true } - var hookErr *dispatch.Error - if errors.As(err, &hookErr) { + if hookErr, ok := errors.AsType[*dispatch.Error](err); ok { writeChatHookDispatchFailed(ctx, rw, hookErr) return true } @@ -3670,8 +3668,8 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { response := codersdk.EditChatMessageResponse{Message: convertChatMessage(editResult.Message)} // Synthetic cancellations precede the replacement with lower IDs; // clients that seed their transcript cache from this response need - // all user-visible inserted rows, or a stream reconnect with after_id set to the - // replacement would skip the earlier ones. + // all user-visible inserted rows, or a stream reconnect with + // after_id set to the replacement would skip the earlier ones. for _, inserted := range editResult.InsertedMessages { if inserted.Visibility == database.ChatMessageVisibilityModel { continue @@ -8257,12 +8255,13 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) { DynamicTools: dynamicTools, }) if err != nil { + if hookErr, ok := errors.AsType[*dispatch.Error](err); ok { + writeChatHookDispatchFailed(ctx, rw, hookErr) + return + } var validationErr *chatd.ToolResultValidationError var conflictErr *chatd.ToolResultStatusConflictError - var hookErr *dispatch.Error switch { - case errors.As(err, &hookErr): - writeChatHookDispatchFailed(ctx, rw, hookErr) case xerrors.Is(err, chatd.ErrChatArchived): httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Cannot submit tool results to an archived chat.", diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index c6365ca5e4b..ccd2b163283 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1473,7 +1473,8 @@ func (p *Server) SendMessage( if _, err := resolveSendMessageModelConfigID(ctx, p.db, chat, opts.ModelConfigID); err != nil { return SendMessageResult{}, err } - // Check queue capacity before dispatch; the transaction rechecks it under lock. + // Check queue capacity before dispatch; the transaction + // rechecks it under lock. queuedCount, err := p.db.CountChatQueuedMessages(ctx, opts.ChatID) if err != nil { return SendMessageResult{}, xerrors.Errorf("count queued messages: %w", err) diff --git a/coderd/x/chatd/hook_errors.go b/coderd/x/chatd/hook_errors.go index 343bbe52490..c514074402a 100644 --- a/coderd/x/chatd/hook_errors.go +++ b/coderd/x/chatd/hook_errors.go @@ -53,8 +53,7 @@ func (e *UserPromptDeniedError) Error() string { } func userPromptDenial(err error) error { - var denied *hookDeniedError - if errors.As(err, &denied) { + if denied, ok := errors.AsType[*hookDeniedError](err); ok { return &UserPromptDeniedError{UserMessage: denied.UserMessage} } return err @@ -112,8 +111,8 @@ func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, e } func hookDispatchErrorMessage(eventType hooks.EventType, dispatchErr error) (string, bool) { - var structured *dispatch.Error - if !errors.As(dispatchErr, &structured) { + structured, ok := errors.AsType[*dispatch.Error](dispatchErr) + if !ok { return "", false } return fmt.Sprintf( @@ -156,9 +155,10 @@ func hookDispatchFailureFromResults(content []fantasy.Content) error { resultErr = output.Error } } - var dispatchErr *dispatch.Error - if resultErr != nil && errors.As(resultErr, &dispatchErr) { - return resultErr + if resultErr != nil { + if _, ok := errors.AsType[*dispatch.Error](resultErr); ok { + return resultErr + } } } return nil diff --git a/coderd/x/chatd/hook_tooluse.go b/coderd/x/chatd/hook_tooluse.go index 5eaa3af8112..5a112e2ac14 100644 --- a/coderd/x/chatd/hook_tooluse.go +++ b/coderd/x/chatd/hook_tooluse.go @@ -66,8 +66,8 @@ func (t *hookTrigger) preflightPendingToolCalls( ToolInput: json.RawMessage(toolCall.Input), }, hooks.EventPreToolUse) if err != nil { - var denied *hookDeniedError - if !errors.As(err, &denied) { + denied, ok := errors.AsType[*hookDeniedError](err) + if !ok { return preToolUseExecutionResult{}, err } // The denial's model context folds into the synthetic tool diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 3e264f82b69..cef41508baf 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -773,8 +773,7 @@ func (p *Server) subagentTools( if err != nil { // A failed hook dispatch must fail closed instead of // degrading into a tool error the model can ignore. - var hookErr *dispatch.Error - if errors.As(err, &hookErr) { + if _, ok := errors.AsType[*dispatch.Error](err); ok { return fantasy.ToolResponse{}, err } // UserPromptDeniedError.Error() carries the user-facing From bb9f0508372cd45d77ab152794937999c3f86f65 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:44:26 +0000 Subject: [PATCH 39/86] fix(coderd/x/chatd): make hook tool denials self-explanatory to the model Replace the terse DENIED prefix in synthetic denial tool results with text that identifies the lifecycle hook policy as the source, marks the decision as persistent, and directs the model to explain the denial instead of retrying. Models treated the old shape as an ordinary tool failure and misreported denials as workspace or infrastructure errors. --- coderd/exp_chats_hooks_test.go | 2 +- coderd/x/chatd/hook_effects.go | 14 +++++++++----- coderd/x/chatd/pre_tool_use_test.go | 7 ++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index a0ae82a1550..c511a0027eb 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -294,7 +294,7 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { 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)), "DENIED: secret reads are blocked") + 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) diff --git a/coderd/x/chatd/hook_effects.go b/coderd/x/chatd/hook_effects.go index f0d87e00acd..7d760c1622a 100644 --- a/coderd/x/chatd/hook_effects.go +++ b/coderd/x/chatd/hook_effects.go @@ -116,13 +116,17 @@ func insertHookResultMessages( // deniedToolResult synthesizes the denial as a tool result so the model // can replan within the same turn. The consumer's model_context rides in -// the same result instead of a separate transcript row. +// the same result instead of a separate transcript row. The text must +// distinguish a policy denial from a genuine tool failure, or the model +// retries the call and misreports the denial as an infrastructure error. func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { - reason = strings.TrimSpace(reason) - if reason == "" { - reason = "denied by lifecycle hook" + message := "Tool call denied by the deployment's lifecycle hook policy." + if reason = strings.TrimSpace(reason); reason != "" { + message += " Reason: " + reason + "." } - message := "DENIED: " + reason + message += " This is an administrative policy decision, not a tool or" + + " workspace failure; retrying the same call will be denied again." + + " Explain the denial to the user and adjust your approach." if modelContext = strings.TrimSpace(modelContext); modelContext != "" { message += "\n\n" + modelContext } diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index 817fa57d35f..b878bae7d2d 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -173,7 +173,8 @@ func TestPreToolUseHookDeny(t *testing.T) { parts := chatToolParts(ctx, t, db, chat.ID) result := requireToolResultPart(t, parts, "read_file") require.True(t, result.IsError) - require.Contains(t, string(result.Result), "DENIED: blocked by policy") + require.Contains(t, string(result.Result), "denied by the deployment's lifecycle hook policy") + require.Contains(t, string(result.Result), "Reason: blocked by policy.") require.Contains(t, string(result.Result), "Do not read secrets.") messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) @@ -192,7 +193,7 @@ func TestPreToolUseHookDeny(t *testing.T) { messagesMu.Lock() modelMessages := append([]chattest.OpenAIMessage(nil), secondMessages...) messagesMu.Unlock() - require.True(t, openAIMessagesContain(modelMessages, "DENIED: blocked by policy")) + require.True(t, openAIMessagesContain(modelMessages, "Reason: blocked by policy.")) require.True(t, openAIMessagesContain(modelMessages, "Do not read secrets.")) } @@ -842,7 +843,7 @@ func TestPreToolUseHookDynamicDeny(t *testing.T) { require.Equal(t, int32(2), modelCalls.Load()) result := requireToolResultPart(t, chatToolParts(ctx, t, db, chat.ID), "my_dynamic_tool") require.True(t, result.IsError) - require.Contains(t, string(result.Result), "DENIED: dynamic denied") + require.Contains(t, string(result.Result), "Reason: dynamic denied.") } func preToolUseConsumer(t *testing.T, response func(hooks.PreToolUseData) string) *httptest.Server { From f74597e73cca0ced0911552af4adb8c5d8c270f6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:57:37 +0000 Subject: [PATCH 40/86] refactor(coderd/x/chatd): extract hook integration into chathooks subpackage Move the lifecycle hook trigger seam, transcript effects, tool gate, and error classification from chatd into coderd/x/chatd/chathooks, exporting the moved symbols. Server-bound glue stays in chatd (hook_server.go): the chat-parking dispatch error handlers, the step-commit row insertion wrappers, and the dynamic post-tool-use state loader, which depends on chatd validation types. Trigger-focused internal tests move with the package; fixture-bound tests stay. --- coderd/exp_chats.go | 3 +- coderd/x/chatd/chatd.go | 49 ++-- .../{hook_effects.go => chathooks/effects.go} | 84 ++---- coderd/x/chatd/chathooks/errors.go | 117 ++++++++ .../x/chatd/chathooks/hooks_internal_test.go | 261 ++++++++++++++++++ .../{hook_tooluse.go => chathooks/tooluse.go} | 144 ++-------- .../{hook_trigger.go => chathooks/trigger.go} | 84 +++--- coderd/x/chatd/create_hooks_test.go | 3 +- coderd/x/chatd/generation.go | 65 ++--- coderd/x/chatd/hook_errors.go | 165 ----------- coderd/x/chatd/hook_server.go | 206 ++++++++++++++ coderd/x/chatd/hooks_internal_test.go | 248 +---------------- coderd/x/chatd/hooks_test.go | 5 +- coderd/x/chatd/subagent.go | 17 +- coderd/x/chatd/subagent_internal_test.go | 5 +- 15 files changed, 758 insertions(+), 698 deletions(-) rename coderd/x/chatd/{hook_effects.go => chathooks/effects.go} (69%) create mode 100644 coderd/x/chatd/chathooks/errors.go create mode 100644 coderd/x/chatd/chathooks/hooks_internal_test.go rename coderd/x/chatd/{hook_tooluse.go => chathooks/tooluse.go} (54%) rename coderd/x/chatd/{hook_trigger.go => chathooks/trigger.go} (67%) delete mode 100644 coderd/x/chatd/hook_errors.go create mode 100644 coderd/x/chatd/hook_server.go diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 5d6bcb17fd1..bdecc7ee52a 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -50,6 +50,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/agentselect" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" @@ -126,7 +127,7 @@ func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, ho // dispatch failures, reporting whether it handled the error. The fallback // message is used when the hook denies without a user message. func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, deniedFallback string) bool { - if denied, ok := errors.AsType[*chatd.UserPromptDeniedError](err); ok { + if denied, ok := errors.AsType[*chathooks.UserPromptDeniedError](err); ok { message := denied.UserMessage if message == "" { message = deniedFallback diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ccd2b163283..bc721465fa7 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -43,6 +43,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatadvisor" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatopenai" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" @@ -178,7 +179,7 @@ type Server struct { stopWorkspaceFn chattool.StopWorkspaceFn pubsub pubsub.Pubsub webpushDispatcher webpush.Dispatcher - hooks *hookTrigger + hooks *chathooks.Trigger providerAPIKeys chatprovider.ProviderAPIKeys allowBYOK bool oidcTokenSource mcpclient.UserOIDCTokenSource @@ -1318,26 +1319,26 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C chatID := uuid.New() contentParts := opts.InitialUserContent - if p.hooks.enabled() { + if p.hooks.Enabled() { // Validate model admission before dispatch, matching the insert path. if err := validateCreateModelConfigID(ctx, p.db, opts.ModelConfigID); err != nil { return database.Chat{}, err } turnID := uuid.New() - promptMessage, err := userPromptHookMessage(contentParts) + promptMessage, err := chathooks.UserPromptMessage(contentParts) if err != nil { return database.Chat{}, err } - promptResult, err := p.hooks.trigger(ctx, hookChat{ + promptResult, err := p.hooks.Trigger(ctx, chathooks.Chat{ ID: chatID, OwnerID: opts.OwnerID, WorkspaceID: opts.WorkspaceID, TurnID: &turnID, }, promptMessage, hooks.EventUserPromptSubmit) if err != nil { - return database.Chat{}, userPromptDenial(err) + return database.Chat{}, chathooks.UserPromptDenial(err) } - composed, overridden, err := composeUserPromptContent(contentParts, promptResult) + composed, overridden, err := chathooks.ComposeUserPromptContent(contentParts, promptResult) if err != nil { return database.Chat{}, err } @@ -1457,7 +1458,7 @@ func (p *Server) SendMessage( } contentParts := opts.Content - if p.hooks.enabled() { + if p.hooks.Enabled() { turnID := uuid.New() chat, err := p.db.GetChatByID(ctx, opts.ChatID) if err != nil { @@ -1482,15 +1483,15 @@ func (p *Server) SendMessage( if queuedCount >= chatstate.MaxQueueSize { return SendMessageResult{}, &chatstate.MessageQueueFullError{Max: chatstate.MaxQueueSize} } - promptMessage, err := userPromptHookMessage(contentParts) + promptMessage, err := chathooks.UserPromptMessage(contentParts) if err != nil { return SendMessageResult{}, err } - promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) + promptResult, err := p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) if err != nil { - return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, userPromptDenial(err)) + return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, chathooks.UserPromptDenial(err)) } - contentParts, _, err = composeUserPromptContent(contentParts, promptResult) + contentParts, _, err = chathooks.ComposeUserPromptContent(contentParts, promptResult) if err != nil { return SendMessageResult{}, err } @@ -1789,8 +1790,8 @@ func (p *Server) EditMessage( } contentParts := opts.Content - var sessionStartHookResult *hookResult - if p.hooks.enabled() { + var sessionStartHookResult *chathooks.Result + if p.hooks.Enabled() { turnID := uuid.New() chat, err := p.db.GetChatByID(ctx, opts.ChatID) if err != nil { @@ -1809,19 +1810,19 @@ func (p *Server) EditMessage( if _, err := validateModelConfigOverride(ctx, p.db, opts.ModelConfigID); err != nil { return EditMessageResult{}, err } - sessionStartHookResult, err = p.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSourceClear}, hooks.EventSessionStart) + sessionStartHookResult, err = p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), chathooks.Message{Source: chathooks.SessionStartSourceClear}, hooks.EventSessionStart) if err != nil { return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, hooks.EventSessionStart, err) } - promptMessage, err := userPromptHookMessage(contentParts) + promptMessage, err := chathooks.UserPromptMessage(contentParts) if err != nil { return EditMessageResult{}, err } - promptResult, err := p.hooks.trigger(ctx, hookChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) + promptResult, err := p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) if err != nil { - return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, userPromptDenial(err)) + return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, chathooks.UserPromptDenial(err)) } - contentParts, _, err = composeUserPromptContent(contentParts, promptResult) + contentParts, _, err = chathooks.ComposeUserPromptContent(contentParts, promptResult) if err != nil { return EditMessageResult{}, err } @@ -1899,7 +1900,7 @@ func (p *Server) EditMessage( // only the session_start(clear) response needs transcript rows. // They insert after the replacement so a later edit's suffix // truncation cleans them up. - suffixMessages, err := hookEventMessages(sessionStartHookResult, modelConfigID) + suffixMessages, err := chathooks.EventMessages(sessionStartHookResult, modelConfigID) if err != nil { return err } @@ -2182,18 +2183,18 @@ func (p *Server) SubmitToolResults( ) error { machine := p.newChatMachine(opts.ChatID) var hookSuffix []chatstate.Message - if p.hooks.enabled() { + if p.hooks.Enabled() { state, err := loadDynamicPostToolUseState(ctx, machine, opts) if err != nil { return err } for _, result := range opts.Results { - response, err := p.hooks.trigger(ctx, hookChatFor(state.chat, nil), dynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), hooks.EventPostToolUse) + response, err := p.hooks.Trigger(ctx, chathooks.ChatFor(state.chat, nil), chathooks.DynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), hooks.EventPostToolUse) if err != nil { // Leave pending calls intact so the client can resubmit after recovery. - return generationHookDispatchError(hooks.EventPostToolUse, err) + return chathooks.GenerationDispatchError(hooks.EventPostToolUse, err) } - responseMessages, err := hookEventMessages(response, state.modelConfigID) + responseMessages, err := chathooks.EventMessages(response, state.modelConfigID) if err != nil { return err } @@ -3196,7 +3197,7 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { stopWorkspaceFn: cfg.StopWorkspace, pubsub: ps, webpushDispatcher: cfg.WebpushDispatcher, - hooks: newHookTrigger(hookDispatcher), + hooks: chathooks.NewTrigger(hookDispatcher), providerAPIKeys: cfg.ProviderAPIKeys, allowBYOK: allowBYOK, oidcTokenSource: cfg.OIDCTokenSource, diff --git a/coderd/x/chatd/hook_effects.go b/coderd/x/chatd/chathooks/effects.go similarity index 69% rename from coderd/x/chatd/hook_effects.go rename to coderd/x/chatd/chathooks/effects.go index 7d760c1622a..fe8c4f59d31 100644 --- a/coderd/x/chatd/hook_effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -1,4 +1,4 @@ -package chatd +package chathooks import ( "bytes" @@ -17,12 +17,12 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// hookEventMessages converts a turn-time hook result into ordinary +// EventMessages converts a turn-time hook result into ordinary // transcript rows: model context becomes a user-role, model-visible row // and the user message becomes a system-role, user-visible notice row. -func hookEventMessages(result *hookResult, modelConfigID uuid.UUID) ([]chatstate.Message, error) { +func EventMessages(result *Result, modelConfigID uuid.UUID) ([]chatstate.Message, error) { messages := make([]chatstate.Message, 0, 2) - if result.modelContext() != "" { + if result.GetModelContext() != "" { content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.ModelContext)}) if err != nil { return nil, xerrors.Errorf("marshal hook model context: %w", err) @@ -35,7 +35,7 @@ func hookEventMessages(result *hookResult, modelConfigID uuid.UUID) ([]chatstate ContentVersion: chatprompt.CurrentContentVersion, }) } - if result.userMessage() != "" { + if result.GetUserMessage() != "" { content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.UserMessage)}) if err != nil { return nil, xerrors.Errorf("marshal hook user message: %w", err) @@ -51,13 +51,13 @@ func hookEventMessages(result *hookResult, modelConfigID uuid.UUID) ([]chatstate return messages, nil } -func hookEventMessagesForResults( - results []*hookResult, +func EventMessagesForResults( + results []*Result, modelConfigID uuid.UUID, ) ([]chatstate.Message, error) { var messages []chatstate.Message for _, result := range results { - resultMessages, err := hookEventMessages(result, modelConfigID) + resultMessages, err := EventMessages(result, modelConfigID) if err != nil { return nil, err } @@ -66,54 +66,6 @@ func hookEventMessagesForResults( return messages, nil } -// applyHookResultMessages inserts hook event rows before the step's -// own rows so injected model context precedes the assistant content it -// steers; providers require tool results to directly follow the -// assistant tool calls. -func applyHookResultMessages( - messages stepMessagesForCommit, - results []*hookResult, - modelConfigID uuid.UUID, -) (stepMessagesForCommit, error) { - return insertHookResultMessages(messages, results, modelConfigID, hookRowsBeforeStep) -} - -func appendHookResultMessages( - messages stepMessagesForCommit, - results []*hookResult, - modelConfigID uuid.UUID, -) (stepMessagesForCommit, error) { - return insertHookResultMessages(messages, results, modelConfigID, hookRowsAfterStep) -} - -type hookRowPlacement int - -const ( - hookRowsBeforeStep hookRowPlacement = iota - hookRowsAfterStep -) - -func insertHookResultMessages( - messages stepMessagesForCommit, - results []*hookResult, - modelConfigID uuid.UUID, - placement hookRowPlacement, -) (stepMessagesForCommit, error) { - rows, err := hookEventMessagesForResults(results, modelConfigID) - if err != nil { - return stepMessagesForCommit{}, err - } - if len(rows) > 0 { - if placement == hookRowsBeforeStep { - messages.Messages = append(rows, messages.Messages...) - } else { - messages.Messages = append(messages.Messages, rows...) - } - messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) - } - return messages, nil -} - // deniedToolResult synthesizes the denial as a tool result so the model // can replan within the same turn. The consumer's model_context rides in // the same result instead of a separate transcript row. The text must @@ -139,9 +91,9 @@ func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext str } } -// restoreToolCallOrder reorders known tool results to match the assistant's +// RestoreToolCallOrder reorders known tool results to match the assistant's // call order while preserving slots for unrelated entries. -func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallContent) { +func RestoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallContent) { position := make(map[string]int, len(calls)) for index, call := range calls { position[call.ToolCallID] = index @@ -167,7 +119,7 @@ func restoreToolCallOrder(content []fantasy.Content, calls []fantasy.ToolCallCon } } -func userPromptOverride(result *hookResult) (string, bool, error) { +func UserPromptOverride(result *Result) (string, bool, error) { if result == nil || len(result.InputOverride) == 0 { return "", false, nil } @@ -188,15 +140,15 @@ func userPromptOverride(result *hookResult) (string, bool, error) { return *override.Prompt, true, nil } -func userPromptHookParts(result *hookResult) []codersdk.ChatMessagePart { +func UserPromptParts(result *Result) []codersdk.ChatMessagePart { parts := make([]codersdk.ChatMessagePart, 0, 2) - if result.modelContext() != "" { + if result.GetModelContext() != "" { parts = append(parts, codersdk.ChatMessagePart{ Type: codersdk.ChatMessagePartTypeHookContext, Text: result.ModelContext, }) } - if result.userMessage() != "" { + if result.GetUserMessage() != "" { parts = append(parts, codersdk.ChatMessagePart{ Type: codersdk.ChatMessagePartTypeHookNotice, Text: result.UserMessage, @@ -205,12 +157,12 @@ func userPromptHookParts(result *hookResult) []codersdk.ChatMessagePart { return parts } -// composeUserPromptContent applies a user_prompt_submit result to the +// ComposeUserPromptContent applies a user_prompt_submit result to the // submitted parts. The merge order is fixed: override-or-original user // parts first, then hook-context, then hook-notice. The composite // content then flows through the ordinary send, queue, and edit paths. -func composeUserPromptContent(parts []codersdk.ChatMessagePart, result *hookResult) ([]codersdk.ChatMessagePart, bool, error) { - override, overridden, err := userPromptOverride(result) +func ComposeUserPromptContent(parts []codersdk.ChatMessagePart, result *Result) ([]codersdk.ChatMessagePart, bool, error) { + override, overridden, err := UserPromptOverride(result) if err != nil { return nil, false, err } @@ -218,7 +170,7 @@ func composeUserPromptContent(parts []codersdk.ChatMessagePart, result *hookResu if overridden { userParts = []codersdk.ChatMessagePart{codersdk.ChatMessageText(override)} } - hookParts := userPromptHookParts(result) + hookParts := UserPromptParts(result) if len(hookParts) == 0 { return userParts, overridden, nil } diff --git a/coderd/x/chatd/chathooks/errors.go b/coderd/x/chatd/chathooks/errors.go new file mode 100644 index 00000000000..b1cb4e3d327 --- /dev/null +++ b/coderd/x/chatd/chathooks/errors.go @@ -0,0 +1,117 @@ +package chathooks + +import ( + "errors" + "fmt" + + "charm.land/fantasy" + + "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" + "github.com/coder/coder/v2/codersdk" +) + +// deniedError is trigger's normalized form of a permission deny. +// Callers translate it per event: user_prompt_submit sites map it to +// UserPromptDeniedError, pre_tool_use sites fold it into a synthetic +// tool result. +type deniedError struct { + Event hooks.EventType + Reason string + ModelContext string + UserMessage string +} + +func (e *deniedError) Error() string { + if e.Reason == "" { + return fmt.Sprintf("%s denied by lifecycle hook", e.Event) + } + return fmt.Sprintf("%s denied by lifecycle hook: %s", e.Event, e.Reason) +} + +// UserPromptDeniedError reports that a lifecycle hook rejected a prompt. +type UserPromptDeniedError struct { + UserMessage string +} + +// Error includes UserMessage so callers that only surface the error +// string, such as subagent tool responses, still expose the user-facing +// denial message. The HTTP handlers unwrap the typed error instead. +func (e *UserPromptDeniedError) Error() string { + if e.UserMessage == "" { + return "user prompt denied by lifecycle hook" + } + return "user prompt denied by lifecycle hook: " + e.UserMessage +} + +func UserPromptDenial(err error) error { + if denied, ok := errors.AsType[*deniedError](err); ok { + return &UserPromptDeniedError{UserMessage: denied.UserMessage} + } + return err +} + +func DispatchErrorMessage(eventType hooks.EventType, dispatchErr error) (string, bool) { + structured, ok := errors.AsType[*dispatch.Error](dispatchErr) + if !ok { + return "", false + } + return fmt.Sprintf( + "hook dispatch failed: %s: %s (dispatch %s)", + eventType, + structured.Class, + structured.DispatchID, + ), true +} + +func GenerationDispatchError(eventType hooks.EventType, dispatchErr error) error { + message, ok := DispatchErrorMessage(eventType, dispatchErr) + if !ok { + message = dispatchErr.Error() + } + return chaterror.WithClassification(dispatchErr, chaterror.ClassifiedError{ + Message: message, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) +} + +// DispatchFailureFromResults returns the first tool result error +// whose chain contains a hook dispatch failure. Tools that dispatch +// lifecycle hooks inside Run (subagent spawn admission) must fail +// closed, but the tool loop persists Run errors as ordinary tool +// results the model can ignore, so the step has to be failed before +// commit instead. +func DispatchFailureFromResults(content []fantasy.Content) error { + for _, block := range content { + toolResult, ok := asToolResultContent(block) + if !ok { + continue + } + var resultErr error + switch output := toolResult.Result.(type) { + case fantasy.ToolResultOutputContentError: + resultErr = output.Error + case *fantasy.ToolResultOutputContentError: + if output != nil { + resultErr = output.Error + } + } + if resultErr != nil { + if _, ok := errors.AsType[*dispatch.Error](resultErr); ok { + return resultErr + } + } + } + return nil +} + +func asToolResultContent(block fantasy.Content) (fantasy.ToolResultContent, bool) { + if tr, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok { + return tr, true + } + if tr, ok := fantasy.AsContentType[*fantasy.ToolResultContent](block); ok && tr != nil { + return *tr, true + } + return fantasy.ToolResultContent{}, false +} diff --git a/coderd/x/chatd/chathooks/hooks_internal_test.go b/coderd/x/chatd/chathooks/hooks_internal_test.go new file mode 100644 index 00000000000..b4a0805b667 --- /dev/null +++ b/coderd/x/chatd/chathooks/hooks_internal_test.go @@ -0,0 +1,261 @@ +package chathooks + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/coderd/x/hooks/dispatch" + "github.com/coder/coder/v2/testutil" +) + +func TestSessionStartDispatchSources(t *testing.T) { + t.Parallel() + + const secret = "test-hook-secret-32-bytes-minimum!!" + type received struct { + request hooks.Request + claims hooks.Claims + data hooks.SessionStartData + } + receivedCh := make(chan received, 2) + consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request hooks.Request + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + claims, err := hooks.Verify(r.Header.Get("Authorization"), []byte(secret)) + require.NoError(t, err) + var data hooks.SessionStartData + require.NoError(t, json.Unmarshal(request.Data, &data)) + receivedCh <- received{request: request, claims: claims, data: data} + _, err = w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + t.Cleanup(consumer.Close) + + db, _ := dbtestutil.NewDB(t) + dispatcher := dispatch.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + secret, + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + ) + trigger := NewTrigger(dispatcher) + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{OwnerID: user.ID, OrganizationID: org.ID, LastModelConfigID: model.ID}) + turnID := uuid.New() + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := trigger.Trigger(ctx, ChatFor(chat, &turnID), Message{Source: SessionStartSource(nil)}, hooks.EventSessionStart) + require.NoError(t, err) + _, err = trigger.Trigger(ctx, ChatFor(chat, &turnID), Message{Source: SessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, hooks.EventSessionStart) + require.NoError(t, err) + + startup := <-receivedCh + resume := <-receivedCh + require.Equal(t, hooks.EventSessionStart, startup.request.Type) + require.Equal(t, SessionStartSourceStartup, startup.data.Source) + require.Equal(t, startup.request.Meta.DispatchID, startup.claims.JTI) + require.Equal(t, hooks.EventSessionStart, resume.request.Type) + require.Equal(t, SessionStartSourceResume, resume.data.Source) + require.Equal(t, resume.request.Meta.DispatchID, resume.claims.JTI) + require.NotEqual(t, startup.claims.JTI, resume.claims.JTI) +} + +func TestRejectDuplicateToolUseIDs(t *testing.T) { + t.Parallel() + + require.NoError(t, rejectDuplicateToolUseIDs([]fantasy.ToolCallContent{ + {ToolCallID: "first", ToolName: "read_file", Input: `{}`}, + {ToolCallID: "second", ToolName: "execute", Input: `{}`}, + })) + require.ErrorContains(t, rejectDuplicateToolUseIDs([]fantasy.ToolCallContent{ + {ToolCallID: "duplicate", ToolName: "read_file", Input: `{}`}, + {ToolCallID: "duplicate", ToolName: "execute", Input: `{}`}, + }), "duplicate tool use ID") +} + +func newTestTrigger(t *testing.T, handler http.Handler) *Trigger { + t.Helper() + consumer := httptest.NewServer(handler) + t.Cleanup(consumer.Close) + return NewTrigger(dispatch.New( + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + consumer.Client(), + consumer.URL, + "test-hook-secret-32-bytes-minimum!!", + time.Second, + "test-deployment", + "test-version", + prometheus.NewRegistry(), + )) +} + +func TestHookTriggerDisabled(t *testing.T) { + t.Parallel() + + for name, trigger := range map[string]*Trigger{ + "NilTrigger": nil, + "NilDispatcher": NewTrigger(nil), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.False(t, trigger.Enabled()) + result, err := trigger.Trigger(t.Context(), Chat{ID: uuid.New()}, Message{}, hooks.EventStop) + require.NoError(t, err) + require.Empty(t, result.GetModelContext()) + require.Empty(t, result.GetUserMessage()) + require.Empty(t, result.InputOverride) + }) + } +} + +func TestHookTriggerDeny(t *testing.T) { + t.Parallel() + + trigger := newTestTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{ + "permission": {"decision": "deny", "reason": "policy"}, + "model_context": "try another tool", + "user_message": "blocked by policy" + }`)) + assert.NoError(t, err) + })) + ctx := testutil.Context(t, testutil.WaitShort) + result, err := trigger.Trigger(ctx, Chat{ID: uuid.New(), OwnerID: uuid.New()}, Message{ + ToolUseID: "call_1", + ToolName: "execute", + ToolInput: json.RawMessage(`{}`), + }, hooks.EventPreToolUse) + require.Nil(t, result) + var denied *deniedError + require.ErrorAs(t, err, &denied) + require.Equal(t, hooks.EventPreToolUse, denied.Event) + require.Equal(t, "policy", denied.Reason) + require.Equal(t, "try another tool", denied.ModelContext) + require.Equal(t, "blocked by policy", denied.UserMessage) +} + +func TestHookTriggerEventPayloads(t *testing.T) { + t.Parallel() + + requests := make(chan hooks.Request, 1) + trigger := newTestTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request hooks.Request + assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + requests <- request + _, err := w.Write([]byte(`{}`)) + assert.NoError(t, err) + })) + chat := Chat{ + ID: uuid.New(), + OwnerID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + ctx := testutil.Context(t, testutil.WaitShort) + dispatchEvent := func(t *testing.T, msg Message, event hooks.EventType) hooks.Request { + t.Helper() + _, err := trigger.Trigger(ctx, chat, msg, event) + require.NoError(t, err) + request := <-requests + require.Equal(t, event, request.Type) + require.Equal(t, chat.ID, request.Meta.ChatID) + require.Equal(t, chat.OwnerID, request.Meta.OwnerID) + require.NotNil(t, request.Meta.WorkspaceID) + require.Equal(t, chat.WorkspaceID.UUID, *request.Meta.WorkspaceID) + return request + } + + sessionStart := dispatchEvent(t, Message{Source: SessionStartSourceClear}, hooks.EventSessionStart) + var sessionStartData hooks.SessionStartData + require.NoError(t, json.Unmarshal(sessionStart.Data, &sessionStartData)) + require.Equal(t, SessionStartSourceClear, sessionStartData.Source) + + prompt := dispatchEvent(t, Message{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, hooks.EventUserPromptSubmit) + var promptData hooks.UserPromptSubmitData + require.NoError(t, json.Unmarshal(prompt.Data, &promptData)) + require.Equal(t, "hello", promptData.Prompt) + require.JSONEq(t, `[{"type":"text","text":"hello"}]`, string(promptData.Parts)) + + preToolUse := dispatchEvent(t, Message{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, hooks.EventPreToolUse) + var preToolUseData hooks.PreToolUseData + require.NoError(t, json.Unmarshal(preToolUse.Data, &preToolUseData)) + require.Equal(t, "call_1", preToolUseData.ToolUseID) + require.Equal(t, "execute", preToolUseData.ToolName) + require.JSONEq(t, `{"cmd":"ls"}`, string(preToolUseData.ToolInput)) + + postToolUse := dispatchEvent(t, Message{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, hooks.EventPostToolUse) + var postToolUseData hooks.PostToolUseData + require.NoError(t, json.Unmarshal(postToolUse.Data, &postToolUseData)) + require.Equal(t, "call_1", postToolUseData.ToolUseID) + require.Equal(t, "execute", postToolUseData.ToolName) + require.JSONEq(t, `{"ok":true}`, string(postToolUseData.ToolResponse)) + require.Equal(t, "boom", postToolUseData.ToolError) + + for _, event := range []hooks.EventType{hooks.EventPreCompact, hooks.EventPostCompact, hooks.EventStop} { + dispatchEvent(t, Message{}, event) + } + + _, err := trigger.Trigger(ctx, chat, Message{}, hooks.EventType("bogus")) + require.ErrorContains(t, err, "unsupported hook event") +} + +func TestRestoreToolCallOrder(t *testing.T) { + t.Parallel() + + calls := []fantasy.ToolCallContent{ + {ToolCallID: "call_a", ToolName: "write_file"}, + {ToolCallID: "call_b", ToolName: "read_file"}, + {ToolCallID: "call_c", ToolName: "execute"}, + } + content := []fantasy.Content{ + fantasy.ToolResultContent{ToolCallID: "call_c", ToolName: "execute"}, + fantasy.ToolResultContent{ToolCallID: "call_b", ToolName: "read_file"}, + fantasy.ToolResultContent{ToolCallID: "call_a", ToolName: "write_file"}, + } + RestoreToolCallOrder(content, calls) + gotIDs := make([]string, 0, len(content)) + for _, entry := range content { + result, ok := entry.(fantasy.ToolResultContent) + require.True(t, ok) + gotIDs = append(gotIDs, result.ToolCallID) + } + require.Equal(t, []string{"call_a", "call_b", "call_c"}, gotIDs) + + mixed := []fantasy.Content{ + fantasy.ToolResultContent{ToolCallID: "call_b", ToolName: "read_file"}, + fantasy.TextContent{Text: "note"}, + fantasy.ToolResultContent{ToolCallID: "unknown", ToolName: "other"}, + fantasy.ToolResultContent{ToolCallID: "call_a", ToolName: "write_file"}, + } + RestoreToolCallOrder(mixed, calls) + first, ok := mixed[0].(fantasy.ToolResultContent) + require.True(t, ok) + require.Equal(t, "call_a", first.ToolCallID) + _, ok = mixed[1].(fantasy.TextContent) + require.True(t, ok) + unknown, ok := mixed[2].(fantasy.ToolResultContent) + require.True(t, ok) + require.Equal(t, "unknown", unknown.ToolCallID) + last, ok := mixed[3].(fantasy.ToolResultContent) + require.True(t, ok) + require.Equal(t, "call_b", last.ToolCallID) +} diff --git a/coderd/x/chatd/hook_tooluse.go b/coderd/x/chatd/chathooks/tooluse.go similarity index 54% rename from coderd/x/chatd/hook_tooluse.go rename to coderd/x/chatd/chathooks/tooluse.go index 5a112e2ac14..3b1ea3de4da 100644 --- a/coderd/x/chatd/hook_tooluse.go +++ b/coderd/x/chatd/chathooks/tooluse.go @@ -1,11 +1,10 @@ -package chatd +package chathooks import ( "bytes" "context" "encoding/json" "errors" - "fmt" "charm.land/fantasy" "github.com/google/uuid" @@ -35,44 +34,44 @@ func rejectDuplicateToolUseIDs(toolCalls []fantasy.ToolCallContent) error { return nil } -// preToolUseExecutionResult preserves hook results in tool-call order for +// PreToolUseExecutionResult preserves hook results in tool-call order for // transcript injection. -type preToolUseExecutionResult struct { +type PreToolUseExecutionResult struct { Allowed []fantasy.ToolCallContent Denied []fantasy.ToolResultContent - Results []*hookResult + Results []*Result Overrides map[string]json.RawMessage } -func (t *hookTrigger) preflightPendingToolCalls( +func (t *Trigger) PreflightPendingToolCalls( ctx context.Context, - chat hookChat, + chat Chat, toolCalls []fantasy.ToolCallContent, -) (preToolUseExecutionResult, error) { - if !t.enabled() { - return preToolUseExecutionResult{Allowed: toolCalls}, nil +) (PreToolUseExecutionResult, error) { + if !t.Enabled() { + return PreToolUseExecutionResult{Allowed: toolCalls}, nil } - result := preToolUseExecutionResult{ + result := PreToolUseExecutionResult{ Allowed: make([]fantasy.ToolCallContent, 0, len(toolCalls)), } if err := rejectDuplicateToolUseIDs(toolCalls); err != nil { - return preToolUseExecutionResult{}, err + return PreToolUseExecutionResult{}, err } for _, toolCall := range toolCalls { - callResult, err := t.trigger(ctx, chat, hookMessage{ + callResult, err := t.Trigger(ctx, chat, Message{ ToolUseID: toolCall.ToolCallID, ToolName: toolCall.ToolName, ToolInput: json.RawMessage(toolCall.Input), }, hooks.EventPreToolUse) if err != nil { - denied, ok := errors.AsType[*hookDeniedError](err) + denied, ok := errors.AsType[*deniedError](err) if !ok { - return preToolUseExecutionResult{}, err + return PreToolUseExecutionResult{}, err } // The denial's model context folds into the synthetic tool // result; only the user notice needs a transcript row. - result.Results = append(result.Results, &hookResult{UserMessage: denied.UserMessage}) + result.Results = append(result.Results, &Result{UserMessage: denied.UserMessage}) result.Denied = append(result.Denied, deniedToolResult(toolCall, denied.Reason, denied.ModelContext)) continue } @@ -89,8 +88,8 @@ func (t *hookTrigger) preflightPendingToolCalls( return result, nil } -func postToolUseMessage(toolResult fantasy.ToolResultContent) (hookMessage, error) { - msg := hookMessage{ +func postToolUseMessage(toolResult fantasy.ToolResultContent) (Message, error) { + msg := Message{ ToolUseID: toolResult.ToolCallID, ToolName: toolResult.ToolName, } @@ -106,22 +105,22 @@ func postToolUseMessage(toolResult fantasy.ToolResultContent) (hookMessage, erro default: encoded, err := json.Marshal(toolResult.Result) if err != nil { - return hookMessage{}, xerrors.Errorf("marshal post_tool_use response: %w", err) + return Message{}, xerrors.Errorf("marshal post_tool_use response: %w", err) } msg.ToolResponse = encoded } return msg, nil } -func (t *hookTrigger) postToolUseResults( +func (t *Trigger) PostToolUseResults( ctx context.Context, - chat hookChat, + chat Chat, content []fantasy.Content, -) ([]*hookResult, error) { - if !t.enabled() { +) ([]*Result, error) { + if !t.Enabled() { return nil, nil } - results := make([]*hookResult, 0, len(content)) + results := make([]*Result, 0, len(content)) var firstErr error for _, block := range content { toolResult, ok := asToolResultContent(block) @@ -135,7 +134,7 @@ func (t *hookTrigger) postToolUseResults( } continue } - result, err := t.trigger(ctx, chat, msg, hooks.EventPostToolUse) + result, err := t.Trigger(ctx, chat, msg, hooks.EventPostToolUse) if err != nil { if firstErr == nil { firstErr = err @@ -147,7 +146,7 @@ func (t *hookTrigger) postToolUseResults( return results, firstErr } -func replacePersistedToolCallInputs( +func ReplacePersistedToolCallInputs( ctx context.Context, tx *chatstate.Tx, chatID uuid.UUID, @@ -187,97 +186,8 @@ func replacePersistedToolCallInputs( return nil } -type dynamicPostToolUseState struct { - chat database.Chat - modelConfigID uuid.UUID - toolNames map[string]string -} - -func loadDynamicPostToolUseState( - ctx context.Context, - machine *chatstate.ChatMachine, - opts SubmitToolResultsOptions, -) (dynamicPostToolUseState, error) { - var state dynamicPostToolUseState - err := machine.ReadLock(ctx, func(store database.Store) error { - chat, err := store.GetChatByID(ctx, opts.ChatID) - if err != nil { - return xerrors.Errorf("load chat: %w", err) - } - if chat.Archived { - return ErrChatArchived - } - if chat.Status != database.ChatStatusRequiresAction { - return &ToolResultStatusConflictError{ActualStatus: chat.Status} - } - messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: opts.ChatID, - AfterID: 0, - }) - if err != nil { - return xerrors.Errorf("load chat messages: %w", err) - } - _, pending, err := unresolvedToolCallsFromHistory(messages, dynamicToolNamesFromChat(chat)) - if err != nil { - return xerrors.Errorf("load pending dynamic tool calls: %w", err) - } - toolNames := make(map[string]string, len(pending)) - for _, call := range pending { - toolNames[call.ToolCallID] = call.ToolName - } - if err := validateSubmittedToolResults(opts.Results, toolNames); err != nil { - return err - } - modelConfigID := opts.ModelConfigID - if modelConfigID == uuid.Nil { - modelConfigID = chat.LastModelConfigID - } - state = dynamicPostToolUseState{ - chat: chat, - modelConfigID: modelConfigID, - toolNames: toolNames, - } - return nil - }) - return state, err -} - -func validateSubmittedToolResults(results []codersdk.ToolResult, toolNames map[string]string) error { - submitted := make(map[string]struct{}, len(results)) - for _, result := range results { - if _, ok := submitted[result.ToolCallID]; ok { - return &ToolResultValidationError{ - Message: "Duplicate tool_call_id in results.", - Detail: fmt.Sprintf("Duplicate tool call ID %q.", result.ToolCallID), - } - } - if !json.Valid(result.Output) { - return &ToolResultValidationError{ - Message: "Tool result output must be valid JSON.", - Detail: fmt.Sprintf("Output for tool call %q is not valid JSON.", result.ToolCallID), - } - } - if _, ok := toolNames[result.ToolCallID]; !ok { - return &ToolResultValidationError{ - Message: "Unexpected tool result.", - Detail: fmt.Sprintf("No pending tool call with ID %q.", result.ToolCallID), - } - } - submitted[result.ToolCallID] = struct{}{} - } - for toolCallID := range toolNames { - if _, ok := submitted[toolCallID]; !ok { - return &ToolResultValidationError{ - Message: "Missing tool result.", - Detail: fmt.Sprintf("Missing result for tool call %q.", toolCallID), - } - } - } - return nil -} - -func dynamicPostToolUseMessage(result codersdk.ToolResult, toolName string) hookMessage { - msg := hookMessage{ +func DynamicPostToolUseMessage(result codersdk.ToolResult, toolName string) Message { + msg := Message{ ToolUseID: result.ToolCallID, ToolName: toolName, } diff --git a/coderd/x/chatd/hook_trigger.go b/coderd/x/chatd/chathooks/trigger.go similarity index 67% rename from coderd/x/chatd/hook_trigger.go rename to coderd/x/chatd/chathooks/trigger.go index 0185779f3aa..a17f8aab14e 100644 --- a/coderd/x/chatd/hook_trigger.go +++ b/coderd/x/chatd/chathooks/trigger.go @@ -1,8 +1,12 @@ -package chatd +// Package chathooks integrates chat lifecycle hooks into chatd: it +// builds event envelopes, dispatches them, and converts consumer +// responses into transcript effects and permission decisions. +package chathooks import ( "context" "encoding/json" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" @@ -15,39 +19,39 @@ import ( ) const ( - sessionStartSourceStartup = "startup" - sessionStartSourceResume = "resume" - sessionStartSourceClear = "clear" + SessionStartSourceStartup = "startup" + SessionStartSourceResume = "resume" + SessionStartSourceClear = "clear" ) -func sessionStartSource(messages []database.ChatMessage) string { +func SessionStartSource(messages []database.ChatMessage) string { for _, message := range messages { if message.Role == database.ChatMessageRoleAssistant { - return sessionStartSourceResume + return SessionStartSourceResume } } - return sessionStartSourceStartup + return SessionStartSourceStartup } -// hookTrigger is the only component that talks to the hook dispatcher. +// Trigger is the only component that talks to the hook dispatcher. // Every lifecycle event flows through trigger, which builds the wire // envelope, dispatches, and normalizes the outcome. -type hookTrigger struct { +type Trigger struct { dispatcher *dispatch.Dispatcher } -func newHookTrigger(dispatcher *dispatch.Dispatcher) *hookTrigger { - return &hookTrigger{dispatcher: dispatcher} +func NewTrigger(dispatcher *dispatch.Dispatcher) *Trigger { + return &Trigger{dispatcher: dispatcher} } -func (t *hookTrigger) enabled() bool { +func (t *Trigger) Enabled() bool { return t != nil && t.dispatcher.Enabled() } -// hookChat identifies the chat and turn an event belongs to. Admission +// Chat identifies the chat and turn an event belongs to. Admission // events for chats that do not exist yet (create, subagent spawn) fill // the fields directly instead of loading a row. -type hookChat struct { +type Chat struct { ID uuid.UUID OwnerID uuid.UUID WorkspaceID uuid.NullUUID @@ -56,8 +60,8 @@ type hookChat struct { TurnID *uuid.UUID } -func hookChatFor(chat database.Chat, turnID *uuid.UUID) hookChat { - return hookChat{ +func ChatFor(chat database.Chat, turnID *uuid.UUID) Chat { + return Chat{ ID: chat.ID, OwnerID: chat.OwnerID, WorkspaceID: chat.WorkspaceID, @@ -67,7 +71,7 @@ func hookChatFor(chat database.Chat, turnID *uuid.UUID) hookChat { } } -func (c hookChat) ref() hooks.ChatRef { +func (c Chat) ref() hooks.ChatRef { ref := hooks.ChatRef{ ChatID: c.ID, OwnerID: c.OwnerID, @@ -85,7 +89,7 @@ func (c hookChat) ref() hooks.ChatRef { return ref } -type hookMessage struct { +type Message struct { Source string Prompt string Parts json.RawMessage @@ -96,51 +100,51 @@ type hookMessage struct { ToolError string } -func userPromptHookMessage(parts []codersdk.ChatMessagePart) (hookMessage, error) { +func UserPromptMessage(parts []codersdk.ChatMessagePart) (Message, error) { encoded, err := chatprompt.MarshalParts(parts) if err != nil { - return hookMessage{}, xerrors.Errorf("marshal prompt parts for hook: %w", err) + return Message{}, xerrors.Errorf("marshal prompt parts for hook: %w", err) } - return hookMessage{ + return Message{ Prompt: textFromParts(parts), Parts: encoded.RawMessage, }, nil } -// hookResult is a consumer response normalized for callers: a non-empty +// Result is a consumer response normalized for callers: a non-empty // InputOverride means the permission decision was allow with a // replacement input (the wire contract rejects allow without one). -// Denials surface as *hookDeniedError instead. -type hookResult struct { +// Denials surface as *deniedError instead. +type Result struct { InputOverride json.RawMessage ModelContext string UserMessage string } -var emptyHookResult = &hookResult{} +var emptyResult = &Result{} -func (r *hookResult) modelContext() string { +func (r *Result) GetModelContext() string { if r == nil { return "" } return r.ModelContext } -func (r *hookResult) userMessage() string { +func (r *Result) GetUserMessage() string { if r == nil { return "" } return r.UserMessage } -func (t *hookTrigger) trigger( +func (t *Trigger) Trigger( ctx context.Context, - chat hookChat, - msg hookMessage, + chat Chat, + msg Message, event hooks.EventType, -) (*hookResult, error) { - if !t.enabled() { - return emptyHookResult, nil +) (*Result, error) { + if !t.Enabled() { + return emptyResult, nil } var data any switch event { @@ -170,14 +174,14 @@ func (t *hookTrigger) trigger( return nil, err } if response.Permission != nil && response.Permission.Decision == hooks.PermissionDeny { - return nil, &hookDeniedError{ + return nil, &deniedError{ Event: event, Reason: response.Permission.Reason, ModelContext: response.ModelContext, UserMessage: response.UserMessage, } } - result := &hookResult{ + result := &Result{ ModelContext: response.ModelContext, UserMessage: response.UserMessage, } @@ -186,3 +190,13 @@ func (t *hookTrigger) trigger( } return result, nil } + +func textFromParts(parts []codersdk.ChatMessagePart) string { + var builder strings.Builder + for _, part := range parts { + if part.Type == codersdk.ChatMessagePartTypeText { + _, _ = builder.WriteString(part.Text) + } + } + return builder.String() +} diff --git a/coderd/x/chatd/create_hooks_test.go b/coderd/x/chatd/create_hooks_test.go index 15a004afdfb..9255ec3a1d5 100644 --- a/coderd/x/chatd/create_hooks_test.go +++ b/coderd/x/chatd/create_hooks_test.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/coderd/x/hooks/dispatch" @@ -150,7 +151,7 @@ func TestCreateChatUserPromptSubmitHook(t *testing.T) { server, requests := newCreateHookTestServer(t, db, ps, http.StatusOK, `{"permission":{"decision":"deny"},"user_message":"blocked"}`) _, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "prompt")) - var denied *chatd.UserPromptDeniedError + var denied *chathooks.UserPromptDeniedError require.ErrorAs(t, err, &denied) require.Equal(t, "blocked", denied.UserMessage) request := testutil.RequireReceive(ctx, t, requests) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 60056f91f38..27b7fc719d4 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatretry" @@ -339,13 +340,13 @@ func applySessionStartResponse( machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, chat database.Chat, - result *hookResult, + result *chathooks.Result, ) (sessionStartResult, error) { - if result.modelContext() == "" && result.userMessage() == "" { + if result.GetModelContext() == "" && result.GetUserMessage() == "" { return sessionStartResult{Chat: chat}, nil } - eventMessages, err := hookEventMessages(result, chat.LastModelConfigID) + eventMessages, err := chathooks.EventMessages(result, chat.LastModelConfigID) if err != nil { return sessionStartResult{}, err } @@ -391,9 +392,9 @@ func (s *taskStarter) startGenerationSession( // Re-arm the claim until its response is applied so a replacement task // can replay session_start effects. defer func() { complete(completed) }() - response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{Source: sessionStartSource(messages)}, hooks.EventSessionStart) + response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{Source: chathooks.SessionStartSource(messages)}, hooks.EventSessionStart) if err != nil { - return sessionStartResult{}, true, generationHookDispatchError(hooks.EventSessionStart, err) + return sessionStartResult{}, true, chathooks.GenerationDispatchError(hooks.EventSessionStart, err) } result, err = applySessionStartResponse(ctx, machine, input, chat, response) if err != nil { @@ -416,7 +417,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS if err != nil { return xerrors.Errorf("load generation state: %w", err) } - if s.server.hooks.enabled() { + if s.server.hooks.Enabled() { result, dispatched, err := s.startGenerationSession(ctx, machine, input, chat, messages) if err != nil { if errors.Is(err, errTaskExpectedExit) { @@ -490,10 +491,10 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS Input: toolCall.Args, }) } - preflight, err := s.server.hooks.preflightPendingToolCalls(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), toolCalls) + preflight, err := s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), toolCalls) if err != nil { cleanup() - return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(hooks.EventPreToolUse, err), generationAttemptNotRequired) + return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(hooks.EventPreToolUse, err), generationAttemptNotRequired) } if len(preflight.Denied) == 0 { cleanup() @@ -769,7 +770,7 @@ func (s *taskStarter) commitPreToolUseDeniedResults( machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, prepared generationPrepared, - preflight preToolUseExecutionResult, + preflight chathooks.PreToolUseExecutionResult, ) error { attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { @@ -807,9 +808,9 @@ func (s *taskStarter) executeLocalTools( prepared generationPrepared, decision generationDecision, ) error { - preflight, err := s.server.hooks.preflightPendingToolCalls(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) + preflight, err := s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) if err != nil { - return generationHookDispatchError(hooks.EventPreToolUse, err) + return chathooks.GenerationDispatchError(hooks.EventPreToolUse, err) } attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { @@ -846,15 +847,15 @@ func (s *taskStarter) executeLocalTools( // Subagent spawn admission dispatches user_prompt_submit inside // the tool run; its failure surfaces as a tool result error and // must fail the step instead of committing. - if hookErr := hookDispatchFailureFromResults(outcome.Step.Content); hookErr != nil { - return generationHookDispatchError(hooks.EventUserPromptSubmit, hookErr) + if hookErr := chathooks.DispatchFailureFromResults(outcome.Step.Content); hookErr != nil { + return chathooks.GenerationDispatchError(hooks.EventUserPromptSubmit, hookErr) } } - postResults, postDispatchErr := s.server.hooks.postToolUseResults(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), outcome.Step.Content) + postResults, postDispatchErr := s.server.hooks.PostToolUseResults(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), outcome.Step.Content) for _, denied := range preflight.Denied { outcome.Step.Content = append(outcome.Step.Content, denied) } - restoreToolCallOrder(outcome.Step.Content, decision.localToolCalls) + chathooks.RestoreToolCallOrder(outcome.Step.Content, decision.localToolCalls) messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{ modelConfigID: prepared.ModelConfigID, modelCallConfig: prepared.ModelConfig, @@ -876,7 +877,7 @@ func (s *taskStarter) executeLocalTools( } var postCommitErr error if postDispatchErr != nil { - postCommitErr = generationHookDispatchError(hooks.EventPostToolUse, postDispatchErr) + postCommitErr = chathooks.GenerationDispatchError(hooks.EventPostToolUse, postDispatchErr) } return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ Overrides: preflight.Overrides, @@ -934,11 +935,11 @@ func (s *taskStarter) generateCompaction( overrideModel.modelConfig, ) } - preResult, err := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, hooks.EventPreCompact) + preResult, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, hooks.EventPreCompact) if err != nil { - return generationHookDispatchError(hooks.EventPreCompact, err) + return chathooks.GenerationDispatchError(hooks.EventPreCompact, err) } - compactionOpts.SummaryHint = preResult.modelContext() + compactionOpts.SummaryHint = preResult.GetModelContext() compactionOpts.PublishMessagePart = attempt.publish compactionOpts.Source = source compactionOpts.Force = source == chatloop.CompactionSourceManual @@ -968,12 +969,12 @@ func (s *taskStarter) generateCompaction( return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } // The summary hint already consumed the pre_compact model context. - persistedPreResult := &hookResult{UserMessage: preResult.userMessage()} + persistedPreResult := &chathooks.Result{UserMessage: preResult.GetUserMessage()} commitMessages, err := applyHookResultMessages(stepMessagesForCommit{ Messages: messages.Messages, VisibleIndexes: visibleMessageIndexes(messages.Messages), ConsumeCompactionRequest: true, - }, []*hookResult{persistedPreResult}, prepared.ModelConfigID) + }, []*chathooks.Result{persistedPreResult}, prepared.ModelConfigID) if err != nil { s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) @@ -981,12 +982,12 @@ func (s *taskStarter) generateCompaction( // Hook effects and fail-closed errors must commit atomically with // compaction; a separate commit races the runner and can be dropped // on crash. - postResult, postDispatchErr := s.server.hooks.trigger(ctx, hookChatFor(prepared.Chat, input.hookTurnID()), hookMessage{}, hooks.EventPostCompact) + postResult, postDispatchErr := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, hooks.EventPostCompact) var postCommitErr error if postDispatchErr != nil { - postCommitErr = generationHookDispatchError(hooks.EventPostCompact, postDispatchErr) + postCommitErr = chathooks.GenerationDispatchError(hooks.EventPostCompact, postDispatchErr) } else { - commitMessages, err = appendHookResultMessages(commitMessages, []*hookResult{postResult}, prepared.ModelConfigID) + commitMessages, err = appendHookResultMessages(commitMessages, []*chathooks.Result{postResult}, prepared.ModelConfigID) if err != nil { s.server.metrics.RecordCompaction(metricProvider, metricModel, false, err) return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) @@ -1125,7 +1126,7 @@ func (s *taskStarter) commitGenerationStep( if _, err := loadChatForGeneration(ctx, store, input, requireGenerationAttempt(attempt)); err != nil { return xerrors.Errorf("load chat for generation: %w", err) } - if err := replacePersistedToolCallInputs(ctx, tx, input.ChatID, commitHooks.Overrides); err != nil { + if err := chathooks.ReplacePersistedToolCallInputs(ctx, tx, input.ChatID, commitHooks.Overrides); err != nil { return err } commitResult, err := tx.CommitStep(chatstate.CommitStepInput{ @@ -1185,7 +1186,7 @@ func (s *taskStarter) enterRequiresAction( machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, prepared generationPrepared, - preflight preToolUseExecutionResult, + preflight chathooks.PreToolUseExecutionResult, ) error { messages, err := applyHookResultMessages(stepMessagesForCommit{}, preflight.Results, prepared.ModelConfigID) if err != nil { @@ -1197,7 +1198,7 @@ func (s *taskStarter) enterRequiresAction( if _, err := loadChatForTask(ctx, store, input, database.ChatStatusRunning, taskFenceOptions{requireHistory: true}); err != nil { return xerrors.Errorf("load chat for task: %w", err) } - if err := replacePersistedToolCallInputs(ctx, tx, input.ChatID, preflight.Overrides); err != nil { + if err := chathooks.ReplacePersistedToolCallInputs(ctx, tx, input.ChatID, preflight.Overrides); err != nil { return err } var inserted []database.ChatMessage @@ -1345,7 +1346,7 @@ func (s *taskStarter) finishGenerationTurn( decision generationDecision, fence generationAttemptFence, ) error { - if !s.server.hooks.enabled() { + if !s.server.hooks.Enabled() { return s.finishGenerationTurnWithoutHook(ctx, machine, input, decision, fence) } var chat database.Chat @@ -1369,16 +1370,16 @@ func (s *taskStarter) finishGenerationTurn( if err != nil { return normalizeTaskTransitionError(err, "load stop hook state") } - response, err := s.server.hooks.trigger(ctx, hookChatFor(chat, input.hookTurnID()), hookMessage{}, hooks.EventStop) + response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{}, hooks.EventStop) if err != nil { - return s.finishGenerationError(ctx, machine, input, generationHookDispatchError(hooks.EventStop, err), fence) + return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(hooks.EventStop, err), fence) } - stopMessages, err := hookEventMessages(response, chat.LastModelConfigID) + stopMessages, err := chathooks.EventMessages(response, chat.LastModelConfigID) if err != nil { return s.finishGenerationError(ctx, machine, input, err, fence) } nudgeKey := stopNudgeKey(messages) - continueTurn := response.modelContext() != "" && input.StopNudges.claim(nudgeKey) + continueTurn := response.GetModelContext() != "" && input.StopNudges.claim(nudgeKey) var committed database.Chat err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { diff --git a/coderd/x/chatd/hook_errors.go b/coderd/x/chatd/hook_errors.go deleted file mode 100644 index c514074402a..00000000000 --- a/coderd/x/chatd/hook_errors.go +++ /dev/null @@ -1,165 +0,0 @@ -package chatd - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "charm.land/fantasy" - "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/x/chatd/chaterror" - "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" - "github.com/coder/coder/v2/codersdk" -) - -// hookDeniedError is trigger's normalized form of a permission deny. -// Callers translate it per event: user_prompt_submit sites map it to -// UserPromptDeniedError, pre_tool_use sites fold it into a synthetic -// tool result. -type hookDeniedError struct { - Event hooks.EventType - Reason string - ModelContext string - UserMessage string -} - -func (e *hookDeniedError) Error() string { - if e.Reason == "" { - return fmt.Sprintf("%s denied by lifecycle hook", e.Event) - } - return fmt.Sprintf("%s denied by lifecycle hook: %s", e.Event, e.Reason) -} - -// UserPromptDeniedError reports that a lifecycle hook rejected a prompt. -type UserPromptDeniedError struct { - UserMessage string -} - -// Error includes UserMessage so callers that only surface the error -// string, such as subagent tool responses, still expose the user-facing -// denial message. The HTTP handlers unwrap the typed error instead. -func (e *UserPromptDeniedError) Error() string { - if e.UserMessage == "" { - return "user prompt denied by lifecycle hook" - } - return "user prompt denied by lifecycle hook: " + e.UserMessage -} - -func userPromptDenial(err error) error { - if denied, ok := errors.AsType[*hookDeniedError](err); ok { - return &UserPromptDeniedError{UserMessage: denied.UserMessage} - } - return err -} - -func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { - return p.handleAPIDispatchError(ctx, chatID, hooks.EventUserPromptSubmit, dispatchErr) -} - -func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType hooks.EventType, dispatchErr error) error { - lastError, ok := hookDispatchErrorMessage(eventType, dispatchErr) - if !ok { - return dispatchErr - } - encoded, marshalErr := json.Marshal(codersdk.ChatError{ - Message: lastError, - Kind: codersdk.ChatErrorKindHookDispatchFailed, - }) - if marshalErr != nil { - return errors.Join(dispatchErr, xerrors.Errorf("encode hook dispatch error: %w", marshalErr)) - } - var failedChat database.Chat - machine := p.newChatMachine(chatID) - err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { - current, err := store.GetChatByID(ctx, chatID) - if err != nil { - return xerrors.Errorf("load chat for hook failure: %w", err) - } - // Park only idle chats. FinishError is also allowed from running - // states, but a running chat keeps its active turn and the - // request error alone surfaces to the caller. - if current.Status != database.ChatStatusWaiting { - return chatstate.ErrTransitionNotAllowed - } - if _, err := tx.FinishError(chatstate.FinishErrorInput{ - LastError: pqtype.NullRawMessage{RawMessage: encoded, Valid: true}, - }); err != nil { - return err - } - chat, err := store.GetChatByID(ctx, chatID) - if err != nil { - return xerrors.Errorf("reload chat after hook failure: %w", err) - } - failedChat = chat - return nil - }) - if errors.Is(err, chatstate.ErrTransitionNotAllowed) { - return dispatchErr - } - if err != nil { - return errors.Join(dispatchErr, xerrors.Errorf("fail idle chat after hook dispatch: %w", err)) - } - p.publishChatPubsubEvent(failedChat, codersdk.ChatWatchEventKindStatusChange, nil) - return dispatchErr -} - -func hookDispatchErrorMessage(eventType hooks.EventType, dispatchErr error) (string, bool) { - structured, ok := errors.AsType[*dispatch.Error](dispatchErr) - if !ok { - return "", false - } - return fmt.Sprintf( - "hook dispatch failed: %s: %s (dispatch %s)", - eventType, - structured.Class, - structured.DispatchID, - ), true -} - -func generationHookDispatchError(eventType hooks.EventType, dispatchErr error) error { - message, ok := hookDispatchErrorMessage(eventType, dispatchErr) - if !ok { - message = dispatchErr.Error() - } - return chaterror.WithClassification(dispatchErr, chaterror.ClassifiedError{ - Message: message, - Kind: codersdk.ChatErrorKindHookDispatchFailed, - }) -} - -// hookDispatchFailureFromResults returns the first tool result error -// whose chain contains a hook dispatch failure. Tools that dispatch -// lifecycle hooks inside Run (subagent spawn admission) must fail -// closed, but the tool loop persists Run errors as ordinary tool -// results the model can ignore, so the step has to be failed before -// commit instead. -func hookDispatchFailureFromResults(content []fantasy.Content) error { - for _, block := range content { - toolResult, ok := asToolResultContent(block) - if !ok { - continue - } - var resultErr error - switch output := toolResult.Result.(type) { - case fantasy.ToolResultOutputContentError: - resultErr = output.Error - case *fantasy.ToolResultOutputContentError: - if output != nil { - resultErr = output.Error - } - } - if resultErr != nil { - if _, ok := errors.AsType[*dispatch.Error](resultErr); ok { - return resultErr - } - } - } - return nil -} diff --git a/coderd/x/chatd/hook_server.go b/coderd/x/chatd/hook_server.go new file mode 100644 index 00000000000..18e6859cecc --- /dev/null +++ b/coderd/x/chatd/hook_server.go @@ -0,0 +1,206 @@ +package chatd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/coderd/x/hooks" + "github.com/coder/coder/v2/codersdk" +) + +// applyHookResultMessages inserts hook event rows before the step's +// own rows so injected model context precedes the assistant content it +// steers; providers require tool results to directly follow the +// assistant tool calls. +func applyHookResultMessages( + messages stepMessagesForCommit, + results []*chathooks.Result, + modelConfigID uuid.UUID, +) (stepMessagesForCommit, error) { + return insertHookResultMessages(messages, results, modelConfigID, hookRowsBeforeStep) +} + +func appendHookResultMessages( + messages stepMessagesForCommit, + results []*chathooks.Result, + modelConfigID uuid.UUID, +) (stepMessagesForCommit, error) { + return insertHookResultMessages(messages, results, modelConfigID, hookRowsAfterStep) +} + +type hookRowPlacement int + +const ( + hookRowsBeforeStep hookRowPlacement = iota + hookRowsAfterStep +) + +func insertHookResultMessages( + messages stepMessagesForCommit, + results []*chathooks.Result, + modelConfigID uuid.UUID, + placement hookRowPlacement, +) (stepMessagesForCommit, error) { + rows, err := chathooks.EventMessagesForResults(results, modelConfigID) + if err != nil { + return stepMessagesForCommit{}, err + } + if len(rows) > 0 { + if placement == hookRowsBeforeStep { + messages.Messages = append(rows, messages.Messages...) + } else { + messages.Messages = append(messages.Messages, rows...) + } + messages.VisibleIndexes = visibleMessageIndexes(messages.Messages) + } + return messages, nil +} + +func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { + return p.handleAPIDispatchError(ctx, chatID, hooks.EventUserPromptSubmit, dispatchErr) +} + +func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType hooks.EventType, dispatchErr error) error { + lastError, ok := chathooks.DispatchErrorMessage(eventType, dispatchErr) + if !ok { + return dispatchErr + } + encoded, marshalErr := json.Marshal(codersdk.ChatError{ + Message: lastError, + Kind: codersdk.ChatErrorKindHookDispatchFailed, + }) + if marshalErr != nil { + return errors.Join(dispatchErr, xerrors.Errorf("encode hook dispatch error: %w", marshalErr)) + } + var failedChat database.Chat + machine := p.newChatMachine(chatID) + err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + current, err := store.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("load chat for hook failure: %w", err) + } + // Park only idle chats. FinishError is also allowed from running + // states, but a running chat keeps its active turn and the + // request error alone surfaces to the caller. + if current.Status != database.ChatStatusWaiting { + return chatstate.ErrTransitionNotAllowed + } + if _, err := tx.FinishError(chatstate.FinishErrorInput{ + LastError: pqtype.NullRawMessage{RawMessage: encoded, Valid: true}, + }); err != nil { + return err + } + chat, err := store.GetChatByID(ctx, chatID) + if err != nil { + return xerrors.Errorf("reload chat after hook failure: %w", err) + } + failedChat = chat + return nil + }) + if errors.Is(err, chatstate.ErrTransitionNotAllowed) { + return dispatchErr + } + if err != nil { + return errors.Join(dispatchErr, xerrors.Errorf("fail idle chat after hook dispatch: %w", err)) + } + p.publishChatPubsubEvent(failedChat, codersdk.ChatWatchEventKindStatusChange, nil) + return dispatchErr +} + +type dynamicPostToolUseState struct { + chat database.Chat + modelConfigID uuid.UUID + toolNames map[string]string +} + +func loadDynamicPostToolUseState( + ctx context.Context, + machine *chatstate.ChatMachine, + opts SubmitToolResultsOptions, +) (dynamicPostToolUseState, error) { + var state dynamicPostToolUseState + err := machine.ReadLock(ctx, func(store database.Store) error { + chat, err := store.GetChatByID(ctx, opts.ChatID) + if err != nil { + return xerrors.Errorf("load chat: %w", err) + } + if chat.Archived { + return ErrChatArchived + } + if chat.Status != database.ChatStatusRequiresAction { + return &ToolResultStatusConflictError{ActualStatus: chat.Status} + } + messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: opts.ChatID, + AfterID: 0, + }) + if err != nil { + return xerrors.Errorf("load chat messages: %w", err) + } + _, pending, err := unresolvedToolCallsFromHistory(messages, dynamicToolNamesFromChat(chat)) + if err != nil { + return xerrors.Errorf("load pending dynamic tool calls: %w", err) + } + toolNames := make(map[string]string, len(pending)) + for _, call := range pending { + toolNames[call.ToolCallID] = call.ToolName + } + if err := validateSubmittedToolResults(opts.Results, toolNames); err != nil { + return err + } + modelConfigID := opts.ModelConfigID + if modelConfigID == uuid.Nil { + modelConfigID = chat.LastModelConfigID + } + state = dynamicPostToolUseState{ + chat: chat, + modelConfigID: modelConfigID, + toolNames: toolNames, + } + return nil + }) + return state, err +} + +func validateSubmittedToolResults(results []codersdk.ToolResult, toolNames map[string]string) error { + submitted := make(map[string]struct{}, len(results)) + for _, result := range results { + if _, ok := submitted[result.ToolCallID]; ok { + return &ToolResultValidationError{ + Message: "Duplicate tool_call_id in results.", + Detail: fmt.Sprintf("Duplicate tool call ID %q.", result.ToolCallID), + } + } + if !json.Valid(result.Output) { + return &ToolResultValidationError{ + Message: "Tool result output must be valid JSON.", + Detail: fmt.Sprintf("Output for tool call %q is not valid JSON.", result.ToolCallID), + } + } + if _, ok := toolNames[result.ToolCallID]; !ok { + return &ToolResultValidationError{ + Message: "Unexpected tool result.", + Detail: fmt.Sprintf("No pending tool call with ID %q.", result.ToolCallID), + } + } + submitted[result.ToolCallID] = struct{}{} + } + for toolCallID := range toolNames { + if _, ok := submitted[toolCallID]; !ok { + return &ToolResultValidationError{ + Message: "Missing tool result.", + Detail: fmt.Sprintf("Missing result for tool call %q.", toolCallID), + } + } + } + return nil +} diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go index 7232e475aad..2fc09ca77c7 100644 --- a/coderd/x/chatd/hooks_internal_test.go +++ b/coderd/x/chatd/hooks_internal_test.go @@ -8,82 +8,20 @@ import ( "testing" "time" - "charm.land/fantasy" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) -func TestSessionStartDispatchSources(t *testing.T) { - t.Parallel() - - const secret = "test-hook-secret-32-bytes-minimum!!" - type received struct { - request hooks.Request - claims hooks.Claims - data hooks.SessionStartData - } - receivedCh := make(chan received, 2) - consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request - require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - claims, err := hooks.Verify(r.Header.Get("Authorization"), []byte(secret)) - require.NoError(t, err) - var data hooks.SessionStartData - require.NoError(t, json.Unmarshal(request.Data, &data)) - receivedCh <- received{request: request, claims: claims, data: data} - _, err = w.Write([]byte(`{}`)) - require.NoError(t, err) - })) - t.Cleanup(consumer.Close) - - db, _ := dbtestutil.NewDB(t) - dispatcher := dispatch.New( - slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - consumer.Client(), - consumer.URL, - secret, - time.Second, - "test-deployment", - "test-version", - prometheus.NewRegistry(), - ) - server := &Server{hooks: newHookTrigger(dispatcher)} - user := dbgen.User(t, db, database.User{}) - org := dbgen.Organization(t, db, database.Organization{}) - model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) - chat := dbgen.Chat(t, db, database.Chat{OwnerID: user.ID, OrganizationID: org.ID, LastModelConfigID: model.ID}) - turnID := uuid.New() - ctx := testutil.Context(t, testutil.WaitLong) - - _, err := server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource(nil)}, hooks.EventSessionStart) - require.NoError(t, err) - _, err = server.hooks.trigger(ctx, hookChatFor(chat, &turnID), hookMessage{Source: sessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, hooks.EventSessionStart) - require.NoError(t, err) - - startup := <-receivedCh - resume := <-receivedCh - require.Equal(t, hooks.EventSessionStart, startup.request.Type) - require.Equal(t, sessionStartSourceStartup, startup.data.Source) - require.Equal(t, startup.request.Meta.DispatchID, startup.claims.JTI) - require.Equal(t, hooks.EventSessionStart, resume.request.Type) - require.Equal(t, sessionStartSourceResume, resume.data.Source) - require.Equal(t, resume.request.Meta.DispatchID, resume.claims.JTI) - require.NotEqual(t, startup.claims.JTI, resume.claims.JTI) -} - func TestSessionStartTrackerRetriesIncompleteDispatch(t *testing.T) { t.Parallel() tracker := &sessionStartTracker{} @@ -128,7 +66,7 @@ func TestSessionStartDispatchFailureFinishesGeneration(t *testing.T) { prometheus.NewRegistry(), ) starter := newTestTaskStarter(t, f, newTaskSideEffectRecorder()) - starter.server.hooks = newHookTrigger(dispatcher) + starter.server.hooks = chathooks.NewTrigger(dispatcher) ctx := testutil.Context(t, testutil.WaitLong) debugTurn := newRunnerDebugTurn(ctx, starter.opts.Logger) defer debugTurn.Finalize(ctx) @@ -173,7 +111,7 @@ func TestApplySessionStartResponse(t *testing.T) { chatstate.NewChatMachine(f.db, f.pubsub, chat.ID), input, chat, - &hookResult{ + &chathooks.Result{ ModelContext: "model context", UserMessage: "user notice", }, @@ -216,183 +154,3 @@ func hookMessageTextInternal(t *testing.T, message database.ChatMessage) string require.Len(t, parts, 1) return parts[0].Text } - -func TestRejectDuplicateToolUseIDs(t *testing.T) { - t.Parallel() - - require.NoError(t, rejectDuplicateToolUseIDs([]fantasy.ToolCallContent{ - {ToolCallID: "first", ToolName: "read_file", Input: `{}`}, - {ToolCallID: "second", ToolName: "execute", Input: `{}`}, - })) - require.ErrorContains(t, rejectDuplicateToolUseIDs([]fantasy.ToolCallContent{ - {ToolCallID: "duplicate", ToolName: "read_file", Input: `{}`}, - {ToolCallID: "duplicate", ToolName: "execute", Input: `{}`}, - }), "duplicate tool use ID") -} - -func newTestHookTrigger(t *testing.T, handler http.Handler) *hookTrigger { - t.Helper() - consumer := httptest.NewServer(handler) - t.Cleanup(consumer.Close) - return newHookTrigger(dispatch.New( - slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - consumer.Client(), - consumer.URL, - "test-hook-secret-32-bytes-minimum!!", - time.Second, - "test-deployment", - "test-version", - prometheus.NewRegistry(), - )) -} - -func TestHookTriggerDisabled(t *testing.T) { - t.Parallel() - - for name, trigger := range map[string]*hookTrigger{ - "NilTrigger": nil, - "NilDispatcher": newHookTrigger(nil), - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - require.False(t, trigger.enabled()) - result, err := trigger.trigger(t.Context(), hookChat{ID: uuid.New()}, hookMessage{}, hooks.EventStop) - require.NoError(t, err) - require.Empty(t, result.modelContext()) - require.Empty(t, result.userMessage()) - require.Empty(t, result.InputOverride) - }) - } -} - -func TestHookTriggerDeny(t *testing.T) { - t.Parallel() - - trigger := newTestHookTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte(`{ - "permission": {"decision": "deny", "reason": "policy"}, - "model_context": "try another tool", - "user_message": "blocked by policy" - }`)) - assert.NoError(t, err) - })) - ctx := testutil.Context(t, testutil.WaitShort) - result, err := trigger.trigger(ctx, hookChat{ID: uuid.New(), OwnerID: uuid.New()}, hookMessage{ - ToolUseID: "call_1", - ToolName: "execute", - ToolInput: json.RawMessage(`{}`), - }, hooks.EventPreToolUse) - require.Nil(t, result) - var denied *hookDeniedError - require.ErrorAs(t, err, &denied) - require.Equal(t, hooks.EventPreToolUse, denied.Event) - require.Equal(t, "policy", denied.Reason) - require.Equal(t, "try another tool", denied.ModelContext) - require.Equal(t, "blocked by policy", denied.UserMessage) -} - -func TestHookTriggerEventPayloads(t *testing.T) { - t.Parallel() - - requests := make(chan hooks.Request, 1) - trigger := newTestHookTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request - assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - requests <- request - _, err := w.Write([]byte(`{}`)) - assert.NoError(t, err) - })) - chat := hookChat{ - ID: uuid.New(), - OwnerID: uuid.New(), - WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - } - ctx := testutil.Context(t, testutil.WaitShort) - dispatchEvent := func(t *testing.T, msg hookMessage, event hooks.EventType) hooks.Request { - t.Helper() - _, err := trigger.trigger(ctx, chat, msg, event) - require.NoError(t, err) - request := <-requests - require.Equal(t, event, request.Type) - require.Equal(t, chat.ID, request.Meta.ChatID) - require.Equal(t, chat.OwnerID, request.Meta.OwnerID) - require.NotNil(t, request.Meta.WorkspaceID) - require.Equal(t, chat.WorkspaceID.UUID, *request.Meta.WorkspaceID) - return request - } - - sessionStart := dispatchEvent(t, hookMessage{Source: sessionStartSourceClear}, hooks.EventSessionStart) - var sessionStartData hooks.SessionStartData - require.NoError(t, json.Unmarshal(sessionStart.Data, &sessionStartData)) - require.Equal(t, sessionStartSourceClear, sessionStartData.Source) - - prompt := dispatchEvent(t, hookMessage{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, hooks.EventUserPromptSubmit) - var promptData hooks.UserPromptSubmitData - require.NoError(t, json.Unmarshal(prompt.Data, &promptData)) - require.Equal(t, "hello", promptData.Prompt) - require.JSONEq(t, `[{"type":"text","text":"hello"}]`, string(promptData.Parts)) - - preToolUse := dispatchEvent(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, hooks.EventPreToolUse) - var preToolUseData hooks.PreToolUseData - require.NoError(t, json.Unmarshal(preToolUse.Data, &preToolUseData)) - require.Equal(t, "call_1", preToolUseData.ToolUseID) - require.Equal(t, "execute", preToolUseData.ToolName) - require.JSONEq(t, `{"cmd":"ls"}`, string(preToolUseData.ToolInput)) - - postToolUse := dispatchEvent(t, hookMessage{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, hooks.EventPostToolUse) - var postToolUseData hooks.PostToolUseData - require.NoError(t, json.Unmarshal(postToolUse.Data, &postToolUseData)) - require.Equal(t, "call_1", postToolUseData.ToolUseID) - require.Equal(t, "execute", postToolUseData.ToolName) - require.JSONEq(t, `{"ok":true}`, string(postToolUseData.ToolResponse)) - require.Equal(t, "boom", postToolUseData.ToolError) - - for _, event := range []hooks.EventType{hooks.EventPreCompact, hooks.EventPostCompact, hooks.EventStop} { - dispatchEvent(t, hookMessage{}, event) - } - - _, err := trigger.trigger(ctx, chat, hookMessage{}, hooks.EventType("bogus")) - require.ErrorContains(t, err, "unsupported hook event") -} - -func TestRestoreToolCallOrder(t *testing.T) { - t.Parallel() - - calls := []fantasy.ToolCallContent{ - {ToolCallID: "call_a", ToolName: "write_file"}, - {ToolCallID: "call_b", ToolName: "read_file"}, - {ToolCallID: "call_c", ToolName: "execute"}, - } - content := []fantasy.Content{ - fantasy.ToolResultContent{ToolCallID: "call_c", ToolName: "execute"}, - fantasy.ToolResultContent{ToolCallID: "call_b", ToolName: "read_file"}, - fantasy.ToolResultContent{ToolCallID: "call_a", ToolName: "write_file"}, - } - restoreToolCallOrder(content, calls) - gotIDs := make([]string, 0, len(content)) - for _, entry := range content { - result, ok := entry.(fantasy.ToolResultContent) - require.True(t, ok) - gotIDs = append(gotIDs, result.ToolCallID) - } - require.Equal(t, []string{"call_a", "call_b", "call_c"}, gotIDs) - - mixed := []fantasy.Content{ - fantasy.ToolResultContent{ToolCallID: "call_b", ToolName: "read_file"}, - fantasy.TextContent{Text: "note"}, - fantasy.ToolResultContent{ToolCallID: "unknown", ToolName: "other"}, - fantasy.ToolResultContent{ToolCallID: "call_a", ToolName: "write_file"}, - } - restoreToolCallOrder(mixed, calls) - first, ok := mixed[0].(fantasy.ToolResultContent) - require.True(t, ok) - require.Equal(t, "call_a", first.ToolCallID) - _, ok = mixed[1].(fantasy.TextContent) - require.True(t, ok) - unknown, ok := mixed[2].(fantasy.ToolResultContent) - require.True(t, ok) - require.Equal(t, "unknown", unknown.ToolCallID) - last, ok := mixed[3].(fantasy.ToolResultContent) - require.True(t, ok) - require.Equal(t, "call_b", last.ToolCallID) -} diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index 790172455f7..3808e5c0b80 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" @@ -104,7 +105,7 @@ func TestSendMessageUserPromptSubmitHook(t *testing.T) { CreatedBy: user.ID, Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("blocked prompt")}, }) - var denied *chatd.UserPromptDeniedError + var denied *chathooks.UserPromptDeniedError require.ErrorAs(t, err, &denied) require.Equal(t, "blocked", denied.UserMessage) @@ -274,7 +275,7 @@ func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { statusCode: http.StatusOK, response: `{"permission":{"decision":"deny"},"user_message":"blocked"}`, assertErr: func(t *testing.T, err error) { - var denied *chatd.UserPromptDeniedError + var denied *chathooks.UserPromptDeniedError require.ErrorAs(t, err, &denied) }, }, diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index cef41508baf..f3ff43fb495 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" @@ -776,7 +777,7 @@ func (p *Server) subagentTools( if _, ok := errors.AsType[*dispatch.Error](err); ok { return fantasy.ToolResponse{}, err } - // UserPromptDeniedError.Error() carries the user-facing + // chathooks.UserPromptDeniedError.Error() carries the user-facing // denial message, so the model can adjust its prompt. return fantasy.NewTextErrorResponse(err.Error()), nil } @@ -1287,14 +1288,14 @@ func (p *Server) createChildSubagentChatWithOptions( // Review before persistence so spawned chats cannot bypass prompt policy. childChatID := uuid.New() - var promptResult *hookResult - if p.hooks.enabled() { + var promptResult *chathooks.Result + if p.hooks.Enabled() { mintedTurnID := uuid.New() - promptMessage, err := userPromptHookMessage([]codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}) + promptMessage, err := chathooks.UserPromptMessage([]codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}) if err != nil { return database.Chat{}, err } - promptResult, err = p.hooks.trigger(ctx, hookChat{ + promptResult, err = p.hooks.Trigger(ctx, chathooks.Chat{ ID: childChatID, OwnerID: parent.OwnerID, WorkspaceID: parent.WorkspaceID, @@ -1303,9 +1304,9 @@ func (p *Server) createChildSubagentChatWithOptions( TurnID: &mintedTurnID, }, promptMessage, hooks.EventUserPromptSubmit) if err != nil { - return database.Chat{}, userPromptDenial(err) + return database.Chat{}, chathooks.UserPromptDenial(err) } - override, overridden, overrideErr := userPromptOverride(promptResult) + override, overridden, overrideErr := chathooks.UserPromptOverride(promptResult) if overrideErr != nil { return database.Chat{}, overrideErr } @@ -1329,7 +1330,7 @@ func (p *Server) createChildSubagentChatWithOptions( return database.Chat{}, xerrors.Errorf("marshal workspace awareness: %w", err) } childUserParts := []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)} - childUserParts = append(childUserParts, userPromptHookParts(promptResult)...) + childUserParts = append(childUserParts, chathooks.UserPromptParts(promptResult)...) userContent, err := chatprompt.MarshalParts(childUserParts) if err != nil { return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", err) diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 05bfdb6715f..3b869494595 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -31,6 +31,7 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" @@ -293,7 +294,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { server := &Server{ db: db, logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - hooks: newHookTrigger(dispatch.New( + hooks: chathooks.NewTrigger(dispatch.New( slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), consumer.Client(), consumer.URL, @@ -372,7 +373,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) { }) _, err := server.createChildSubagentChatWithOptions(ctx, parent, "exfiltrate secrets", "", childSubagentChatOptions{}) - var denied *UserPromptDeniedError + var denied *chathooks.UserPromptDeniedError require.ErrorAs(t, err, &denied) require.Equal(t, "not allowed", denied.UserMessage) From f085d1f0d46a97c58c94087bd54ea3edb91c49f2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:39:32 +0000 Subject: [PATCH 41/86] refactor: adopt codersdk/x/agenthooks import paths Mechanical follow-up to the SDK move on the backend branch; also documents the strict response body rules in the consumer contract. --- coderd/coderd.go | 2 +- coderd/exp_chats.go | 2 +- coderd/exp_chats_hooks_test.go | 90 +++++++++---------- coderd/x/chatd/chatd.go | 18 ++-- coderd/x/chatd/chathooks/errors.go | 10 +-- .../x/chatd/chathooks/hooks_internal_test.go | 56 ++++++------ coderd/x/chatd/chathooks/tooluse.go | 6 +- coderd/x/chatd/chathooks/trigger.go | 42 ++++----- coderd/x/chatd/compaction_hooks_test.go | 24 ++--- coderd/x/chatd/create_hooks_test.go | 14 +-- coderd/x/chatd/generation.go | 26 +++--- coderd/x/chatd/hook_server.go | 6 +- coderd/x/chatd/hooks_internal_test.go | 2 +- coderd/x/chatd/hooks_test.go | 56 ++++++------ coderd/x/chatd/post_tool_use_test.go | 36 ++++---- coderd/x/chatd/pre_tool_use_test.go | 36 ++++---- coderd/x/chatd/stop_test.go | 6 +- coderd/x/chatd/subagent.go | 6 +- coderd/x/chatd/subagent_internal_test.go | 2 +- docs/admin/setup/chat-lifecycle-hooks.md | 9 +- 20 files changed, 225 insertions(+), 224 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 07bf4031834..3456eb9fda1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -97,11 +97,11 @@ import ( "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/coderd/wsbuildorchestrator" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" "github.com/coder/coder/v2/coderd/x/gitsync" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/drpcsdk" "github.com/coder/coder/v2/codersdk/healthsdk" diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index bdecc7ee52a..763c52279e6 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -47,6 +47,7 @@ import ( "github.com/coder/coder/v2/coderd/util/xjson" "github.com/coder/coder/v2/coderd/workspaceapps" "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/agentselect" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" @@ -57,7 +58,6 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatfiles" "github.com/coder/coder/v2/coderd/x/gitsync" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/websocket" diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index c511a0027eb..a0e0cf3d5f0 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -19,8 +19,8 @@ import ( "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/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" ) @@ -51,9 +51,9 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - requests := make(chan hooks.Request, 2) + requests := make(chan agenthooks.Request, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) requests <- request w.WriteHeader(test.statusCode) @@ -89,7 +89,7 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) { require.Equal(t, test.wantMessage, sdkErr.Message) } request := testutil.RequireReceive(ctx, t, requests) - require.Equal(t, hooks.EventUserPromptSubmit, request.Type) + 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) @@ -139,9 +139,9 @@ func TestChatPromptHookContextHiddenFromAPI(t *testing.T) { t.Parallel() const secret = "test-hook-secret-32-bytes-minimum!!" - consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ - UserPromptSubmit: func(context.Context, hooks.Meta, hooks.UserPromptSubmitData) (hooks.Response, error) { - return hooks.Response{ + consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ + UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { + return agenthooks.Response{ ModelContext: "prompt context", UserMessage: "prompt notice", }, nil @@ -213,46 +213,46 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { } }) - hookEvents := make(chan hooks.EventType, 16) - recordHook := func(event hooks.EventType) { + hookEvents := make(chan agenthooks.EventType, 16) + recordHook := func(event agenthooks.EventType) { hookEvents <- event } - consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ - SessionStart: func(context.Context, hooks.Meta, hooks.SessionStartData) (hooks.Response, error) { - recordHook(hooks.EventSessionStart) - return hooks.Response{}, nil + consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(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, hooks.Meta, hooks.UserPromptSubmitData) (hooks.Response, error) { - recordHook(hooks.EventUserPromptSubmit) - return hooks.Response{}, nil + UserPromptSubmit: func(context.Context, agenthooks.Meta, agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { + recordHook(agenthooks.EventUserPromptSubmit) + return agenthooks.Response{}, nil }, - PreToolUse: func(_ context.Context, _ hooks.Meta, tool hooks.PreToolUseData) (hooks.Response, error) { - recordHook(hooks.EventPreToolUse) + PreToolUse: func(_ context.Context, _ agenthooks.Meta, tool agenthooks.PreToolUseData) (agenthooks.Response, error) { + recordHook(agenthooks.EventPreToolUse) switch tool.ToolUseID { case deniedToolCallID: - return hooks.Response{Permission: &hooks.Permission{ - Decision: hooks.PermissionDeny, + return agenthooks.Response{Permission: &agenthooks.Permission{ + Decision: agenthooks.PermissionDeny, Reason: "secret reads are blocked", }}, nil case allowedToolCallID: - return hooks.Response{Permission: &hooks.Permission{ - Decision: hooks.PermissionAllow, + return agenthooks.Response{Permission: &agenthooks.Permission{ + Decision: agenthooks.PermissionAllow, InputOverride: json.RawMessage(`{"query":"public documentation"}`), }}, nil default: - return hooks.Response{}, nil + return agenthooks.Response{}, nil } }, - PostToolUse: func(context.Context, hooks.Meta, hooks.PostToolUseData) (hooks.Response, error) { - recordHook(hooks.EventPostToolUse) - return hooks.Response{ + 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, hooks.Meta, hooks.StopData) (hooks.Response, error) { - recordHook(hooks.EventStop) - return hooks.Response{}, nil + Stop: func(context.Context, agenthooks.Meta, agenthooks.StopData) (agenthooks.Response, error) { + recordHook(agenthooks.EventStop) + return agenthooks.Response{}, nil }, })) t.Cleanup(consumer.Close) @@ -339,24 +339,24 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { } require.True(t, foundPostToolNotice) - var seenEvents []hooks.EventType + var seenEvents []agenthooks.EventType for { event := testutil.RequireReceive(ctx, t, hookEvents) seenEvents = append(seenEvents, event) - if event == hooks.EventStop { + if event == agenthooks.EventStop { break } } - require.Contains(t, seenEvents, hooks.EventUserPromptSubmit) - require.Contains(t, seenEvents, hooks.EventSessionStart) + require.Contains(t, seenEvents, agenthooks.EventUserPromptSubmit) + require.Contains(t, seenEvents, agenthooks.EventSessionStart) var preToolUseEvents int for _, event := range seenEvents { - if event == hooks.EventPreToolUse { + if event == agenthooks.EventPreToolUse { preToolUseEvents++ } } require.GreaterOrEqual(t, preToolUseEvents, 2) - require.Contains(t, seenEvents, hooks.EventPostToolUse) + require.Contains(t, seenEvents, agenthooks.EventPostToolUse) } func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { @@ -370,15 +370,15 @@ func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { } return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) }) - consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ - UserPromptSubmit: func(_ context.Context, _ hooks.Meta, data hooks.UserPromptSubmitData) (hooks.Response, error) { + consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ + UserPromptSubmit: func(_ context.Context, _ agenthooks.Meta, data agenthooks.UserPromptSubmitData) (agenthooks.Response, error) { if strings.Contains(data.Prompt, "REDACTME") { - return hooks.Response{Permission: &hooks.Permission{ - Decision: hooks.PermissionAllow, + return agenthooks.Response{Permission: &agenthooks.Permission{ + Decision: agenthooks.PermissionAllow, InputOverride: json.RawMessage(`{"prompt":"redacted"}`), }}, nil } - return hooks.Response{}, nil + return agenthooks.Response{}, nil }, })) t.Cleanup(consumer.Close) @@ -457,12 +457,12 @@ func TestChatHookNoticeMessagesInResponses(t *testing.T) { return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) }) - consumer := httptest.NewServer(hooks.NewHTTPHandler([]byte(secret), hooks.Hooks{ - SessionStart: func(context.Context, hooks.Meta, hooks.SessionStartData) (hooks.Response, error) { - return hooks.Response{UserMessage: "session notice"}, nil + consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(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, _ hooks.Meta, data hooks.UserPromptSubmitData) (hooks.Response, error) { - response := hooks.Response{UserMessage: "prompt notice"} + 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" } diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index bc721465fa7..90da1bd0125 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/v2/coderd/util/xjson" "github.com/coder/coder/v2/coderd/webpush" "github.com/coder/coder/v2/coderd/workspacestats" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/agentselect" "github.com/coder/coder/v2/coderd/x/chatd/chatadvisor" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" @@ -52,11 +53,10 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatd/mcpclient" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" skillspkg "github.com/coder/coder/v2/coderd/x/skills" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/quartz" ) @@ -1334,7 +1334,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C OwnerID: opts.OwnerID, WorkspaceID: opts.WorkspaceID, TurnID: &turnID, - }, promptMessage, hooks.EventUserPromptSubmit) + }, promptMessage, agenthooks.EventUserPromptSubmit) if err != nil { return database.Chat{}, chathooks.UserPromptDenial(err) } @@ -1487,7 +1487,7 @@ func (p *Server) SendMessage( if err != nil { return SendMessageResult{}, err } - promptResult, err := p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) + promptResult, err := p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), promptMessage, agenthooks.EventUserPromptSubmit) if err != nil { return SendMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, chathooks.UserPromptDenial(err)) } @@ -1810,15 +1810,15 @@ func (p *Server) EditMessage( if _, err := validateModelConfigOverride(ctx, p.db, opts.ModelConfigID); err != nil { return EditMessageResult{}, err } - sessionStartHookResult, err = p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), chathooks.Message{Source: chathooks.SessionStartSourceClear}, hooks.EventSessionStart) + sessionStartHookResult, err = p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), chathooks.Message{Source: chathooks.SessionStartSourceClear}, agenthooks.EventSessionStart) if err != nil { - return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, hooks.EventSessionStart, err) + return EditMessageResult{}, p.handleAPIDispatchError(ctx, opts.ChatID, agenthooks.EventSessionStart, err) } promptMessage, err := chathooks.UserPromptMessage(contentParts) if err != nil { return EditMessageResult{}, err } - promptResult, err := p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), promptMessage, hooks.EventUserPromptSubmit) + promptResult, err := p.hooks.Trigger(ctx, chathooks.ChatFor(chat, &turnID), promptMessage, agenthooks.EventUserPromptSubmit) if err != nil { return EditMessageResult{}, p.handleUserPromptDispatchError(ctx, opts.ChatID, chathooks.UserPromptDenial(err)) } @@ -2189,10 +2189,10 @@ func (p *Server) SubmitToolResults( return err } for _, result := range opts.Results { - response, err := p.hooks.Trigger(ctx, chathooks.ChatFor(state.chat, nil), chathooks.DynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), hooks.EventPostToolUse) + response, err := p.hooks.Trigger(ctx, chathooks.ChatFor(state.chat, nil), chathooks.DynamicPostToolUseMessage(result, state.toolNames[result.ToolCallID]), agenthooks.EventPostToolUse) if err != nil { // Leave pending calls intact so the client can resubmit after recovery. - return chathooks.GenerationDispatchError(hooks.EventPostToolUse, err) + return chathooks.GenerationDispatchError(agenthooks.EventPostToolUse, err) } responseMessages, err := chathooks.EventMessages(response, state.modelConfigID) if err != nil { diff --git a/coderd/x/chatd/chathooks/errors.go b/coderd/x/chatd/chathooks/errors.go index b1cb4e3d327..5390794e487 100644 --- a/coderd/x/chatd/chathooks/errors.go +++ b/coderd/x/chatd/chathooks/errors.go @@ -6,10 +6,10 @@ import ( "charm.land/fantasy" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" ) // deniedError is trigger's normalized form of a permission deny. @@ -17,7 +17,7 @@ import ( // UserPromptDeniedError, pre_tool_use sites fold it into a synthetic // tool result. type deniedError struct { - Event hooks.EventType + Event agenthooks.EventType Reason string ModelContext string UserMessage string @@ -52,7 +52,7 @@ func UserPromptDenial(err error) error { return err } -func DispatchErrorMessage(eventType hooks.EventType, dispatchErr error) (string, bool) { +func DispatchErrorMessage(eventType agenthooks.EventType, dispatchErr error) (string, bool) { structured, ok := errors.AsType[*dispatch.Error](dispatchErr) if !ok { return "", false @@ -65,7 +65,7 @@ func DispatchErrorMessage(eventType hooks.EventType, dispatchErr error) (string, ), true } -func GenerationDispatchError(eventType hooks.EventType, dispatchErr error) error { +func GenerationDispatchError(eventType agenthooks.EventType, dispatchErr error) error { message, ok := DispatchErrorMessage(eventType, dispatchErr) if !ok { message = dispatchErr.Error() diff --git a/coderd/x/chatd/chathooks/hooks_internal_test.go b/coderd/x/chatd/chathooks/hooks_internal_test.go index b4a0805b667..00be3bca298 100644 --- a/coderd/x/chatd/chathooks/hooks_internal_test.go +++ b/coderd/x/chatd/chathooks/hooks_internal_test.go @@ -17,8 +17,8 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -27,17 +27,17 @@ func TestSessionStartDispatchSources(t *testing.T) { const secret = "test-hook-secret-32-bytes-minimum!!" type received struct { - request hooks.Request - claims hooks.Claims - data hooks.SessionStartData + request agenthooks.Request + claims agenthooks.Claims + data agenthooks.SessionStartData } receivedCh := make(chan received, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - claims, err := hooks.Verify(r.Header.Get("Authorization"), []byte(secret)) + claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte(secret)) require.NoError(t, err) - var data hooks.SessionStartData + var data agenthooks.SessionStartData require.NoError(t, json.Unmarshal(request.Data, &data)) receivedCh <- received{request: request, claims: claims, data: data} _, err = w.Write([]byte(`{}`)) @@ -64,17 +64,17 @@ func TestSessionStartDispatchSources(t *testing.T) { turnID := uuid.New() ctx := testutil.Context(t, testutil.WaitLong) - _, err := trigger.Trigger(ctx, ChatFor(chat, &turnID), Message{Source: SessionStartSource(nil)}, hooks.EventSessionStart) + _, err := trigger.Trigger(ctx, ChatFor(chat, &turnID), Message{Source: SessionStartSource(nil)}, agenthooks.EventSessionStart) require.NoError(t, err) - _, err = trigger.Trigger(ctx, ChatFor(chat, &turnID), Message{Source: SessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, hooks.EventSessionStart) + _, err = trigger.Trigger(ctx, ChatFor(chat, &turnID), Message{Source: SessionStartSource([]database.ChatMessage{{Role: database.ChatMessageRoleAssistant}})}, agenthooks.EventSessionStart) require.NoError(t, err) startup := <-receivedCh resume := <-receivedCh - require.Equal(t, hooks.EventSessionStart, startup.request.Type) + require.Equal(t, agenthooks.EventSessionStart, startup.request.Type) require.Equal(t, SessionStartSourceStartup, startup.data.Source) require.Equal(t, startup.request.Meta.DispatchID, startup.claims.JTI) - require.Equal(t, hooks.EventSessionStart, resume.request.Type) + require.Equal(t, agenthooks.EventSessionStart, resume.request.Type) require.Equal(t, SessionStartSourceResume, resume.data.Source) require.Equal(t, resume.request.Meta.DispatchID, resume.claims.JTI) require.NotEqual(t, startup.claims.JTI, resume.claims.JTI) @@ -119,7 +119,7 @@ func TestHookTriggerDisabled(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() require.False(t, trigger.Enabled()) - result, err := trigger.Trigger(t.Context(), Chat{ID: uuid.New()}, Message{}, hooks.EventStop) + result, err := trigger.Trigger(t.Context(), Chat{ID: uuid.New()}, Message{}, agenthooks.EventStop) require.NoError(t, err) require.Empty(t, result.GetModelContext()) require.Empty(t, result.GetUserMessage()) @@ -144,11 +144,11 @@ func TestHookTriggerDeny(t *testing.T) { ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{}`), - }, hooks.EventPreToolUse) + }, agenthooks.EventPreToolUse) require.Nil(t, result) var denied *deniedError require.ErrorAs(t, err, &denied) - require.Equal(t, hooks.EventPreToolUse, denied.Event) + require.Equal(t, agenthooks.EventPreToolUse, denied.Event) require.Equal(t, "policy", denied.Reason) require.Equal(t, "try another tool", denied.ModelContext) require.Equal(t, "blocked by policy", denied.UserMessage) @@ -157,9 +157,9 @@ func TestHookTriggerDeny(t *testing.T) { func TestHookTriggerEventPayloads(t *testing.T) { t.Parallel() - requests := make(chan hooks.Request, 1) + requests := make(chan agenthooks.Request, 1) trigger := newTestTrigger(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) requests <- request _, err := w.Write([]byte(`{}`)) @@ -171,7 +171,7 @@ func TestHookTriggerEventPayloads(t *testing.T) { WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, } ctx := testutil.Context(t, testutil.WaitShort) - dispatchEvent := func(t *testing.T, msg Message, event hooks.EventType) hooks.Request { + dispatchEvent := func(t *testing.T, msg Message, event agenthooks.EventType) agenthooks.Request { t.Helper() _, err := trigger.Trigger(ctx, chat, msg, event) require.NoError(t, err) @@ -184,37 +184,37 @@ func TestHookTriggerEventPayloads(t *testing.T) { return request } - sessionStart := dispatchEvent(t, Message{Source: SessionStartSourceClear}, hooks.EventSessionStart) - var sessionStartData hooks.SessionStartData + sessionStart := dispatchEvent(t, Message{Source: SessionStartSourceClear}, agenthooks.EventSessionStart) + var sessionStartData agenthooks.SessionStartData require.NoError(t, json.Unmarshal(sessionStart.Data, &sessionStartData)) require.Equal(t, SessionStartSourceClear, sessionStartData.Source) - prompt := dispatchEvent(t, Message{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, hooks.EventUserPromptSubmit) - var promptData hooks.UserPromptSubmitData + prompt := dispatchEvent(t, Message{Prompt: "hello", Parts: json.RawMessage(`[{"type":"text","text":"hello"}]`)}, agenthooks.EventUserPromptSubmit) + var promptData agenthooks.UserPromptSubmitData require.NoError(t, json.Unmarshal(prompt.Data, &promptData)) require.Equal(t, "hello", promptData.Prompt) require.JSONEq(t, `[{"type":"text","text":"hello"}]`, string(promptData.Parts)) - preToolUse := dispatchEvent(t, Message{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, hooks.EventPreToolUse) - var preToolUseData hooks.PreToolUseData + preToolUse := dispatchEvent(t, Message{ToolUseID: "call_1", ToolName: "execute", ToolInput: json.RawMessage(`{"cmd":"ls"}`)}, agenthooks.EventPreToolUse) + var preToolUseData agenthooks.PreToolUseData require.NoError(t, json.Unmarshal(preToolUse.Data, &preToolUseData)) require.Equal(t, "call_1", preToolUseData.ToolUseID) require.Equal(t, "execute", preToolUseData.ToolName) require.JSONEq(t, `{"cmd":"ls"}`, string(preToolUseData.ToolInput)) - postToolUse := dispatchEvent(t, Message{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, hooks.EventPostToolUse) - var postToolUseData hooks.PostToolUseData + postToolUse := dispatchEvent(t, Message{ToolUseID: "call_1", ToolName: "execute", ToolResponse: json.RawMessage(`{"ok":true}`), ToolError: "boom"}, agenthooks.EventPostToolUse) + var postToolUseData agenthooks.PostToolUseData require.NoError(t, json.Unmarshal(postToolUse.Data, &postToolUseData)) require.Equal(t, "call_1", postToolUseData.ToolUseID) require.Equal(t, "execute", postToolUseData.ToolName) require.JSONEq(t, `{"ok":true}`, string(postToolUseData.ToolResponse)) require.Equal(t, "boom", postToolUseData.ToolError) - for _, event := range []hooks.EventType{hooks.EventPreCompact, hooks.EventPostCompact, hooks.EventStop} { + for _, event := range []agenthooks.EventType{agenthooks.EventPreCompact, agenthooks.EventPostCompact, agenthooks.EventStop} { dispatchEvent(t, Message{}, event) } - _, err := trigger.Trigger(ctx, chat, Message{}, hooks.EventType("bogus")) + _, err := trigger.Trigger(ctx, chat, Message{}, agenthooks.EventType("bogus")) require.ErrorContains(t, err, "unsupported hook event") } diff --git a/coderd/x/chatd/chathooks/tooluse.go b/coderd/x/chatd/chathooks/tooluse.go index 3b1ea3de4da..fc743ee29c2 100644 --- a/coderd/x/chatd/chathooks/tooluse.go +++ b/coderd/x/chatd/chathooks/tooluse.go @@ -13,8 +13,8 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" ) // rejectDuplicateToolUseIDs fails closed because hook consumers key @@ -63,7 +63,7 @@ func (t *Trigger) PreflightPendingToolCalls( ToolUseID: toolCall.ToolCallID, ToolName: toolCall.ToolName, ToolInput: json.RawMessage(toolCall.Input), - }, hooks.EventPreToolUse) + }, agenthooks.EventPreToolUse) if err != nil { denied, ok := errors.AsType[*deniedError](err) if !ok { @@ -134,7 +134,7 @@ func (t *Trigger) PostToolUseResults( } continue } - result, err := t.Trigger(ctx, chat, msg, hooks.EventPostToolUse) + result, err := t.Trigger(ctx, chat, msg, agenthooks.EventPostToolUse) if err != nil { if firstErr == nil { firstErr = err diff --git a/coderd/x/chatd/chathooks/trigger.go b/coderd/x/chatd/chathooks/trigger.go index a17f8aab14e..3af38fa65be 100644 --- a/coderd/x/chatd/chathooks/trigger.go +++ b/coderd/x/chatd/chathooks/trigger.go @@ -12,10 +12,10 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" ) const ( @@ -71,8 +71,8 @@ func ChatFor(chat database.Chat, turnID *uuid.UUID) Chat { } } -func (c Chat) ref() hooks.ChatRef { - ref := hooks.ChatRef{ +func (c Chat) ref() agenthooks.ChatRef { + ref := agenthooks.ChatRef{ ChatID: c.ID, OwnerID: c.OwnerID, TurnID: c.TurnID, @@ -141,27 +141,27 @@ func (t *Trigger) Trigger( ctx context.Context, chat Chat, msg Message, - event hooks.EventType, + event agenthooks.EventType, ) (*Result, error) { if !t.Enabled() { return emptyResult, nil } var data any switch event { - case hooks.EventSessionStart: - data = hooks.SessionStartData{Source: msg.Source} - case hooks.EventUserPromptSubmit: - data = hooks.UserPromptSubmitData{Prompt: msg.Prompt, Parts: msg.Parts} - case hooks.EventPreToolUse: - data = hooks.PreToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolInput: msg.ToolInput} - case hooks.EventPostToolUse: - data = hooks.PostToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolResponse: msg.ToolResponse, ToolError: msg.ToolError} - case hooks.EventPreCompact: - data = hooks.PreCompactData{} - case hooks.EventPostCompact: - data = hooks.PostCompactData{} - case hooks.EventStop: - data = hooks.StopData{} + case agenthooks.EventSessionStart: + data = agenthooks.SessionStartData{Source: msg.Source} + case agenthooks.EventUserPromptSubmit: + data = agenthooks.UserPromptSubmitData{Prompt: msg.Prompt, Parts: msg.Parts} + case agenthooks.EventPreToolUse: + data = agenthooks.PreToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolInput: msg.ToolInput} + case agenthooks.EventPostToolUse: + data = agenthooks.PostToolUseData{ToolUseID: msg.ToolUseID, ToolName: msg.ToolName, ToolResponse: msg.ToolResponse, ToolError: msg.ToolError} + case agenthooks.EventPreCompact: + data = agenthooks.PreCompactData{} + case agenthooks.EventPostCompact: + data = agenthooks.PostCompactData{} + case agenthooks.EventStop: + data = agenthooks.StopData{} default: return nil, xerrors.Errorf("unsupported hook event %q", event) } @@ -173,7 +173,7 @@ func (t *Trigger) Trigger( if err != nil { return nil, err } - if response.Permission != nil && response.Permission.Decision == hooks.PermissionDeny { + if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionDeny { return nil, &deniedError{ Event: event, Reason: response.Permission.Reason, @@ -185,7 +185,7 @@ func (t *Trigger) Trigger( ModelContext: response.ModelContext, UserMessage: response.UserMessage, } - if response.Permission != nil && response.Permission.Decision == hooks.PermissionAllow { + if response.Permission != nil && response.Permission.Decision == agenthooks.PermissionAllow { result.InputOverride = response.Permission.InputOverride } return result, nil diff --git a/coderd/x/chatd/compaction_hooks_test.go b/coderd/x/chatd/compaction_hooks_test.go index 819b3617440..355063e4849 100644 --- a/coderd/x/chatd/compaction_hooks_test.go +++ b/coderd/x/chatd/compaction_hooks_test.go @@ -18,10 +18,10 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -30,11 +30,11 @@ func TestCompactionHooksHintAndPostCommitResponses(t *testing.T) { var postSawCommitted atomic.Bool fixture := startCompactionHookChat(t, - func(t *testing.T, db database.Store, request hooks.Request) (int, string) { + func(t *testing.T, db database.Store, request agenthooks.Request) (int, string) { switch request.Type { - case hooks.EventPreCompact: + case agenthooks.EventPreCompact: return http.StatusOK, `{"model_context":"preserve deployment constraints","user_message":"compaction starting"}` - case hooks.EventPostCompact: + case agenthooks.EventPostCompact: postSawCommitted.Store(hasCompactionRows(t, db, request.Meta.ChatID)) return http.StatusOK, `{"model_context":"post compact context","user_message":"compaction complete"}` default: @@ -68,8 +68,8 @@ func TestPreCompactHookFailureAbortsCompaction(t *testing.T) { t.Parallel() fixture := startCompactionHookChat(t, - func(_ *testing.T, _ database.Store, request hooks.Request) (int, string) { - if request.Type == hooks.EventPreCompact { + func(_ *testing.T, _ database.Store, request agenthooks.Request) (int, string) { + if request.Type == agenthooks.EventPreCompact { return http.StatusInternalServerError, "" } return http.StatusOK, `{}` @@ -93,8 +93,8 @@ func TestPostCompactHookFailureKeepsCompaction(t *testing.T) { var postSawCommitted atomic.Bool fixture := startCompactionHookChat(t, - func(t *testing.T, db database.Store, request hooks.Request) (int, string) { - if request.Type == hooks.EventPostCompact { + func(t *testing.T, db database.Store, request agenthooks.Request) (int, string) { + if request.Type == agenthooks.EventPostCompact { postSawCommitted.Store(hasCompactionRows(t, db, request.Meta.ChatID)) return http.StatusInternalServerError, "" } @@ -124,7 +124,7 @@ type compactionHookFixture struct { func startCompactionHookChat( t *testing.T, - hookResponse func(*testing.T, database.Store, hooks.Request) (int, string), + hookResponse func(*testing.T, database.Store, agenthooks.Request) (int, string), inspectCompaction func(*testing.T, string), ) compactionHookFixture { t.Helper() @@ -161,12 +161,12 @@ func startCompactionHookChat( model = updateChatModelCompressionThreshold(t, db, model, contextLimit, thresholdPercent) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) switch request.Type { - case hooks.EventPreCompact: + case agenthooks.EventPreCompact: preCompactCalls.Add(1) - case hooks.EventPostCompact: + case agenthooks.EventPostCompact: postCompactCalls.Add(1) } status, body := hookResponse(t, db, request) diff --git a/coderd/x/chatd/create_hooks_test.go b/coderd/x/chatd/create_hooks_test.go index 9255ec3a1d5..a05026e6354 100644 --- a/coderd/x/chatd/create_hooks_test.go +++ b/coderd/x/chatd/create_hooks_test.go @@ -14,12 +14,12 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -36,11 +36,11 @@ func TestCreateChatUserPromptSubmitHook(t *testing.T) { chat, err := server.CreateChat(ctx, createHookOptions(t, db, user.ID, org.ID, model.ID, "passthrough")) require.NoError(t, err) request := testutil.RequireReceive(ctx, t, requests) - require.Equal(t, hooks.EventUserPromptSubmit, request.Type) + require.Equal(t, agenthooks.EventUserPromptSubmit, request.Type) require.Equal(t, chat.ID, request.Meta.ChatID) require.Equal(t, user.ID, request.Meta.OwnerID) require.NotNil(t, request.Meta.TurnID) - data := decodeHookData[hooks.UserPromptSubmitData](t, request) + data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) require.Equal(t, "passthrough", data.Prompt) var hookParts []codersdk.ChatMessagePart require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) @@ -194,11 +194,11 @@ func newCreateHookTestServer( ps dbpubsub.Pubsub, statusCode int, response string, -) (*chatd.Server, <-chan hooks.Request) { +) (*chatd.Server, <-chan agenthooks.Request) { t.Helper() - requests := make(chan hooks.Request, 2) + requests := make(chan agenthooks.Request, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) requests <- request w.WriteHeader(statusCode) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 27b7fc719d4..f1e908f8d7b 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -22,8 +22,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" ) // generationPrepareInput contains the committed state used to prepare one @@ -392,9 +392,9 @@ func (s *taskStarter) startGenerationSession( // Re-arm the claim until its response is applied so a replacement task // can replay session_start effects. defer func() { complete(completed) }() - response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{Source: chathooks.SessionStartSource(messages)}, hooks.EventSessionStart) + response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{Source: chathooks.SessionStartSource(messages)}, agenthooks.EventSessionStart) if err != nil { - return sessionStartResult{}, true, chathooks.GenerationDispatchError(hooks.EventSessionStart, err) + return sessionStartResult{}, true, chathooks.GenerationDispatchError(agenthooks.EventSessionStart, err) } result, err = applySessionStartResponse(ctx, machine, input, chat, response) if err != nil { @@ -494,7 +494,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS preflight, err := s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), toolCalls) if err != nil { cleanup() - return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(hooks.EventPreToolUse, err), generationAttemptNotRequired) + return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, err), generationAttemptNotRequired) } if len(preflight.Denied) == 0 { cleanup() @@ -810,7 +810,7 @@ func (s *taskStarter) executeLocalTools( ) error { preflight, err := s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) if err != nil { - return chathooks.GenerationDispatchError(hooks.EventPreToolUse, err) + return chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, err) } attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { @@ -848,7 +848,7 @@ func (s *taskStarter) executeLocalTools( // the tool run; its failure surfaces as a tool result error and // must fail the step instead of committing. if hookErr := chathooks.DispatchFailureFromResults(outcome.Step.Content); hookErr != nil { - return chathooks.GenerationDispatchError(hooks.EventUserPromptSubmit, hookErr) + return chathooks.GenerationDispatchError(agenthooks.EventUserPromptSubmit, hookErr) } } postResults, postDispatchErr := s.server.hooks.PostToolUseResults(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), outcome.Step.Content) @@ -877,7 +877,7 @@ func (s *taskStarter) executeLocalTools( } var postCommitErr error if postDispatchErr != nil { - postCommitErr = chathooks.GenerationDispatchError(hooks.EventPostToolUse, postDispatchErr) + postCommitErr = chathooks.GenerationDispatchError(agenthooks.EventPostToolUse, postDispatchErr) } return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages, generationCommitHooks{ Overrides: preflight.Overrides, @@ -935,9 +935,9 @@ func (s *taskStarter) generateCompaction( overrideModel.modelConfig, ) } - preResult, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, hooks.EventPreCompact) + preResult, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventPreCompact) if err != nil { - return chathooks.GenerationDispatchError(hooks.EventPreCompact, err) + return chathooks.GenerationDispatchError(agenthooks.EventPreCompact, err) } compactionOpts.SummaryHint = preResult.GetModelContext() compactionOpts.PublishMessagePart = attempt.publish @@ -982,10 +982,10 @@ func (s *taskStarter) generateCompaction( // Hook effects and fail-closed errors must commit atomically with // compaction; a separate commit races the runner and can be dropped // on crash. - postResult, postDispatchErr := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, hooks.EventPostCompact) + postResult, postDispatchErr := s.server.hooks.Trigger(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventPostCompact) var postCommitErr error if postDispatchErr != nil { - postCommitErr = chathooks.GenerationDispatchError(hooks.EventPostCompact, postDispatchErr) + postCommitErr = chathooks.GenerationDispatchError(agenthooks.EventPostCompact, postDispatchErr) } else { commitMessages, err = appendHookResultMessages(commitMessages, []*chathooks.Result{postResult}, prepared.ModelConfigID) if err != nil { @@ -1370,9 +1370,9 @@ func (s *taskStarter) finishGenerationTurn( if err != nil { return normalizeTaskTransitionError(err, "load stop hook state") } - response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{}, hooks.EventStop) + response, err := s.server.hooks.Trigger(ctx, chathooks.ChatFor(chat, input.hookTurnID()), chathooks.Message{}, agenthooks.EventStop) if err != nil { - return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(hooks.EventStop, err), fence) + return s.finishGenerationError(ctx, machine, input, chathooks.GenerationDispatchError(agenthooks.EventStop, err), fence) } stopMessages, err := chathooks.EventMessages(response, chat.LastModelConfigID) if err != nil { diff --git a/coderd/x/chatd/hook_server.go b/coderd/x/chatd/hook_server.go index 18e6859cecc..45826b11157 100644 --- a/coderd/x/chatd/hook_server.go +++ b/coderd/x/chatd/hook_server.go @@ -13,8 +13,8 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" ) // applyHookResultMessages inserts hook event rows before the step's @@ -66,10 +66,10 @@ func insertHookResultMessages( } func (p *Server) handleUserPromptDispatchError(ctx context.Context, chatID uuid.UUID, dispatchErr error) error { - return p.handleAPIDispatchError(ctx, chatID, hooks.EventUserPromptSubmit, dispatchErr) + return p.handleAPIDispatchError(ctx, chatID, agenthooks.EventUserPromptSubmit, dispatchErr) } -func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType hooks.EventType, dispatchErr error) error { +func (p *Server) handleAPIDispatchError(ctx context.Context, chatID uuid.UUID, eventType agenthooks.EventType, dispatchErr error) error { lastError, ok := chathooks.DispatchErrorMessage(eventType, dispatchErr) if !ok { return dispatchErr diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go index 2fc09ca77c7..501d9a1a4f6 100644 --- a/coderd/x/chatd/hooks_internal_test.go +++ b/coderd/x/chatd/hooks_internal_test.go @@ -14,10 +14,10 @@ import ( "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index 3808e5c0b80..c16385b5bba 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -21,14 +21,14 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -51,9 +51,9 @@ func TestSendMessageUserPromptSubmitHook(t *testing.T) { codersdk.ChatMessageFileReference("main.go", 1, 3, "package main"), } consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - data := decodeHookData[hooks.UserPromptSubmitData](t, request) + data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) require.Equal(t, "before", data.Prompt) var hookParts []codersdk.ChatMessagePart require.NoError(t, json.Unmarshal(data.Parts, &hookParts)) @@ -184,7 +184,7 @@ func TestSendMessageUserPromptSubmitPassthrough(t *testing.T) { OwnerID: user.ID, LastModelConfigID: model.ID, }) - var received hooks.Request + var received agenthooks.Request consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) _, err := w.Write([]byte(`{}`)) @@ -198,8 +198,8 @@ func TestSendMessageUserPromptSubmitPassthrough(t *testing.T) { }) require.NoError(t, err) require.Equal(t, "passthrough", hookMessageText(t, result.Message)) - require.Equal(t, hooks.EventUserPromptSubmit, received.Type) - promptData := decodeHookData[hooks.UserPromptSubmitData](t, received) + require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) + promptData := decodeHookData[agenthooks.UserPromptSubmitData](t, received) require.Equal(t, "passthrough", promptData.Prompt) // The persisted content is jsonb-normalized, so compare JSON // semantics rather than raw bytes. @@ -219,7 +219,7 @@ func TestSendMessageUserPromptSubmitQueue(t *testing.T) { InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("running")}, }) require.NoError(t, err) - var received hooks.Request + var received agenthooks.Request consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) _, err := w.Write([]byte(`{"permission":{"decision":"allow","input_override":{"prompt":"queued override"}},"model_context":"queued context","user_message":"queued notice"}`)) @@ -258,8 +258,8 @@ func TestSendMessageUserPromptSubmitQueue(t *testing.T) { }) require.NoError(t, err) require.Equal(t, wantQueuedParts, persistedParts) - require.Equal(t, hooks.EventUserPromptSubmit, received.Type) - require.Equal(t, "queued original", decodeHookData[hooks.UserPromptSubmitData](t, received).Prompt) + require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) + require.Equal(t, "queued original", decodeHookData[agenthooks.UserPromptSubmitData](t, received).Prompt) } func TestSendMessageUserPromptSubmitQueuedRejections(t *testing.T) { @@ -344,10 +344,10 @@ func TestSubagentSpawnHookDispatchFailureFailsTurn(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type == hooks.EventUserPromptSubmit { - data := decodeHookData[hooks.UserPromptSubmitData](t, request) + if request.Type == agenthooks.EventUserPromptSubmit { + data := decodeHookData[agenthooks.UserPromptSubmitData](t, request) if data.Prompt == "child admission prompt" { w.WriteHeader(http.StatusInternalServerError) return @@ -401,7 +401,7 @@ func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { OwnerID: user.ID, LastModelConfigID: model.ID, }) - var received hooks.Request + var received agenthooks.Request consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, json.NewDecoder(r.Body).Decode(&received)) w.WriteHeader(http.StatusInternalServerError) @@ -422,8 +422,8 @@ func TestSendMessageUserPromptSubmitDispatchFailure(t *testing.T) { var chatErr codersdk.ChatError require.NoError(t, json.Unmarshal(updated.LastError.RawMessage, &chatErr)) require.Equal(t, "hook dispatch failed: user_prompt_submit: http_error (dispatch "+dispatchErr.DispatchID.String()+")", chatErr.Message) - require.Equal(t, hooks.EventUserPromptSubmit, received.Type) - prompt := decodeHookData[hooks.UserPromptSubmitData](t, received) + require.Equal(t, agenthooks.EventUserPromptSubmit, received.Type) + prompt := decodeHookData[agenthooks.UserPromptSubmitData](t, received) require.Equal(t, "fails", prompt.Prompt) messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID}) require.NoError(t, err) @@ -451,21 +451,21 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) { require.NoError(t, err) require.Len(t, inserted, 1) type receivedHook struct { - request hooks.Request - claims hooks.Claims + request agenthooks.Request + claims agenthooks.Claims } var receivedMu sync.Mutex received := make([]receivedHook, 0, 2) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - claims, err := hooks.Verify(r.Header.Get("Authorization"), []byte("test-hook-secret-32-bytes-minimum!!")) + claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte("test-hook-secret-32-bytes-minimum!!")) require.NoError(t, err) receivedMu.Lock() received = append(received, receivedHook{request: request, claims: claims}) receivedMu.Unlock() response := `{"model_context":"clear context","user_message":"clear notice"}` - if request.Type == hooks.EventUserPromptSubmit { + if request.Type == agenthooks.EventUserPromptSubmit { response = `{"permission":{"decision":"allow","input_override":{"prompt":"edited override"}},"model_context":"edit context","user_message":"edit notice"}` } _, err = w.Write([]byte(response)) @@ -492,11 +492,11 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) { received = slices.Clone(received) receivedMu.Unlock() require.Len(t, received, 2) - require.Equal(t, hooks.EventSessionStart, received[0].request.Type) - require.Equal(t, hooks.SessionStartData{Source: "clear"}, decodeHookData[hooks.SessionStartData](t, received[0].request)) + require.Equal(t, agenthooks.EventSessionStart, received[0].request.Type) + require.Equal(t, agenthooks.SessionStartData{Source: "clear"}, decodeHookData[agenthooks.SessionStartData](t, received[0].request)) require.Equal(t, received[0].request.Meta.DispatchID, received[0].claims.JTI) - require.Equal(t, hooks.EventUserPromptSubmit, received[1].request.Type) - prompt := decodeHookData[hooks.UserPromptSubmitData](t, received[1].request) + require.Equal(t, agenthooks.EventUserPromptSubmit, received[1].request.Type) + prompt := decodeHookData[agenthooks.UserPromptSubmitData](t, received[1].request) require.Equal(t, "edited original", prompt.Prompt) require.NotNil(t, received[0].request.Meta.TurnID) require.Equal(t, received[0].request.Meta.TurnID, received[1].request.Meta.TurnID) @@ -578,9 +578,9 @@ func TestPromptHooksAdmissionPreflight(t *testing.T) { db, ps := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) user, org, model := seedChatDependencies(t, db) - received := make(chan hooks.Request, 8) + received := make(chan agenthooks.Request, 8) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) received <- request _, err := w.Write([]byte(`{}`)) @@ -705,7 +705,7 @@ func hookMessageText(t *testing.T, message database.ChatMessage) string { return parts[0].Text } -func decodeHookData[T any](t *testing.T, request hooks.Request) T { +func decodeHookData[T any](t *testing.T, request agenthooks.Request) T { t.Helper() var data T require.NoError(t, json.Unmarshal(request.Data, &data)) diff --git a/coderd/x/chatd/post_tool_use_test.go b/coderd/x/chatd/post_tool_use_test.go index 43c68f0fc69..0056ff2d0dd 100644 --- a/coderd/x/chatd/post_tool_use_test.go +++ b/coderd/x/chatd/post_tool_use_test.go @@ -16,14 +16,14 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -65,16 +65,16 @@ func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var mu sync.Mutex - var received []hooks.PostToolUseData + var received []agenthooks.PostToolUseData consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventPostToolUse { + if request.Type != agenthooks.EventPostToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[hooks.PostToolUseData](t, request) + data := decodeHookData[agenthooks.PostToolUseData](t, request) mu.Lock() received = append(received, data) index := len(received) @@ -126,7 +126,7 @@ func TestPostToolUseHookResponsesCommitWithResults(t *testing.T) { waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) mu.Lock() - receivedSnapshot := append([]hooks.PostToolUseData(nil), received...) + receivedSnapshot := append([]agenthooks.PostToolUseData(nil), received...) mu.Unlock() require.Len(t, receivedSnapshot, 2) require.Equal(t, "call_first", receivedSnapshot[0].ToolUseID) @@ -196,15 +196,15 @@ func TestPostToolUseHookDynamicResult(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) var postCalls atomic.Int32 consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventPostToolUse { + if request.Type != agenthooks.EventPostToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } postCalls.Add(1) - data := decodeHookData[hooks.PostToolUseData](t, request) + data := decodeHookData[agenthooks.PostToolUseData](t, request) require.Equal(t, "call_dynamic_result", data.ToolUseID) require.Equal(t, "my_dynamic_tool", data.ToolName) require.JSONEq(t, `{"answer":42}`, string(data.ToolResponse)) @@ -284,9 +284,9 @@ func TestPostToolUseHookDynamicFailureRejectsSubmission(t *testing.T) { var failPostToolUse atomic.Bool failPostToolUse.Store(true) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type == hooks.EventPostToolUse { + if request.Type == agenthooks.EventPostToolUse { postCalls.Add(1) if failPostToolUse.Load() { w.WriteHeader(http.StatusInternalServerError) @@ -370,11 +370,11 @@ func TestPostToolUseHookFailureCommitsResultThenErrors(t *testing.T) { ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var postCalls atomic.Int32 consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type == hooks.EventPostToolUse { + if request.Type == agenthooks.EventPostToolUse { postCalls.Add(1) - data := decodeHookData[hooks.PostToolUseData](t, request) + data := decodeHookData[agenthooks.PostToolUseData](t, request) require.Equal(t, "call_failure", data.ToolUseID) w.WriteHeader(http.StatusInternalServerError) return @@ -444,14 +444,14 @@ func TestPostToolUseHookFailureDispatchesRemainingResults(t *testing.T) { var mu sync.Mutex results := map[string]string{} consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventPostToolUse { + if request.Type != agenthooks.EventPostToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[hooks.PostToolUseData](t, request) + data := decodeHookData[agenthooks.PostToolUseData](t, request) result := "ok" if data.ToolUseID == "call_first" { result = "http_error" diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index b878bae7d2d..400e57dea9d 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -21,10 +21,10 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -69,7 +69,7 @@ func TestPreToolUseHookAllow(t *testing.T) { ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, "call_non_uuid", data.ToolUseID) require.Equal(t, "read_file", data.ToolName) @@ -140,7 +140,7 @@ func TestPreToolUseHookDeny(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { require.Equal(t, "call_denied", data.ToolUseID) return `{"permission":{"decision":"deny","reason":"blocked by policy"},"model_context":"Do not read secrets."}` }) @@ -210,7 +210,7 @@ func TestPreToolUseSkipsProviderExecutedTools(t *testing.T) { user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) model = enableAnthropicWebSearchForTest(t, db, model) var preToolCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(agenthooks.PreToolUseData) string { preToolCalls.Add(1) return `{}` }) @@ -241,7 +241,7 @@ func TestPreToolUseHookDynamicAllowResponse(t *testing.T) { return chattest.OpenAIStreamingResponse(chunk) }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { require.Equal(t, "call_dynamic_allow", data.ToolUseID) return `{"permission":{"decision":"allow","input_override":{"query":"redacted"}},"model_context":"dynamic context","user_message":"dynamic notice"}` }) @@ -373,7 +373,7 @@ func TestPreToolUseHookRepeatedToolCallIDDispatchesFresh(t *testing.T) { })) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, toolUseID, data.ToolUseID) return `{"permission":{"decision":"deny","reason":"repeated call"}}` @@ -433,9 +433,9 @@ func TestPreToolUseHookDispatchFailure(t *testing.T) { }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventPreToolUse { + if request.Type != agenthooks.EventPreToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return @@ -510,14 +510,14 @@ func TestPreToolUseHookErrorRetryRedispatchesSiblings(t *testing.T) { var failSecond atomic.Bool failSecond.Store(true) consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventPreToolUse { + if request.Type != agenthooks.EventPreToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[hooks.PreToolUseData](t, request) + data := decodeHookData[agenthooks.PreToolUseData](t, request) switch data.ToolUseID { case "call_first": firstCalls.Add(1) @@ -622,7 +622,7 @@ func TestPreToolUseHookSettledDecisionDispatchesFresh(t *testing.T) { user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, "call_reused_after_settle", data.ToolUseID) return `{}` @@ -696,7 +696,7 @@ func TestPreToolUseHookResumeFallback(t *testing.T) { }) var hookCalls atomic.Int32 - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { hookCalls.Add(1) require.Equal(t, "call_resume_fallback", data.ToolUseID) return `{"permission":{"decision":"allow","input_override":{"path":"/tmp/resume.txt"}}}` @@ -815,7 +815,7 @@ func TestPreToolUseHookDynamicDeny(t *testing.T) { return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("replanned")...) }) user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - consumer := preToolUseConsumer(t, func(data hooks.PreToolUseData) string { + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { require.Equal(t, "call_dynamic_denied", data.ToolUseID) return `{"permission":{"decision":"deny","reason":"dynamic denied"}}` }) @@ -846,17 +846,17 @@ func TestPreToolUseHookDynamicDeny(t *testing.T) { require.Contains(t, string(result.Result), "Reason: dynamic denied.") } -func preToolUseConsumer(t *testing.T, response func(hooks.PreToolUseData) string) *httptest.Server { +func preToolUseConsumer(t *testing.T, response func(agenthooks.PreToolUseData) string) *httptest.Server { t.Helper() consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventPreToolUse { + if request.Type != agenthooks.EventPreToolUse { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return } - data := decodeHookData[hooks.PreToolUseData](t, request) + data := decodeHookData[agenthooks.PreToolUseData](t, request) var err error _, err = w.Write([]byte(response(data))) require.NoError(t, err) diff --git a/coderd/x/chatd/stop_test.go b/coderd/x/chatd/stop_test.go index a5a23c7aa90..8fef0ac0f6b 100644 --- a/coderd/x/chatd/stop_test.go +++ b/coderd/x/chatd/stop_test.go @@ -16,8 +16,8 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" - "github.com/coder/coder/v2/coderd/x/hooks" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -162,9 +162,9 @@ func TestStopHookDispatchFailureErrorsChat(t *testing.T) { func stopConsumer(t *testing.T, response func() (int, string)) *httptest.Server { t.Helper() consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request hooks.Request + var request agenthooks.Request require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) - if request.Type != hooks.EventStop { + if request.Type != agenthooks.EventStop { _, err := w.Write([]byte(`{}`)) require.NoError(t, err) return diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index f3ff43fb495..98ba862b709 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -21,14 +21,14 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" - "github.com/coder/coder/v2/coderd/x/hooks" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/x/agenthooks" ) var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat") @@ -1302,7 +1302,7 @@ func (p *Server) createChildSubagentChatWithOptions( ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true}, TurnID: &mintedTurnID, - }, promptMessage, hooks.EventUserPromptSubmit) + }, promptMessage, agenthooks.EventUserPromptSubmit) if err != nil { return database.Chat{}, chathooks.UserPromptDenial(err) } diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 3b869494595..1764c19d91f 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -31,12 +31,12 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattool" - "github.com/coder/coder/v2/coderd/x/hooks/dispatch" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 991f4fed0c2..2c098aa7009 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -85,9 +85,9 @@ A consumer must apply all of the following checks before it uses the body: - Compute SHA-256 over the exact request body bytes and compare it with `body_sha256`. - Check that the chat ID in `sub` matches `meta.chat_id`. -The Go consumer SDK in `coderd/x/hooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. -Use `hooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. -Pass `hooks.WithExpectedIssuer` with the deployment ID associated with the secret to enforce the `iss` check. +The Go consumer SDK in `codersdk/x/agenthooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. +Use `agenthooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. +Pass `agenthooks.WithExpectedIssuer` with the deployment ID associated with the secret to enforce the `iss` check. Without it, `NewHTTPHandler` accepts any non-empty `iss` signed with the shared secret, so use a secret dedicated to one deployment or always set the expected issuer. ### Return a response @@ -95,6 +95,7 @@ Without it, `NewHTTPHandler` accepts any non-empty `iss` signed with the shared Return any `2xx` status with an empty body for a no-op response. An empty JSON object has the same effect. If the response has a body, return a JSON object with these optional fields. +Coder rejects a response body with unknown fields, duplicate JSON keys, or trailing data as malformed, so the dispatch fails closed instead of misreading the decision. | Field | Effect | |-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -171,7 +172,7 @@ Keep the break-glass procedure available throughout the rollout. ## Start from the reference consumer -The reference consumer at `scripts/agenthooks-server` uses `hooks.NewHTTPHandler` and logs 1 JSON object for each event. +The reference consumer at `scripts/agenthooks-server` uses `agenthooks.NewHTTPHandler` and logs 1 JSON object for each event. Log-only mode returns an empty response for every verified event. With log-only mode disabled, the optional example flags can deny tool names by regular expression or replace matching prompt text before the agent loop uses it. It also demonstrates consumer-owned state: it remembers `pre_tool_use` decisions in memory keyed by chat and tool-use ID, replays them for duplicate deliveries, and marks the duplicates in its log output. From f0edb9b1932c9ebecff9ab76c2b630d0ce345896 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:44:00 +0000 Subject: [PATCH 42/86] chore: hide experimental chat lifecycle hook flags from server help The options still work and stay documented in the setup guide; hiding them keeps experimental surface out of coder server --help and the CLI reference. --- cli/testdata/coder_server_--help.golden | 14 ------- codersdk/deployment.go | 4 ++ docs/reference/cli/server.md | 41 ------------------- .../cli/testdata/coder_server_--help.golden | 14 ------- 4 files changed, 4 insertions(+), 69 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 9da5b1df7bd..8abc40867e2 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -281,20 +281,6 @@ Configure the background chat processing daemon. Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. - --chat-hook-enabled bool, $CODER_CHAT_HOOK_ENABLED (default: true) - Whether to dispatch chat agent lifecycle hooks when a hook URL is - configured. Requires the agent-lifecycle-hooks experiment. - - --chat-hook-secret string, $CODER_CHAT_HOOK_SECRET - Shared secret used to sign chat agent lifecycle hook JWTs. - - --chat-hook-timeout duration, $CODER_CHAT_HOOK_TIMEOUT (default: 1.5s) - Maximum time to wait for a chat agent lifecycle hook response. - - --chat-hook-url url, $CODER_CHAT_HOOK_URL - HTTPS URL to receive chat agent lifecycle hook events. Hooks are - disabled when unset. Requires the agent-lifecycle-hooks experiment. - CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2f047c6ee4a..2cc8c93bbef 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4297,6 +4297,7 @@ Write out the current server config as YAML to stdout.`, Name: "Chat: Hook URL", Description: "HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.", Flag: "chat-hook-url", + Hidden: true, Env: "CODER_CHAT_HOOK_URL", Value: &c.AI.Chat.HookURL, Default: "", @@ -4307,6 +4308,7 @@ Write out the current server config as YAML to stdout.`, Name: "Chat: Hook Secret", Description: "Shared secret used to sign chat agent lifecycle hook JWTs.", Flag: "chat-hook-secret", + Hidden: true, Env: "CODER_CHAT_HOOK_SECRET", Value: &c.AI.Chat.HookSecret, Default: "", @@ -4317,6 +4319,7 @@ Write out the current server config as YAML to stdout.`, Name: "Chat: Hook Timeout", Description: "Maximum time to wait for a chat agent lifecycle hook response.", Flag: "chat-hook-timeout", + Hidden: true, Env: "CODER_CHAT_HOOK_TIMEOUT", Value: &c.AI.Chat.HookTimeout, Default: (1500 * time.Millisecond).String(), @@ -4328,6 +4331,7 @@ Write out the current server config as YAML to stdout.`, Name: "Chat: Hook Enabled", Description: "Whether to dispatch chat agent lifecycle hooks when a hook URL is configured. Requires the agent-lifecycle-hooks experiment.", Flag: "chat-hook-enabled", + Hidden: true, Env: "CODER_CHAT_HOOK_ENABLED", Value: &c.AI.Chat.HookEnabled, Default: "true", diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index bd7f5215028..f4ae058d878 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1745,47 +1745,6 @@ Hide AI tasks from the dashboard. Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. -### --chat-hook-url - -| | | -|-------------|-----------------------------------| -| Type | url | -| Environment | $CODER_CHAT_HOOK_URL | -| YAML | chat.hookURL | - -HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment. - -### --chat-hook-secret - -| | | -|-------------|--------------------------------------| -| Type | string | -| Environment | $CODER_CHAT_HOOK_SECRET | - -Shared secret used to sign chat agent lifecycle hook JWTs. - -### --chat-hook-timeout - -| | | -|-------------|---------------------------------------| -| Type | duration | -| Environment | $CODER_CHAT_HOOK_TIMEOUT | -| YAML | chat.hookTimeout | -| Default | 1.5s | - -Maximum time to wait for a chat agent lifecycle hook response. - -### --chat-hook-enabled - -| | | -|-------------|---------------------------------------| -| Type | bool | -| Environment | $CODER_CHAT_HOOK_ENABLED | -| YAML | chat.hookEnabled | -| Default | true | - -Whether to dispatch chat agent lifecycle hooks when a hook URL is configured. Requires the agent-lifecycle-hooks experiment. - ### --ai-gateway-enabled | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index e1962f40464..369b2fe72c8 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -282,20 +282,6 @@ Configure the background chat processing daemon. Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. - --chat-hook-enabled bool, $CODER_CHAT_HOOK_ENABLED (default: true) - Whether to dispatch chat agent lifecycle hooks when a hook URL is - configured. Requires the agent-lifecycle-hooks experiment. - - --chat-hook-secret string, $CODER_CHAT_HOOK_SECRET - Shared secret used to sign chat agent lifecycle hook JWTs. - - --chat-hook-timeout duration, $CODER_CHAT_HOOK_TIMEOUT (default: 1.5s) - Maximum time to wait for a chat agent lifecycle hook response. - - --chat-hook-url url, $CODER_CHAT_HOOK_URL - HTTPS URL to receive chat agent lifecycle hook events. Hooks are - disabled when unset. Requires the agent-lifecycle-hooks experiment. - CLIENT OPTIONS: These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. From 2502cd33e96ea5cf22cd7c83c33a9628c9b71fd1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:48:00 +0000 Subject: [PATCH 43/86] fix(coderd/x/chatd): clarify that denied tool calls were blocked by external policy --- coderd/x/chatd/chathooks/effects.go | 5 +++-- coderd/x/chatd/pre_tool_use_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index fe8c4f59d31..0b9ec25808b 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -72,13 +72,14 @@ func EventMessagesForResults( // distinguish a policy denial from a genuine tool failure, or the model // retries the call and misreports the denial as an infrastructure error. func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { - message := "Tool call denied by the deployment's lifecycle hook policy." + message := "This tool usage was blocked by an external policy" + + " (the deployment's lifecycle hook)." if reason = strings.TrimSpace(reason); reason != "" { message += " Reason: " + reason + "." } message += " This is an administrative policy decision, not a tool or" + " workspace failure; retrying the same call will be denied again." + - " Explain the denial to the user and adjust your approach." + " Explain the policy block to the user and adjust your approach." if modelContext = strings.TrimSpace(modelContext); modelContext != "" { message += "\n\n" + modelContext } diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index 400e57dea9d..59e39f0c41e 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -173,7 +173,7 @@ func TestPreToolUseHookDeny(t *testing.T) { parts := chatToolParts(ctx, t, db, chat.ID) result := requireToolResultPart(t, parts, "read_file") require.True(t, result.IsError) - require.Contains(t, string(result.Result), "denied by the deployment's lifecycle hook policy") + require.Contains(t, string(result.Result), "blocked by an external policy") require.Contains(t, string(result.Result), "Reason: blocked by policy.") require.Contains(t, string(result.Result), "Do not read secrets.") From e4ca57524767c9ca78c2120a50ff4ead31a8eb7c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:22:51 +0000 Subject: [PATCH 44/86] fix(coderd/x/chatd/chathooks): state that denied tool calls were never executed --- coderd/x/chatd/chathooks/effects.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index 0b9ec25808b..0bc5f3b534d 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -73,7 +73,7 @@ func EventMessagesForResults( // retries the call and misreports the denial as an infrastructure error. func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { message := "This tool usage was blocked by an external policy" + - " (the deployment's lifecycle hook)." + " (the deployment's lifecycle hook); the tool call was not executed." if reason = strings.TrimSpace(reason); reason != "" { message += " Reason: " + reason + "." } From 62ce9978c4090121bf39147d82bd4b77d2b8472c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:25:56 +0000 Subject: [PATCH 45/86] docs: align chat lifecycle hook docs with dispatch behavior --- coderd/x/chatd/ARCHITECTURE.md | 2 +- docs/admin/setup/chat-lifecycle-hooks.md | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 13bd4b71730..90dbd1cec04 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -913,7 +913,7 @@ The `compaction_requested_at` marker is one-shot: transitions that keep an activ When the `agent-lifecycle-hooks` experiment is enabled and a hook URL is configured, chatd sends events to an external consumer at key points in a conversation: session start, prompt submission, tool use, compaction, and turn completion. -The consumer can observe activity, add model-only or user-visible context, replace supported prompt or tool input, and deny prompts or tool calls. Prompt submission is evaluated once when the submission is accepted, including queued messages and subagent prompts. Returned context becomes part of the conversation for its intended audience. +The consumer can observe activity, add model-only or user-visible context, replace supported prompt or tool input, and deny prompts or tool calls. Prompt submission is evaluated once when the submission is accepted, including queued messages and subagent prompts. Returned context becomes part of the conversation for its intended audience, except that context returned before a compaction guides the compaction summary instead. Lifecycle hooks fail closed. If the consumer cannot be reached or returns an invalid response, Coder stops the triggering operation rather than continuing without the consumer's decision. Affected chats can enter an error state until the consumer recovers or hooks are disabled. diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 2c098aa7009..ebdb7b51e96 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -44,8 +44,8 @@ Rotation is a hard cutover: Coder signs with exactly one secret, so dispatches f Rotate during a maintenance window, or temporarily set `CODER_CHAT_HOOK_ENABLED=false` for the cutover if blocked chats are worse than unreviewed ones for your deployment. Coder requires the configured URL to use HTTPS. A TLS terminator can forward the request to a consumer over plain HTTP on a trusted local network. -It must set `X-Forwarded-Proto: https` and either preserve the original `Host` header or carry it in `X-Forwarded-Host` for the SDK handler's audience check. -The SDK trusts those forwarded headers, so the audience check is only as strong as that proxy boundary: the proxy must strip or overwrite client-supplied forwarded headers, and the consumer must not be reachable except through the proxy. +The proxy must set `X-Forwarded-Proto: https` and either preserve the original `Host` header or carry it in `X-Forwarded-Host` for the SDK handler's audience check. +The SDK handler ignores forwarded headers unless the consumer opts in with `agenthooks.WithTrustForwardedHeaders`, because the audience check is then only as strong as the proxy boundary: the proxy must strip or overwrite client-supplied forwarded headers, and the consumer must not be reachable except through the proxy. ## Handle lifecycle events @@ -83,7 +83,7 @@ A consumer must apply all of the following checks before it uses the body: - Check that `jti` equals the request `meta.dispatch_id`. - Check that the JWT event `type` equals the body event `type`. - Compute SHA-256 over the exact request body bytes and compare it with `body_sha256`. -- Check that the chat ID in `sub` matches `meta.chat_id`. +- Check that `sub` has the form `coder:chat:` and that its chat ID matches `meta.chat_id`. The Go consumer SDK in `codersdk/x/agenthooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. Use `agenthooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. @@ -114,12 +114,13 @@ Permission rules depend on the event: Coder persists the replacement with the tool call and executes the tool with it. Nothing marks the call as rewritten in the chat, so the model may misattribute the changed behavior; a consumer that rewrites input should also return `user_message` explaining the change. - For either event, `deny` blocks the input and must not include `input_override`. - A denied prompt isn't persisted, and a denied tool call becomes a synthetic error result, carrying any returned `model_context`, so the model can choose another action. + A denied prompt isn't persisted: Coder rejects the submission and surfaces any returned `user_message` in the rejection, ignoring `model_context`. + A denied tool call becomes a synthetic error result, carrying any returned `model_context`, so the model can choose another action. - For all other events, omit `permission`. For `user_prompt_submit`, `model_context` and `user_message` are stored as typed parts of the prompt message itself: the model-context part goes to the model but never to clients, and the user-message part is shown to the user attached to the prompt but never sent to the model. For a denied `pre_tool_use`, `model_context` is included in the synthetic denied tool result. -Other hook effects become ordinary transcript messages with audience-specific visibility. +Other hook effects become ordinary transcript messages with audience-specific visibility, except that a `pre_compact` `model_context` guides the compaction summary instead of entering the transcript. Coder dispatches `user_prompt_submit` exactly once per submission, when the prompt is admitted (sent, queued, edited, or used to create a chat or subagent), and applies the response effects to the final stored prompt content. ## Plan failure recovery @@ -140,7 +141,7 @@ Treat events as attempt notifications rather than proof of a committed operation Delivery is at least once. Coder retries one connection failure per dispatch with the same JWT, so use `dispatch_id` to recognize a repeated HTTP attempt and return the same response. Coder also re-dispatches the same logical event with a new `dispatch_id` whenever an operation runs again, for example when a chat recovers after a crash and retries a pending tool call, or when a user retries a turn that failed before committing. -Every tool call is validated through a fresh `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. +Every non-provider-executed tool call is validated through a fresh `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. Use event-specific identifiers for logical duplicates: @@ -154,7 +155,7 @@ Use event-specific identifiers for logical duplicates: Rejecting duplicates breaks Coder's retries. Return the same decision whenever the payload identifies the same logical event. After the consumer is healthy, send another message to an existing errored chat to resume it. -Coder emits `session_start` with `source` set to `resume` when the agent loop starts again. +Coder emits `session_start` when the agent loop starts again, with `source` set to `resume` when the chat already contains an assistant reply and `startup` otherwise. If the consumer continues blocking chat activity, set `CODER_CHAT_HOOK_ENABLED=false` and roll out the Coder deployment configuration before users retry. ## Roll out enforcement in stages @@ -187,7 +188,7 @@ CODER_AGENTHOOKS_SECRET='' \ ``` The reference server accepts optional TLS certificate and key paths. -For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs. +For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs, and pass `--trust-forwarded-headers` so the audience check uses the proxy's forwarded scheme and host. Run `go run ./scripts/agenthooks-server --help` for all flags and environment variable names. ## Audit dispatches From e1c903371fa934dfc3ef838b6a9c06df940b1925 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:05:41 +0000 Subject: [PATCH 46/86] fix: keep denied pre_tool_use hook context out of the client transcript A denied tool call folded the consumer's model_context into the synthetic tool result, which persists with both-audiences visibility, so the context leaked to REST, SSE, shared transcripts, and the UI. The context now becomes a model-only transcript row, matching the allow path; prompt conversion already keeps tool results adjacent to their assistant calls with the context after them. --- coderd/x/chatd/chathooks/effects.go | 14 +-- coderd/x/chatd/chathooks/tooluse.go | 12 +- coderd/x/chatd/pre_tool_use_test.go | 149 ++++++++++++++++++++--- docs/admin/setup/chat-lifecycle-hooks.md | 4 +- 4 files changed, 150 insertions(+), 29 deletions(-) diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index 0bc5f3b534d..4ff435d475e 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -67,11 +67,12 @@ func EventMessagesForResults( } // deniedToolResult synthesizes the denial as a tool result so the model -// can replan within the same turn. The consumer's model_context rides in -// the same result instead of a separate transcript row. The text must -// distinguish a policy denial from a genuine tool failure, or the model -// retries the call and misreports the denial as an infrastructure error. -func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext string) fantasy.ToolResultContent { +// can replan within the same turn. The result is client-visible, so it +// must never carry the consumer's model_context; that travels as a +// model-only transcript row instead. The text must distinguish a policy +// denial from a genuine tool failure, or the model retries the call and +// misreports the denial as an infrastructure error. +func deniedToolResult(toolCall fantasy.ToolCallContent, reason string) fantasy.ToolResultContent { message := "This tool usage was blocked by an external policy" + " (the deployment's lifecycle hook); the tool call was not executed." if reason = strings.TrimSpace(reason); reason != "" { @@ -80,9 +81,6 @@ func deniedToolResult(toolCall fantasy.ToolCallContent, reason, modelContext str message += " This is an administrative policy decision, not a tool or" + " workspace failure; retrying the same call will be denied again." + " Explain the policy block to the user and adjust your approach." - if modelContext = strings.TrimSpace(modelContext); modelContext != "" { - message += "\n\n" + modelContext - } return fantasy.ToolResultContent{ ToolCallID: toolCall.ToolCallID, ToolName: toolCall.ToolName, diff --git a/coderd/x/chatd/chathooks/tooluse.go b/coderd/x/chatd/chathooks/tooluse.go index fc743ee29c2..9add07640ed 100644 --- a/coderd/x/chatd/chathooks/tooluse.go +++ b/coderd/x/chatd/chathooks/tooluse.go @@ -69,10 +69,14 @@ func (t *Trigger) PreflightPendingToolCalls( if !ok { return PreToolUseExecutionResult{}, err } - // The denial's model context folds into the synthetic tool - // result; only the user notice needs a transcript row. - result.Results = append(result.Results, &Result{UserMessage: denied.UserMessage}) - result.Denied = append(result.Denied, deniedToolResult(toolCall, denied.Reason, denied.ModelContext)) + // The synthetic tool result is client-visible, so the + // denial's model context becomes a model-only transcript + // row instead of riding in the result. + result.Results = append(result.Results, &Result{ + ModelContext: denied.ModelContext, + UserMessage: denied.UserMessage, + }) + result.Denied = append(result.Denied, deniedToolResult(toolCall, denied.Reason)) continue } result.Results = append(result.Results, callResult) diff --git a/coderd/x/chatd/pre_tool_use_test.go b/coderd/x/chatd/pre_tool_use_test.go index 59e39f0c41e..3ca588f7148 100644 --- a/coderd/x/chatd/pre_tool_use_test.go +++ b/coderd/x/chatd/pre_tool_use_test.go @@ -175,26 +175,112 @@ func TestPreToolUseHookDeny(t *testing.T) { require.True(t, result.IsError) require.Contains(t, string(result.Result), "blocked by an external policy") require.Contains(t, string(result.Result), "Reason: blocked by policy.") - require.Contains(t, string(result.Result), "Do not read secrets.") + require.NotContains(t, string(result.Result), "Do not read secrets.") - messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) - require.NoError(t, err) - for _, message := range messages { - if message.Visibility != database.ChatMessageVisibilityModel { - continue - } - parsed, err := chatprompt.ParseContent(message) - require.NoError(t, err) - for _, part := range parsed { - require.NotEqual(t, "Do not read secrets.", part.Text) - } - } + requireNoClientVisibleText(ctx, t, db, chat.ID, "Do not read secrets.") + requireModelOnlyTextCount(ctx, t, db, chat.ID, "Do not read secrets.", 1) messagesMu.Lock() modelMessages := append([]chattest.OpenAIMessage(nil), secondMessages...) messagesMu.Unlock() - require.True(t, openAIMessagesContain(modelMessages, "Reason: blocked by policy.")) - require.True(t, openAIMessagesContain(modelMessages, "Do not read secrets.")) + deniedIndex, contextIndex := -1, -1 + for i, msg := range modelMessages { + if strings.Contains(msg.Content, "Reason: blocked by policy.") { + deniedIndex = i + require.Equal(t, "tool", msg.Role) + } + if strings.Contains(msg.Content, "Do not read secrets.") { + contextIndex = i + require.Equal(t, "user", msg.Role) + } + } + // The model still receives the denial reason and the hook context, + // with the context after the tool result so results stay adjacent + // to the assistant tool calls. + require.NotEqual(t, -1, deniedIndex) + require.Greater(t, contextIndex, deniedIndex) +} + +func TestPreToolUseHookDenyMixedWithAllowed(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var modelCalls atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + if modelCalls.Add(1) == 1 { + first := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/secret.txt"}`) + first.Choices[0].ToolCalls[0].ID = "call_denied" + second := chattest.OpenAIToolCallChunk("read_file", `{"path":"/tmp/allowed.txt"}`).Choices[0].ToolCalls[0] + second.ID = "call_allowed" + second.Index = 1 + first.Choices[0].ToolCalls = append(first.Choices[0].ToolCalls, second) + return chattest.OpenAIStreamingResponse(first) + } + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) + }) + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + + consumer := preToolUseConsumer(t, func(data agenthooks.PreToolUseData) string { + if data.ToolUseID == "call_denied" { + return `{"permission":{"decision":"deny","reason":"blocked by policy"},"model_context":"Do not read secrets."}` + } + return `{}` + }) + + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + setupToolExecutionAgentConn(t, mockConn) + mockConn.EXPECT().ReadFileLines(gomock.Any(), "/tmp/allowed.txt", int64(1), int64(0), gomock.Any()). + Return(workspacesdk.ReadFileLinesResponse{ + Success: true, FileSize: 4, TotalLines: 1, LinesRead: 1, Content: "data", + }, nil). + Times(1) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + cfg.HookDispatcher = newHookDispatcher(t, db, consumer) + cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + require.Equal(t, dbAgent.ID, agentID) + return mockConn, func() {}, nil + } + }) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + Title: "pre-tool-use-deny-mixed", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("read both files"), + }, + }) + require.NoError(t, err) + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + + var results []codersdk.ChatMessagePart + for _, part := range chatToolParts(ctx, t, db, chat.ID) { + if part.Type == codersdk.ChatMessagePartTypeToolResult { + results = append(results, part) + } + } + require.Len(t, results, 2) + // Persisted results keep the assistant call order even though the + // denied result is synthesized after the executed one. + require.Equal(t, "call_denied", results[0].ToolCallID) + require.Equal(t, "call_allowed", results[1].ToolCallID) + require.True(t, results[0].IsError) + require.Contains(t, string(results[0].Result), "Reason: blocked by policy.") + require.NotContains(t, string(results[0].Result), "Do not read secrets.") + require.False(t, results[1].IsError) + + requireNoClientVisibleText(ctx, t, db, chat.ID, "Do not read secrets.") + requireModelOnlyTextCount(ctx, t, db, chat.ID, "Do not read secrets.", 1) } func TestPreToolUseSkipsProviderExecutedTools(t *testing.T) { @@ -846,6 +932,39 @@ func TestPreToolUseHookDynamicDeny(t *testing.T) { require.Contains(t, string(result.Result), "Reason: dynamic denied.") } +func requireNoClientVisibleText(ctx context.Context, t *testing.T, db database.Store, chatID uuid.UUID, text string) { + t.Helper() + for _, message := range chatMessages(ctx, t, db, chatID) { + parsed, err := chatprompt.ParseContent(message) + require.NoError(t, err) + for _, part := range parsed { + require.NotContains(t, part.Text, text) + require.NotContains(t, string(part.Result), text) + require.NotContains(t, string(part.Args), text) + } + } +} + +func requireModelOnlyTextCount(ctx context.Context, t *testing.T, db database.Store, chatID uuid.UUID, text string, count int) { + t.Helper() + messages, err := db.GetChatMessagesForPromptByChatID(ctx, chatID) + require.NoError(t, err) + found := 0 + for _, message := range messages { + if message.Visibility != database.ChatMessageVisibilityModel { + continue + } + parsed, err := chatprompt.ParseContent(message) + require.NoError(t, err) + for _, part := range parsed { + if strings.Contains(part.Text, text) { + found++ + } + } + } + require.Equal(t, count, found) +} + func preToolUseConsumer(t *testing.T, response func(agenthooks.PreToolUseData) string) *httptest.Server { t.Helper() consumer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index ebdb7b51e96..0411fd33a88 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -115,11 +115,11 @@ Permission rules depend on the event: Nothing marks the call as rewritten in the chat, so the model may misattribute the changed behavior; a consumer that rewrites input should also return `user_message` explaining the change. - For either event, `deny` blocks the input and must not include `input_override`. A denied prompt isn't persisted: Coder rejects the submission and surfaces any returned `user_message` in the rejection, ignoring `model_context`. - A denied tool call becomes a synthetic error result, carrying any returned `model_context`, so the model can choose another action. + A denied tool call becomes a synthetic error result, and any returned `model_context` reaches the model separately, so the model can choose another action. - For all other events, omit `permission`. For `user_prompt_submit`, `model_context` and `user_message` are stored as typed parts of the prompt message itself: the model-context part goes to the model but never to clients, and the user-message part is shown to the user attached to the prompt but never sent to the model. -For a denied `pre_tool_use`, `model_context` is included in the synthetic denied tool result. +For a denied `pre_tool_use`, the synthetic denied tool result stays visible to both audiences, while `model_context` becomes a model-only transcript message so it never reaches clients. Other hook effects become ordinary transcript messages with audience-specific visibility, except that a `pre_compact` `model_context` guides the compaction summary instead of entering the transcript. Coder dispatches `user_prompt_submit` exactly once per submission, when the prompt is admitted (sent, queued, edited, or used to create a chat or subagent), and applies the response effects to the final stored prompt content. From 5380806e62b33280a6f75a310e7b3ccaa61078c3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:42:26 +0000 Subject: [PATCH 47/86] refactor(coderd/x/chatd): reuse chatstate tool-result validation in the hook precheck --- coderd/x/chatd/hook_server.go | 37 +++++++++-------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/coderd/x/chatd/hook_server.go b/coderd/x/chatd/hook_server.go index 45826b11157..4a6bc17f4c0 100644 --- a/coderd/x/chatd/hook_server.go +++ b/coderd/x/chatd/hook_server.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -171,36 +170,18 @@ func loadDynamicPostToolUseState( return state, err } +// validateSubmittedToolResults rejects invalid results before hook dispatch, +// using the same rules as CompleteRequiresAction. func validateSubmittedToolResults(results []codersdk.ToolResult, toolNames map[string]string) error { - submitted := make(map[string]struct{}, len(results)) + inputs := make([]chatstate.ToolResultInput, 0, len(results)) for _, result := range results { - if _, ok := submitted[result.ToolCallID]; ok { - return &ToolResultValidationError{ - Message: "Duplicate tool_call_id in results.", - Detail: fmt.Sprintf("Duplicate tool call ID %q.", result.ToolCallID), - } - } - if !json.Valid(result.Output) { - return &ToolResultValidationError{ - Message: "Tool result output must be valid JSON.", - Detail: fmt.Sprintf("Output for tool call %q is not valid JSON.", result.ToolCallID), - } - } - if _, ok := toolNames[result.ToolCallID]; !ok { - return &ToolResultValidationError{ - Message: "Unexpected tool result.", - Detail: fmt.Sprintf("No pending tool call with ID %q.", result.ToolCallID), - } - } - submitted[result.ToolCallID] = struct{}{} + inputs = append(inputs, chatstate.ToolResultInput{ + ToolCallID: result.ToolCallID, + Output: result.Output, + }) } - for toolCallID := range toolNames { - if _, ok := submitted[toolCallID]; !ok { - return &ToolResultValidationError{ - Message: "Missing tool result.", - Detail: fmt.Sprintf("Missing result for tool call %q.", toolCallID), - } - } + if invalid := chatstate.ValidateToolResults(inputs, toolNames); invalid != nil { + return translateToolResultValidationError(invalid) } return nil } From cfba00a2a434ecf5f8be594d699fc3fab625fc38 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:45:32 +0000 Subject: [PATCH 48/86] docs(docs/admin/setup): frame the chat lifecycle hooks page as a how-to --- docs/admin/setup/chat-lifecycle-hooks.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 0411fd33a88..9754d42687c 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -4,8 +4,8 @@ > Chat lifecycle hooks are an experimental feature. > The feature requires the `agent-lifecycle-hooks` experiment, and the consumer contract (including the request schema and JWT claims) may change or be removed in any release without a compatibility guarantee. -This reference is for Coder deployment administrators who need to apply an external policy service to the agent loop. -It covers deployment configuration, the consumer contract, failure behavior, and rollout. +This guide is for Coder deployment administrators who need to apply an external policy service to the agent loop. +Work through it to configure the deployment, handle events in a consumer, recover from dispatch failures, and roll out enforcement. Chat lifecycle hooks send events from the agent loop to 1 deployment-wide webhook endpoint. The configured consumer can observe all 7 lifecycle events, add model or user context, replace mutable input, or deny selected actions. @@ -55,6 +55,8 @@ The `meta` object includes `dispatch_id`, `schema_version`, `chat_id`, `owner_id Events from subagent chats also carry `parent_chat_id` and `root_chat_id` so a consumer can correlate a subagent subtree with the user-facing conversation and apply the parent's policy context. The current `schema_version` is `1`. +Handle the events your policy needs, using the data Coder sends with each one: + | Event | When Coder sends it | Decision-relevant data | |----------------------|---------------------------------------------------------------------|------------------------------------------------------------------------| | `session_start` | A chat session starts, resumes, or clears | `source` (`startup`, `resume`, or `clear`) | @@ -187,6 +189,12 @@ CODER_AGENTHOOKS_SECRET='' \ --log-only=true ``` +The server confirms the listener and then stays in the foreground, printing 1 JSON object per event it receives: + +```output +Agent hooks server listening on 127.0.0.1:8081 +``` + The reference server accepts optional TLS certificate and key paths. For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs, and pass `--trust-forwarded-headers` so the audience check uses the proxy's forwarded scheme and host. Run `go run ./scripts/agenthooks-server --help` for all flags and environment variable names. From 0bc4f76757b9e0d91243337ee3e7bc8e43d71f8e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:48:48 +0000 Subject: [PATCH 49/86] fix(coderd/x/chatd): keep exclusive-tool batches rejected when a hook denies a call --- coderd/x/chatd/generation.go | 21 +++++++++++-- coderd/x/chatd/generation_internal_test.go | 34 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index f1e908f8d7b..8776535575e 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -319,6 +319,13 @@ func unresolvedToolCallsFromHistory( return localCalls, dynamicCalls, nil } +// exclusiveBatchRejected reports whether the exclusive-tool policy will +// reject the whole batch, which mirrors the condition chatloop applies +// when it decides that nothing in the batch may execute. +func exclusiveBatchRejected(toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool) bool { + return len(toolCalls) > 1 && hasExclusiveToolCall(toolCalls, exclusiveToolNames) +} + func hasExclusiveToolCall(toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool) bool { if len(exclusiveToolNames) == 0 { return false @@ -808,9 +815,17 @@ func (s *taskStarter) executeLocalTools( prepared generationPrepared, decision generationDecision, ) error { - preflight, err := s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) - if err != nil { - return chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, err) + // A batch that mixes an exclusive tool with other tools is rejected + // whole and executes nothing, so no call is gated. Removing hook + // denials from it first would leave the exclusive call looking alone + // and let it run. + preflight := chathooks.PreToolUseExecutionResult{Allowed: decision.localToolCalls} + if !exclusiveBatchRejected(decision.localToolCalls, prepared.ExclusiveToolNames) { + var preflightErr error + preflight, preflightErr = s.server.hooks.PreflightPendingToolCalls(ctx, chathooks.ChatFor(prepared.Chat, input.hookTurnID()), decision.localToolCalls) + if preflightErr != nil { + return chathooks.GenerationDispatchError(agenthooks.EventPreToolUse, preflightErr) + } } attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { diff --git a/coderd/x/chatd/generation_internal_test.go b/coderd/x/chatd/generation_internal_test.go index aa8e93a3d96..1d58427951e 100644 --- a/coderd/x/chatd/generation_internal_test.go +++ b/coderd/x/chatd/generation_internal_test.go @@ -3,6 +3,7 @@ package chatd //nolint:testpackage // Exercises unexported generation helpers. import ( "testing" + "charm.land/fantasy" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -13,6 +14,39 @@ import ( "github.com/coder/coder/v2/testutil" ) +func TestExclusiveBatchRejected(t *testing.T) { + t.Parallel() + + call := func(name string) fantasy.ToolCallContent { + return fantasy.ToolCallContent{ToolCallID: "call_" + name, ToolName: name} + } + exclusive := map[string]bool{"advisor": true} + + cases := []struct { + name string + toolCalls []fantasy.ToolCallContent + exclusives map[string]bool + want bool + }{ + {name: "ExclusiveAlone", toolCalls: []fantasy.ToolCallContent{call("advisor")}, exclusives: exclusive}, + {name: "NoExclusive", toolCalls: []fantasy.ToolCallContent{call("execute"), call("read_file")}, exclusives: exclusive}, + {name: "NoExclusiveNames", toolCalls: []fantasy.ToolCallContent{call("advisor"), call("execute")}}, + { + name: "ExclusiveMixed", + toolCalls: []fantasy.ToolCallContent{call("advisor"), call("execute")}, + exclusives: exclusive, + want: true, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, exclusiveBatchRejected(test.toolCalls, test.exclusives)) + }) + } +} + func TestCompactionMetricIdentity(t *testing.T) { t.Parallel() From 6f08efdf45160510dd06f1dc3b0bfe7f42a55845 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:54:45 +0000 Subject: [PATCH 50/86] docs(docs/admin/setup): describe hook delivery as best-effort --- docs/admin/setup/chat-lifecycle-hooks.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 9754d42687c..84fe9d3d7e9 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -140,7 +140,8 @@ Coder checks admission before dispatching, but concurrent requests can still fai The consumer then observes an event for a request that Coder rejects, and the rejected request doesn't persist a prompt or tool result. Treat events as attempt notifications rather than proof of a committed operation, and key idempotent tool-event processing on `tool_use_id`. -Delivery is at least once. +Delivery is best-effort and can duplicate. +Coder never queues a failed dispatch for redelivery, so plan for duplicates without assuming every event arrives. Coder retries one connection failure per dispatch with the same JWT, so use `dispatch_id` to recognize a repeated HTTP attempt and return the same response. Coder also re-dispatches the same logical event with a new `dispatch_id` whenever an operation runs again, for example when a chat recovers after a crash and retries a pending tool call, or when a user retries a turn that failed before committing. Every non-provider-executed tool call is validated through a fresh `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. From 0c5881ed86290dc68e7b42a727c04abd6a907955 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:05:19 +0000 Subject: [PATCH 51/86] docs(coderd/x/chatd): describe hook delivery as best-effort --- coderd/x/chatd/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 90dbd1cec04..c91f5f8e086 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -917,7 +917,7 @@ The consumer can observe activity, add model-only or user-visible context, repla Lifecycle hooks fail closed. If the consumer cannot be reached or returns an invalid response, Coder stops the triggering operation rather than continuing without the consumer's decision. Affected chats can enter an error state until the consumer recovers or hooks are disabled. -Coder stores no hook-specific dispatch or decision state. Delivery is at least once, so the consumer owns durable policy state, audit records, and deduplication based on stable event identifiers. +Coder stores no hook-specific dispatch or decision state. Delivery is best-effort and can duplicate, and a failed dispatch is never redelivered, so the consumer owns durable policy state, audit records, and deduplication based on stable event identifiers. # Stream loop From bbb7f90ba2c9dacf4e8c290845f6dd8337fe8692 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:33:17 +0000 Subject: [PATCH 52/86] fix(coderd/x/chatd): ignore blank stop-hook model context Prompt conversion drops whitespace-only text parts, so a stop hook returning a blank model_context consumed the one allowed continuation and started a paid generation that nudged the model with nothing. The continuation decision and the persisted hook-context row now both test trimmed content. --- coderd/x/chatd/chathooks/effects.go | 2 +- coderd/x/chatd/chathooks/hooks_internal_test.go | 14 ++++++++++++++ coderd/x/chatd/generation.go | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index 4ff435d475e..e0f82eb26b2 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -22,7 +22,7 @@ import ( // and the user message becomes a system-role, user-visible notice row. func EventMessages(result *Result, modelConfigID uuid.UUID) ([]chatstate.Message, error) { messages := make([]chatstate.Message, 0, 2) - if result.GetModelContext() != "" { + if strings.TrimSpace(result.GetModelContext()) != "" { content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.ModelContext)}) if err != nil { return nil, xerrors.Errorf("marshal hook model context: %w", err) diff --git a/coderd/x/chatd/chathooks/hooks_internal_test.go b/coderd/x/chatd/chathooks/hooks_internal_test.go index 00be3bca298..e241d352fc9 100644 --- a/coderd/x/chatd/chathooks/hooks_internal_test.go +++ b/coderd/x/chatd/chathooks/hooks_internal_test.go @@ -259,3 +259,17 @@ func TestRestoreToolCallOrder(t *testing.T) { require.True(t, ok) require.Equal(t, "call_b", last.ToolCallID) } + +func TestEventMessagesSkipsBlankModelContext(t *testing.T) { + t.Parallel() + + modelConfigID := uuid.New() + messages, err := EventMessages(&Result{ModelContext: " \n\t "}, modelConfigID) + require.NoError(t, err) + require.Empty(t, messages) + + messages, err = EventMessages(&Result{ModelContext: "real context"}, modelConfigID) + require.NoError(t, err) + require.Len(t, messages, 1) + require.Equal(t, database.ChatMessageVisibilityModel, messages[0].Visibility) +} diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 8776535575e..5766e2945e9 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -1394,7 +1394,9 @@ func (s *taskStarter) finishGenerationTurn( return s.finishGenerationError(ctx, machine, input, err, fence) } nudgeKey := stopNudgeKey(messages) - continueTurn := response.GetModelContext() != "" && input.StopNudges.claim(nudgeKey) + // Prompt conversion drops whitespace-only text parts, so a blank + // model context would buy a continuation that nudges nothing. + continueTurn := strings.TrimSpace(response.GetModelContext()) != "" && input.StopNudges.claim(nudgeKey) var committed database.Chat err = machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { From 0895b4df2dfe8c24f0e3b5d70bfff2180482f27d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:37:20 +0000 Subject: [PATCH 53/86] feat(site): surface chat lifecycle hook outcomes in the chats UI Render lifecycle hook outcomes in the chat experience: - Show hook notices attached to their user message as timeline notes, and show an info tooltip for notices on queued messages. - Cache the full inserted message batch from send and edit responses so hook-inserted messages survive reconnects and queue promotion. - Reconcile the promoted queue head after a send to an errored chat so a missed queue update neither duplicates nor hides messages. - Refresh chat details when a send fails, because a failed hook dispatch can move the chat to the error state. --- site/src/api/queries/chatMessageEdits.test.ts | 53 ++++++++- site/src/api/queries/chatMessageEdits.ts | 28 +++-- site/src/api/queries/chats.test.ts | 62 +++++------ site/src/api/queries/chats.ts | 3 +- .../pages/AgentsPage/AgentChatPage.test.ts | 102 +++++++++++++++++- site/src/pages/AgentsPage/AgentChatPage.tsx | 79 ++++++++++++-- .../ConversationTimeline.stories.tsx | 63 +++++++++++ .../ChatConversation/ConversationTimeline.tsx | 62 ++++++++++- .../ChatConversation/useChatStore.ts | 75 +++++++------ .../components/QueuedMessagesList.test.ts | 26 +++++ .../components/QueuedMessagesList.tsx | 46 +++++--- .../AgentsPage/utils/usageLimitMessage.ts | 13 +++ 12 files changed, 512 insertions(+), 100 deletions(-) diff --git a/site/src/api/queries/chatMessageEdits.test.ts b/site/src/api/queries/chatMessageEdits.test.ts index 0cf726ff85c..8314f157e3b 100644 --- a/site/src/api/queries/chatMessageEdits.test.ts +++ b/site/src/api/queries/chatMessageEdits.test.ts @@ -1,6 +1,10 @@ +import type { InfiniteData } from "react-query"; import { describe, expect, it } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { buildOptimisticEditedMessage } from "./chatMessageEdits"; +import { + buildOptimisticEditedMessage, + reconcileEditedMessageInCache, +} from "./chatMessageEdits"; const makeUserMessage = ( content: readonly TypesGen.ChatMessagePart[] = [ @@ -42,3 +46,50 @@ describe("buildOptimisticEditedMessage", () => { expect(message.content).toEqual([existingFilePart]); }); }); + +describe("reconcileEditedMessageInCache", () => { + it("drops messages the edit deleted, such as stale hook notices", () => { + const staleNotice: TypesGen.ChatMessage = { + id: 2, + chat_id: "chat-1", + created_at: "2025-01-01T00:00:00.000Z", + role: "system", + content: [{ type: "text", text: "old hook notice" }], + }; + const newNotice: TypesGen.ChatMessage = { + id: 5, + chat_id: "chat-1", + created_at: "2025-01-01T00:01:00.000Z", + role: "system", + content: [{ type: "text", text: "new hook notice" }], + }; + const replacement: TypesGen.ChatMessage = { + id: 6, + chat_id: "chat-1", + created_at: "2025-01-01T00:01:00.000Z", + role: "user", + content: [{ type: "text", text: "edited prompt" }], + }; + const currentData: InfiniteData = { + pages: [ + { + messages: [staleNotice, makeUserMessage()], + queued_messages: [], + has_more: false, + }, + ], + pageParams: [undefined], + }; + + const reconciled = reconcileEditedMessageInCache({ + currentData, + optimisticMessageId: 1, + responseMessages: [newNotice, replacement], + deletedMessageIds: [staleNotice.id, 1], + }); + + const ids = reconciled?.pages[0]?.messages.map((message) => message.id); + // The first page is ordered newest first. + expect(ids).toEqual([replacement.id, newNotice.id]); + }); +}); diff --git a/site/src/api/queries/chatMessageEdits.ts b/site/src/api/queries/chatMessageEdits.ts index 2fbefa12741..fe0512beada 100644 --- a/site/src/api/queries/chatMessageEdits.ts +++ b/site/src/api/queries/chatMessageEdits.ts @@ -117,28 +117,40 @@ export const projectEditedConversationIntoCache = ({ export const reconcileEditedMessageInCache = ({ currentData, optimisticMessageId, - responseMessage, + responseMessages, + deletedMessageIds, }: { currentData: InfiniteData | undefined; optimisticMessageId: number; - responseMessage: TypesGen.ChatMessage; + // Every message the edit inserted, in insertion order. All of them + // must land in the cache, or a stream reconnect keyed on the + // highest cached ID would skip rows around the replacement. + responseMessages: readonly TypesGen.ChatMessage[]; + // Messages the edit soft-deleted. Dropped here so the cache does + // not keep them if the history reset event is missed. + deletedMessageIds?: readonly number[]; }): InfiniteData | undefined => { - if (!currentData?.pages?.length) { + if (!currentData?.pages?.length || responseMessages.length === 0) { return currentData; } + const responseIDs = new Set(responseMessages.map((message) => message.id)); + const deletedIDs = new Set(deletedMessageIds ?? []); const replacedPages = currentData.pages.map((page, pageIndex) => { const preservedMessages = page.messages.filter( (message) => - message.id !== optimisticMessageId && message.id !== responseMessage.id, + message.id !== optimisticMessageId && + !responseIDs.has(message.id) && + !deletedIDs.has(message.id), ); if (pageIndex !== 0) { return { ...page, messages: preservedMessages }; } - return { - ...page, - messages: upsertFirstPageMessage(preservedMessages, responseMessage), - }; + let messages = preservedMessages; + for (const responseMessage of responseMessages) { + messages = upsertFirstPageMessage(messages, responseMessage); + } + return { ...page, messages }; }); return { diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 9f2f5386377..da35e9ad44f 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1043,37 +1043,6 @@ describe("mutation invalidation scope", () => { ).toBe(true); }); - it("editChatMessage onError invalidates messages", async () => { - const queryClient = createTestQueryClient(); - const chatId = "chat-1"; - const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); - - queryClient.setQueryData(chatMessagesKey(chatId), { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }); - - const mutation = editChatMessage(queryClient, chatId); - mutation.onError( - new Error("fail"), - { messageId: 2, req: editReq }, - { - previousData: { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }, - }, - ); - - await new Promise((r) => setTimeout(r, 0)); - - const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); - expect( - messagesState?.isInvalidated, - "chatMessagesKey should be invalidated on error", - ).toBe(true); - }); - // Shared type for the infinite messages cache shape used by // editChatMessage tests below. type InfMessages = { @@ -1120,6 +1089,37 @@ describe("mutation invalidation scope", () => { requestContent: editReq.content, }); + it("editChatMessage onError invalidates messages", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); + + queryClient.setQueryData(chatMessagesKey(chatId), { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }); + + const mutation = editChatMessage(queryClient, chatId); + mutation.onError( + new Error("fail"), + { messageId: 2, req: editReq }, + { + previousData: { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }, + }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); + expect( + messagesState?.isInvalidated, + "chatMessagesKey should be invalidated on error", + ).toBe(true); + }); + it("editChatMessage writes the optimistic replacement into cache", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 6a241ad8f5f..ae7f8cb5691 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1417,7 +1417,8 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({ reconcileEditedMessageInCache({ currentData: current, optimisticMessageId: variables.messageId, - responseMessage: response.message, + responseMessages: response.messages ?? [response.message], + deletedMessageIds: response.deleted_message_ids, }), ); }, diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 945a5724e6e..e87c37daabd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -1,14 +1,18 @@ import { act, renderHook } from "@testing-library/react"; import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatQueuedMessage } from "#/api/typesGenerated"; -import { MockChatQueuedMessage } from "#/testHelpers/chatEntities"; +import type { ChatMessage, ChatQueuedMessage } from "#/api/typesGenerated"; +import { + MockChatMessage, + MockChatQueuedMessage, +} from "#/testHelpers/chatEntities"; import { createDeferred } from "#/testHelpers/deferred"; import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities"; import { draftInputStorageKeyPrefix, getPersistedDraftInputValue, getWorkspaceOptionsWithLinkedWorkspace, + reconcilePromotedQueueHead, restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, submitEditAndScroll, @@ -284,6 +288,100 @@ describe("runPromoteQueuedMessage", () => { }); }); +describe("reconcilePromotedQueueHead", () => { + const buildQueuedMessage = (id: number, text: string): ChatQueuedMessage => ({ + ...MockChatQueuedMessage, + id, + content: [{ type: "text", text }], + }); + const userMessage: ChatMessage = { ...MockChatMessage, id: 10, role: "user" }; + const toolMessage: ChatMessage = { ...MockChatMessage, id: 9, role: "tool" }; + + it("suppresses the captured head and appends the queued tail", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const tail = buildQueuedMessage(3, "C"); + store.setQueuedMessages([a, b]); + + const reconciled = reconcilePromotedQueueHead( + store, + [toolMessage, userMessage], + a.id, + tail, + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([b.id, tail.id]); + expect(snapshot.suppressedQueuedMessageIDs.has(a.id)).toBe(true); + expect(reconciled?.map((m) => m.id)).toEqual([b.id, tail.id]); + }); + + it("does not suppress the rotated head when a queue_update already applied", () => { + const store = createChatStore(); + // Pre-send queue was [a, b]; a was promoted and c was queued, + // and the authoritative post-promotion snapshot [b, c] landed + // before the send response. Re-appending c must not duplicate it. + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const c = buildQueuedMessage(3, "C"); + store.setQueuedMessages([b, c]); + + reconcilePromotedQueueHead(store, [userMessage], a.id, c); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([b.id, c.id]); + expect(snapshot.suppressedQueuedMessageIDs.has(a.id)).toBe(true); + expect(snapshot.suppressedQueuedMessageIDs.has(b.id)).toBe(false); + expect(snapshot.suppressedQueuedMessageIDs.has(c.id)).toBe(false); + + // A late pre-promotion snapshot must not resurrect the + // promoted row, while the post-promotion snapshot clears the + // suppression entry. + store.applyAuthoritativeQueuedMessages([a, b, c]); + expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([ + b.id, + c.id, + ]); + store.applyAuthoritativeQueuedMessages([b, c]); + expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); + }); + + it("does nothing when no user row was inserted", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + store.setQueuedMessages([a]); + + const reconciled = reconcilePromotedQueueHead( + store, + [toolMessage], + a.id, + buildQueuedMessage(2, "B"), + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages.map((m) => m.id)).toEqual([a.id]); + expect(snapshot.suppressedQueuedMessageIDs.size).toBe(0); + expect(reconciled).toBeUndefined(); + }); + + it("does nothing when no head was captured before the send", () => { + const store = createChatStore(); + + const reconciled = reconcilePromotedQueueHead( + store, + [userMessage], + undefined, + buildQueuedMessage(1, "A"), + ); + + const snapshot = store.getSnapshot(); + expect(snapshot.queuedMessages).toEqual([]); + expect(snapshot.suppressedQueuedMessageIDs.size).toBe(0); + expect(reconciled).toBeUndefined(); + }); +}); + describe("useConversationEditingState", () => { const chatID = "chat-abc-123"; const expectedKey = `${draftInputStorageKeyPrefix}${chatID}`; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index c96001d1ce2..70767b4a93c 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -115,6 +115,7 @@ import { import { type ChatDetailError, formatUsageLimitMessage, + isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "./utils/usageLimitMessage"; @@ -232,6 +233,37 @@ export const runPromoteQueuedMessage = async (params: { } }; +// promotedHeadID must be the queue head captured before the send because +// queue updates can rotate the current head before the response arrives. +export const reconcilePromotedQueueHead = ( + store: Pick< + ChatStore, + "batch" | "getSnapshot" | "setQueuedMessages" | "suppressQueuedMessageID" + >, + insertedMessages: readonly TypesGen.ChatMessage[], + promotedHeadID: number | undefined, + queuedTail: TypesGen.ChatQueuedMessage | undefined, +): readonly TypesGen.ChatQueuedMessage[] | undefined => { + if (promotedHeadID === undefined) { + return undefined; + } + if (!insertedMessages.some((message) => message.role === "user")) { + return undefined; + } + const remaining = store + .getSnapshot() + .queuedMessages.filter((message) => message.id !== promotedHeadID); + const next = + queuedTail && !remaining.some((message) => message.id === queuedTail.id) + ? [...remaining, queuedTail] + : remaining; + store.batch(() => { + store.suppressQueuedMessageID(promotedHeadID); + store.setQueuedMessages(next); + }); + return next; +}; + export async function submitEditAndScroll({ editMessage, editArgs, @@ -1102,7 +1134,12 @@ const AgentChatPage: FC = () => { }; const aiGatewayDisabled = !useAIGatewayEnabled(); - const { store, clearStreamError, upsertCacheMessages } = useChatStore({ + const { + store, + clearStreamError, + setCacheQueuedMessages, + upsertCacheMessages, + } = useChatStore({ chatID: agentId, chatMessages: chatMessagesList, chatRecord, @@ -1254,7 +1291,9 @@ const AgentChatPage: FC = () => { } else if (isApiError(error)) { const detail = error.response?.data?.detail?.trim() || undefined; const reason: ChatDetailError = { - kind: "generic", + kind: isChatHookDispatchFailedResponse(error.response?.data) + ? "hook_dispatch_failed" + : "generic", message: getErrorMessage(error, "An unexpected error occurred."), ...(detail ? { detail } : {}), }; @@ -1627,6 +1666,9 @@ const AgentChatPage: FC = () => { clearStreamError(); scrollToBottomRef.current?.(); + // Capture the queue head before sending because an errored chat may promote it. + const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; + // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle // clearing; for non-queued sends we clear explicitly @@ -1636,12 +1678,16 @@ const AgentChatPage: FC = () => { response = await sendMessage(request); } catch (error) { handleUsageLimitError(error); + // Refresh chat details in case the failed request changed server state. + void queryClient.invalidateQueries({ + queryKey: chatKey(agentId), + exact: true, + }); throw error; } // When the server accepts the message immediately (not - // queued), clear the stream and insert the user's message - // so it appears in the timeline without waiting for the - // WebSocket stream. + // queued), clear the stream so the timeline updates without + // waiting for the WebSocket stream. if (!response.queued) { store.clearStreamState(); // Optimistically set status to "running" so the @@ -1653,9 +1699,26 @@ const AgentChatPage: FC = () => { // to error/pending instead, the WebSocket event // overrides this optimistic value. store.setChatStatus("running"); - if (response.message) { - store.upsertDurableMessage(response.message); - upsertCacheMessages([response.message]); + } + // Prefer the full inserted batch: queued sends can insert + // messages beyond the user row, such as a promoted queue head + // on an errored chat, and a stream reconnect keyed on the + // highest cached ID would skip them, so upsert unconditionally. + const insertedMessages = + response.messages ?? (response.message ? [response.message] : []); + if (insertedMessages.length > 0) { + store.upsertDurableMessages(insertedMessages); + upsertCacheMessages(insertedMessages); + if (response.queued) { + const reconciledQueue = reconcilePromotedQueueHead( + store, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + ); + if (reconciledQueue) { + setCacheQueuedMessages(reconciledQueue); + } } } if (selectedModelConfigID) { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index e58d45bdd1c..a64a2245fa5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -405,6 +405,69 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const LifecycleHookNotice: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "system", + content: [ + { + type: "text", + text: "Your organization requires an approval before deployment.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const notice = canvas.getByRole("note"); + expect(notice).toBeVisible(); + expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); + expect( + within(notice).getByText( + "Your organization requires an approval before deployment.", + ), + ).toBeVisible(); + expect( + canvas.queryByRole("button", { name: "Copy message" }), + ).not.toBeInTheDocument(); + }, +}; + +export const LifecycleHookNoticeOnUserMessage: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "original prompt" }, + { + type: "hook-notice", + text: "Deployment context was added to this prompt.", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const notice = canvas.getByRole("note"); + expect(notice).toBeVisible(); + expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); + expect( + within(notice).getByText("Deployment context was added to this prompt."), + ).toBeVisible(); + expect(canvas.getByText("original prompt")).toBeVisible(); + }, +}; + export const DurableListTemplatesToolLifecycle: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index fa74ec3b7e7..b64557f6ae7 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1,8 +1,14 @@ -import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from "lucide-react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + InfoIcon, + PencilIcon, +} from "lucide-react"; import { type FC, Fragment, memo, + type ReactNode, useLayoutEffect, useRef, useState, @@ -14,6 +20,7 @@ import { preferenceSettings } from "#/api/queries/users"; import type * as TypesGen from "#/api/typesGenerated"; import type { ThinkingDisplayMode } from "#/api/typesGenerated"; +import { AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; import { CopyButton } from "#/components/CopyButton/CopyButton"; import { @@ -512,6 +519,31 @@ export const BlockList: FC<{ ); }; +// Avoid announcing historical hook notices as live alerts. +const TimelineNotice: FC<{ children?: ReactNode }> = ({ children }) => ( +
+
+ +
{children}
+
+
+); + +const LifecycleHookNotice: FC<{ + children: string; + urlTransform?: UrlTransform; +}> = ({ children, urlTransform }) => ( + +
+ Lifecycle hook + {children} +
+
+); + const ChatMessageItem = memo<{ message: TypesGen.ChatMessage; parsed: ParsedMessageContent; @@ -591,6 +623,22 @@ const ChatMessageItem = memo<{ if (displayState.shouldHide) { return null; } + if (message.role === "system") { + return ( +
+ + {parsed.markdown} + +
+ ); + } const conversationItemProps: { role: "user" | "assistant" } = { role: isUser ? "user" : "assistant", @@ -603,6 +651,14 @@ const ChatMessageItem = memo<{ "group/msg relative transition-opacity duration-200", )} > + {parsed.hookNotices.map((notice, index) => ( + + {notice} + + ))} {isUser ? ( = 0; i--) { const entry = displayMessages[i]; + if (entry.message.role === "system") { + nextVisibleIsUser = true; + continue; + } if (entry.message.role !== "user") { flags[i] = nextVisibleIsUser; } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index e887682d58d..53fd37affc8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -58,6 +58,9 @@ export const useChatStore = ( ): { store: ChatStore; clearStreamError: () => void; + setCacheQueuedMessages: ( + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, + ) => void; upsertCacheMessages: (messages: readonly TypesGen.ChatMessage[]) => void; } => { const { @@ -127,6 +130,42 @@ export const useChatStore = ( // its snapshot, defeating pagination. const initialDataLoaded = chatMessages !== undefined; + // Writes an authoritative queued-message snapshot into the + // messages query cache so REST re-hydration cannot replay a stale + // queue over the store. + const setCacheQueuedMessages = useCallback( + (queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined) => { + if (!chatID) { + return; + } + const nextQueuedMessages = queuedMessages ?? []; + queryClient.setQueryData< + InfiniteData | undefined + >(chatMessagesKey(chatID), (currentData) => { + if (!currentData?.pages?.length) { + return currentData; + } + const firstPage = currentData.pages[0]; + if ( + chatQueuedMessagesEqualByID( + firstPage.queued_messages, + nextQueuedMessages, + ) + ) { + return currentData; + } + return { + ...currentData, + pages: [ + { ...firstPage, queued_messages: nextQueuedMessages }, + ...currentData.pages.slice(1), + ], + }; + }); + }, + [chatID, queryClient], + ); + // Write WebSocket-delivered durable messages into the React // Query infinite cache so that navigating away and back // serves up-to-date data instead of the stale REST snapshot. @@ -325,38 +364,6 @@ export const useChatStore = ( }); }; - const updateChatQueuedMessages = ( - queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, - ) => { - if (!chatID) { - return; - } - const nextQueuedMessages = queuedMessages ?? []; - queryClient.setQueryData< - InfiniteData | undefined - >(chatMessagesKey(chatID), (currentData) => { - if (!currentData?.pages?.length) { - return currentData; - } - const firstPage = currentData.pages[0]; - if ( - chatQueuedMessagesEqualByID( - firstPage.queued_messages, - nextQueuedMessages, - ) - ) { - return currentData; - } - return { - ...currentData, - pages: [ - { ...firstPage, queued_messages: nextQueuedMessages }, - ...currentData.pages.slice(1), - ], - }; - }); - }; - store.resetTransientState(); activeChatIDRef.current = chatID ?? null; @@ -572,7 +579,7 @@ export const useChatStore = ( store.applyAuthoritativeQueuedMessages( streamEvent.queued_messages, ); - updateChatQueuedMessages(streamEvent.queued_messages); + setCacheQueuedMessages(streamEvent.queued_messages); continue; case "status": { const nextStatus = streamEvent.status?.status; @@ -716,6 +723,7 @@ export const useChatStore = ( initialDataLoaded, queryClient, replaceCacheMessages, + setCacheQueuedMessages, store, upsertCacheMessages, ]); @@ -724,6 +732,7 @@ export const useChatStore = ( clearStreamError: () => { store.clearStreamError(); }, + setCacheQueuedMessages, upsertCacheMessages, }; }; diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts b/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts index 1f6a2db4c3e..b0a11daed39 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.test.ts @@ -16,6 +16,23 @@ describe("getQueuedMessageInfo", () => { displayText: "hello", rawText: "hello", attachmentCount: 0, + hookNotices: [], + fileBlocks: [], + }); + }); + + it("collects hook notices without polluting the preview text", () => { + const result = getQueuedMessageInfo( + buildMessage([ + { type: "text", text: "hello" }, + { type: "hook-notice", text: "policy notice" }, + ]), + ); + expect(result).toEqual({ + displayText: "hello", + rawText: "hello", + attachmentCount: 0, + hookNotices: ["policy notice"], fileBlocks: [], }); }); @@ -28,6 +45,7 @@ describe("getQueuedMessageInfo", () => { displayText: "line1\nline2", rawText: "line1\nline2", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -40,6 +58,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 1, + hookNotices: [], fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }], }); }); @@ -55,6 +74,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 2, + hookNotices: [], fileBlocks: [ { type: "file", file_id: "a", media_type: "image/png" }, { type: "file", file_id: "b", media_type: "image/png" }, @@ -73,6 +93,7 @@ describe("getQueuedMessageInfo", () => { displayText: "look", rawText: "look", attachmentCount: 1, + hookNotices: [], fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }], }); }); @@ -83,6 +104,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -95,6 +117,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -110,6 +133,7 @@ describe("getQueuedMessageInfo", () => { displayText: "[Queued message]", rawText: "", attachmentCount: 1, + hookNotices: [], fileBlocks: [{ type: "file", file_id: "a", media_type: "image/png" }], }); }); @@ -125,6 +149,7 @@ describe("getQueuedMessageInfo", () => { displayText: "a b", rawText: "a b", attachmentCount: 0, + hookNotices: [], fileBlocks: [], }); }); @@ -141,6 +166,7 @@ describe("getQueuedMessageInfo", () => { displayText: "check this", rawText: "check this", attachmentCount: 2, + hookNotices: [], fileBlocks: [ { type: "file", file_id: "img-1", media_type: "image/png" }, { type: "file", file_id: "doc-2", media_type: "application/pdf" }, diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx index 81d6b394b00..0ea38c43b9d 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx @@ -2,6 +2,7 @@ import { ArrowUpIcon, CornerDownLeftIcon, ImageIcon, + InfoIcon, PencilIcon, Trash2Icon, } from "lucide-react"; @@ -34,34 +35,32 @@ interface QueuedMessageInfo { rawText: string; attachmentCount: number; fileBlocks: readonly ChatMessagePart[]; + hookNotices: string[]; } export const getQueuedMessageInfo = ( message: ChatQueuedMessage, ): QueuedMessageInfo => { - const { content } = message; - const fileBlocks = content.filter((p) => p.type === "file"); + const fileBlocks: ChatMessagePart[] = []; const textParts: string[] = []; - for (const part of content) { - if (part.type === "text" && part.text?.trim()) { + const hookNotices: string[] = []; + for (const part of message.content) { + if (part.type === "file") { + fileBlocks.push(part); + } else if (part.type === "text" && part.text?.trim()) { textParts.push(part.text); + } else if (part.type === "hook-notice" && part.text?.trim()) { + hookNotices.push(part.text); } } const rawText = textParts.join(" ").trim(); - if (rawText) { - return { - displayText: rawText, - rawText, - attachmentCount: fileBlocks.length, - fileBlocks, - }; - } return { - displayText: "[Queued message]", - rawText: "", + displayText: rawText || "[Queued message]", + rawText, attachmentCount: fileBlocks.length, fileBlocks, + hookNotices, }; }; @@ -74,7 +73,7 @@ export const QueuedMessagesList: FC = ({ className, }) => { const items = messages.map((message) => { - const { displayText, rawText, attachmentCount, fileBlocks } = + const { displayText, rawText, attachmentCount, fileBlocks, hookNotices } = getQueuedMessageInfo(message); return { id: message.id, @@ -82,6 +81,7 @@ export const QueuedMessagesList: FC = ({ rawText, attachmentCount, fileBlocks, + hookNotices, }; }); @@ -214,6 +214,22 @@ export const QueuedMessagesList: FC = ({ )} + {item.hookNotices.length > 0 && ( + + + + + + + + {item.hookNotices.join(" ")} + + + )} {isFirst && ( ; + return obj.kind === "hook_dispatch_failed"; +} + /** * Build a user-friendly usage-limit message from structured 409 * response data. Falls back to a generic message if structured From 83fad1b0b8f8cb4adabbadfad53b685673283e97 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:06:54 +0000 Subject: [PATCH 54/86] fix(site): make queued hook notices keyboard-accessible and keep promote suppression - Use a focusable tooltip trigger whose accessible name carries the notice text so keyboard and screen reader users can read queued hook outcomes, and cover the interaction in Storybook. - Skip REST re-hydration snapshots identical to the visible queue so the promoted-queue reconciliation's own cache write cannot lift the promote suppression while a stale pre-promotion queue_update can still arrive. --- .../ChatConversation/useChatStore.ts | 13 ++++++++++ .../components/QueuedMessagesList.stories.tsx | 24 +++++++++++++++++++ .../components/QueuedMessagesList.tsx | 12 +++++----- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 53fd37affc8..7ddc89f13d8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -338,6 +338,19 @@ export const useChatStore = ( return; } queuedMessagesHydratedChatIDRef.current = chatID; + // Skip snapshots identical to the visible queue. The promoted-queue + // reconciliation writes its own optimistic snapshot into the cache, + // and treating that write as authoritative would lift the promote + // suppression while a stale pre-promotion queue_update can still + // arrive and re-show the promoted message. + if ( + chatQueuedMessagesEqualByID( + store.getSnapshot().queuedMessages, + chatQueuedMessages ?? [], + ) + ) { + return; + } store.applyAuthoritativeQueuedMessages(chatQueuedMessages); }, [chatMessagesData, chatID, chatQueuedMessages, store]); diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index 6142665f4b1..231c1b2dae2 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -201,3 +201,27 @@ export const MixedQueueWithAttachments: Story = { ], }, }; + +// A queued message carrying a lifecycle hook notice shows an info +// indicator whose accessible name includes the notice text and whose +// tooltip opens on keyboard focus. +export const HookNotice: Story = { + args: { + messages: [ + buildMessage(1, [ + { type: "text", text: "Deploy to production" }, + { type: "hook-notice", text: "Deployment prompts are audited." }, + ] as ChatQueuedMessage["content"]), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { + name: "Lifecycle hook notice: Deployment prompts are audited.", + }); + await userEvent.tab(); + expect(trigger).toHaveFocus(); + const tooltip = await within(document.body).findByRole("tooltip"); + expect(tooltip).toHaveTextContent("Deployment prompts are audited."); + }, +}; diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx index 0ea38c43b9d..a8d418336d3 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx @@ -217,13 +217,13 @@ export const QueuedMessagesList: FC = ({ {item.hookNotices.length > 0 && ( - - - + {item.hookNotices.join(" ")} From ba1887807a24374f3be071812fa910894609efb1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:36:51 +0000 Subject: [PATCH 55/86] fix(site): hide the inactive sticky message copy from assistive tech StickyUserMessage renders the message twice while stuck; the flow copy was only opacity-hidden, so screen readers encountered the message and its hook notices twice. --- .../components/ChatConversation/ConversationTimeline.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index b64557f6ae7..af8a94b1377 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1074,6 +1074,11 @@ const StickyUserMessage = memo<{ ? { opacity: "calc(1 - var(--overlay-ready, 0))" } : undefined } + // While the overlay copy is shown, drop the flow copy + // from the accessibility tree so the message and its + // hook notices aren't exposed twice. + aria-hidden={isStuck && !isTooTall ? true : undefined} + inert={isStuck && !isTooTall ? true : undefined} > Date: Thu, 23 Jul 2026 17:24:20 +0000 Subject: [PATCH 56/86] fix(site): refresh chat state after promoted sends and failed edits Queued sends that promote a head now clear the stream and set the store to running so the Thinking indicator can appear before the websocket status event. Failed edits invalidate the chat query because hook dispatch failures can park the chat in error server-side. Also trims redundant comments and restores the moved onError test to its original position. --- site/src/api/queries/chatMessageEdits.ts | 5 -- site/src/api/queries/chats.test.ts | 62 ++++++++++----------- site/src/pages/AgentsPage/AgentChatPage.tsx | 12 ++++ 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/site/src/api/queries/chatMessageEdits.ts b/site/src/api/queries/chatMessageEdits.ts index fe0512beada..84235a007c2 100644 --- a/site/src/api/queries/chatMessageEdits.ts +++ b/site/src/api/queries/chatMessageEdits.ts @@ -122,12 +122,7 @@ export const reconcileEditedMessageInCache = ({ }: { currentData: InfiniteData | undefined; optimisticMessageId: number; - // Every message the edit inserted, in insertion order. All of them - // must land in the cache, or a stream reconnect keyed on the - // highest cached ID would skip rows around the replacement. responseMessages: readonly TypesGen.ChatMessage[]; - // Messages the edit soft-deleted. Dropped here so the cache does - // not keep them if the history reset event is missed. deletedMessageIds?: readonly number[]; }): InfiniteData | undefined => { if (!currentData?.pages?.length || responseMessages.length === 0) { diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index da35e9ad44f..9f2f5386377 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1043,6 +1043,37 @@ describe("mutation invalidation scope", () => { ).toBe(true); }); + it("editChatMessage onError invalidates messages", async () => { + const queryClient = createTestQueryClient(); + const chatId = "chat-1"; + const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); + + queryClient.setQueryData(chatMessagesKey(chatId), { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }); + + const mutation = editChatMessage(queryClient, chatId); + mutation.onError( + new Error("fail"), + { messageId: 2, req: editReq }, + { + previousData: { + pages: [{ messages, queued_messages: [], has_more: false }], + pageParams: [undefined], + }, + }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); + expect( + messagesState?.isInvalidated, + "chatMessagesKey should be invalidated on error", + ).toBe(true); + }); + // Shared type for the infinite messages cache shape used by // editChatMessage tests below. type InfMessages = { @@ -1089,37 +1120,6 @@ describe("mutation invalidation scope", () => { requestContent: editReq.content, }); - it("editChatMessage onError invalidates messages", async () => { - const queryClient = createTestQueryClient(); - const chatId = "chat-1"; - const messages = [3, 2, 1].map((id) => makeMsg(chatId, id)); - - queryClient.setQueryData(chatMessagesKey(chatId), { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }); - - const mutation = editChatMessage(queryClient, chatId); - mutation.onError( - new Error("fail"), - { messageId: 2, req: editReq }, - { - previousData: { - pages: [{ messages, queued_messages: [], has_more: false }], - pageParams: [undefined], - }, - }, - ); - - await new Promise((r) => setTimeout(r, 0)); - - const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); - expect( - messagesState?.isInvalidated, - "chatMessagesKey should be invalidated on error", - ).toBe(true); - }); - it("editChatMessage writes the optimistic replacement into cache", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 70767b4a93c..8382b219039 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1635,6 +1635,13 @@ const AgentChatPage: FC = () => { onError: (error) => { restoreOptimisticRequestSnapshot(store, previousSnapshot); handleUsageLimitError(error); + // A failed edit can park the chat in error server-side + // (hook dispatch failures); refresh so the status is not + // stale if the websocket event is missed. + void queryClient.invalidateQueries({ + queryKey: chatKey(agentId), + exact: true, + }); }, }); if (editSelectedModelConfigID) { @@ -1718,6 +1725,11 @@ const AgentChatPage: FC = () => { ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); + // A promoted head means a turn just started; clear the + // stale error status so the Thinking indicator can show + // before the status websocket event arrives. + store.clearStreamState(); + store.setChatStatus("running"); } } } From 1db0dbb3e2410ef7a50afedb03c94688c034b6e0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:07:23 +0000 Subject: [PATCH 57/86] fix(site): drop avoidable cast in the HookNotice story --- .../pages/AgentsPage/components/QueuedMessagesList.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index 231c1b2dae2..caca89806b0 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -211,7 +211,7 @@ export const HookNotice: Story = { buildMessage(1, [ { type: "text", text: "Deploy to production" }, { type: "hook-notice", text: "Deployment prompts are audited." }, - ] as ChatQueuedMessage["content"]), + ]), ], }, play: async ({ canvasElement }) => { From dd429d09b596b0f2acd7ba0077ceffedfb628548 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:02:06 +0000 Subject: [PATCH 58/86] fix(site): keep suppressed queued messages out of the query cache A queue_update that still contains a promoted message was cached raw, so REST re-hydration could re-show the suppressed head. Cache the store's filtered snapshot instead. --- .../ChatConversation/chatStore.test.tsx | 77 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 5 +- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index b4a87870b31..f81b455ba1e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1537,6 +1537,83 @@ describe("useChatStore", () => { expect(cachedData?.pages[0]?.queued_messages).toEqual([]); }); + it("caches the filtered queue when a queue_update still contains a suppressed message", async () => { + const chatID = "chat-1"; + const existingMessage = buildMessage(chatID, 1, "user", "hello"); + const queuedMessage = buildQueuedMessage(chatID, 10, "queued"); + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + networkMode: "offlineFirst", + }, + }, + }); + const initialChatMessagesData: TypesGen.ChatMessagesResponse = { + messages: [existingMessage], + queued_messages: [queuedMessage], + has_more: false, + }; + queryClient.setQueryData(chatMessagesKey(chatID), { + pages: [initialChatMessagesData], + pageParams: [undefined], + }); + + const wrapper = createWrapper(queryClient); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: buildChat(chatID), + chatMessagesData: initialChatMessagesData, + chatQueuedMessages: [queuedMessage], + setChatErrorReason, + clearChatErrorReason, + }); + return { + store, + queuedMessages: useChatSelector(store, selectQueuedMessages), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, 1); + }); + + // Promote the queued message, then deliver a stale queue_update + // that still contains it. + act(() => { + result.current.store.suppressQueuedMessageID(queuedMessage.id); + }); + act(() => { + mockSocket.emitData({ + type: "queue_update", + chat_id: chatID, + queued_messages: [queuedMessage], + }); + }); + + await waitFor(() => { + expect(result.current.queuedMessages).toEqual([]); + }); + const cachedData = queryClient.getQueryData<{ + pages: TypesGen.ChatMessagesResponse[]; + pageParams: unknown[]; + }>(chatMessagesKey(chatID)); + expect(cachedData?.pages[0]?.queued_messages).toEqual([]); + }); + it("writes WebSocket message events into the chat query cache", async () => { const chatID = "chat-1"; const existingMessage = buildMessage(chatID, 1, "user", "hello"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 7ddc89f13d8..4495b22d46a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -592,7 +592,10 @@ export const useChatStore = ( store.applyAuthoritativeQueuedMessages( streamEvent.queued_messages, ); - setCacheQueuedMessages(streamEvent.queued_messages); + // Cache the store's filtered queue, not the raw + // event, so a promoted message suppressed by the + // store cannot reappear on REST re-hydration. + setCacheQueuedMessages(store.getSnapshot().queuedMessages); continue; case "status": { const nextStatus = streamEvent.status?.status; From 71aca5deb003fa18619433f91e19d36fd5e56044 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:48:24 +0000 Subject: [PATCH 59/86] fix(site): drop manual useCallback in the compiler-managed chat store hook Hoist the queued-message cache write to a module-level helper so the hook needs no manual memoization and the effect no longer depends on a locally created callback. --- .../ChatConversation/useChatStore.ts | 87 ++++++++++--------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 4495b22d46a..edfc151df9c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -5,7 +5,11 @@ import { useRef, useState, } from "react"; -import { type InfiniteData, useQueryClient } from "react-query"; +import { + type InfiniteData, + type QueryClient, + useQueryClient, +} from "react-query"; import { watchChat } from "#/api/api"; import { chatMessagesKey, @@ -27,6 +31,40 @@ import { } from "./chatStore"; import type { RetryState } from "./types"; +// Writes an authoritative queued-message snapshot into the messages +// query cache so REST re-hydration cannot replay a stale queue over +// the store. +const writeQueuedMessagesToCache = ( + queryClient: QueryClient, + chatID: string | undefined, + queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, +): void => { + if (!chatID) { + return; + } + const nextQueuedMessages = queuedMessages ?? []; + queryClient.setQueryData< + InfiniteData | undefined + >(chatMessagesKey(chatID), (currentData) => { + if (!currentData?.pages?.length) { + return currentData; + } + const firstPage = currentData.pages[0]; + if ( + chatQueuedMessagesEqualByID(firstPage.queued_messages, nextQueuedMessages) + ) { + return currentData; + } + return { + ...currentData, + pages: [ + { ...firstPage, queued_messages: nextQueuedMessages }, + ...currentData.pages.slice(1), + ], + }; + }); +}; + const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({ attempt: Math.max(1, retry.attempt), error: retry.error.trim() || "Retrying request shortly.", @@ -130,42 +168,6 @@ export const useChatStore = ( // its snapshot, defeating pagination. const initialDataLoaded = chatMessages !== undefined; - // Writes an authoritative queued-message snapshot into the - // messages query cache so REST re-hydration cannot replay a stale - // queue over the store. - const setCacheQueuedMessages = useCallback( - (queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined) => { - if (!chatID) { - return; - } - const nextQueuedMessages = queuedMessages ?? []; - queryClient.setQueryData< - InfiniteData | undefined - >(chatMessagesKey(chatID), (currentData) => { - if (!currentData?.pages?.length) { - return currentData; - } - const firstPage = currentData.pages[0]; - if ( - chatQueuedMessagesEqualByID( - firstPage.queued_messages, - nextQueuedMessages, - ) - ) { - return currentData; - } - return { - ...currentData, - pages: [ - { ...firstPage, queued_messages: nextQueuedMessages }, - ...currentData.pages.slice(1), - ], - }; - }); - }, - [chatID, queryClient], - ); - // Write WebSocket-delivered durable messages into the React // Query infinite cache so that navigating away and back // serves up-to-date data instead of the stale REST snapshot. @@ -595,7 +597,11 @@ export const useChatStore = ( // Cache the store's filtered queue, not the raw // event, so a promoted message suppressed by the // store cannot reappear on REST re-hydration. - setCacheQueuedMessages(store.getSnapshot().queuedMessages); + writeQueuedMessagesToCache( + queryClient, + chatID, + store.getSnapshot().queuedMessages, + ); continue; case "status": { const nextStatus = streamEvent.status?.status; @@ -739,7 +745,6 @@ export const useChatStore = ( initialDataLoaded, queryClient, replaceCacheMessages, - setCacheQueuedMessages, store, upsertCacheMessages, ]); @@ -748,7 +753,9 @@ export const useChatStore = ( clearStreamError: () => { store.clearStreamError(); }, - setCacheQueuedMessages, + setCacheQueuedMessages: (queuedMessages) => { + writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); + }, upsertCacheMessages, }; }; From 0749104bb8cdeea5b0ff48e2a09fe315ec3bc25e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:06:53 +0000 Subject: [PATCH 60/86] fix(site): thread urlTransform through sticky user message hook notices --- .../ChatConversation/ConversationTimeline.stories.tsx | 11 +++++++---- .../ChatConversation/ConversationTimeline.tsx | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index a64a2245fa5..5bb5cb28189 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -441,6 +441,8 @@ export const LifecycleHookNotice: Story = { export const LifecycleHookNoticeOnUserMessage: Story = { args: { ...defaultArgs, + urlTransform: (url) => + url.replace("http://localhost:3000", "https://proxy.example.com"), parsedMessages: buildMessages([ { ...baseMessage, @@ -450,7 +452,7 @@ export const LifecycleHookNoticeOnUserMessage: Story = { { type: "text", text: "original prompt" }, { type: "hook-notice", - text: "Deployment context was added to this prompt.", + text: "Deployment context was added: [policy](http://localhost:3000/policy)", }, ], }, @@ -461,10 +463,11 @@ export const LifecycleHookNoticeOnUserMessage: Story = { const notice = canvas.getByRole("note"); expect(notice).toBeVisible(); expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); - expect( - within(notice).getByText("Deployment context was added to this prompt."), - ).toBeVisible(); expect(canvas.getByText("original prompt")).toBeVisible(); + // The user-message notice must receive the timeline's + // urlTransform even through the sticky message wrapper. + const link = within(notice).getByRole("link", { name: "policy" }); + expect(link).toHaveAttribute("href", "https://proxy.example.com/policy"); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index af8a94b1377..14381b53164 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -835,6 +835,7 @@ const StickyUserMessage = memo<{ nextUserMessageId?: number; onJumpToUserMessage?: (messageId: number) => void; registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void; + urlTransform?: UrlTransform; }>( ({ message, @@ -846,6 +847,7 @@ const StickyUserMessage = memo<{ nextUserMessageId, onJumpToUserMessage, registerSentinel, + urlTransform, }) => { const [isStuck, setIsStuck] = useState(false); const [isReady, setIsReady] = useState(false); @@ -1089,6 +1091,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} /> @@ -1134,6 +1137,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} fadeFromBottom /> @@ -1332,6 +1336,7 @@ export const ConversationTimeline = memo( nextUserMessageId={userNeighborsById.get(message.id)?.nextId} onJumpToUserMessage={jumpToUserMessage} registerSentinel={registerSentinel} + urlTransform={urlTransform} /> ); } From 879bb97b2966e822b99b891d0fe2ef0d8aae9b5c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:43:58 +0000 Subject: [PATCH 61/86] style(site/src/pages/AgentsPage): remove redundant test comments --- site/src/pages/AgentsPage/AgentChatPage.test.ts | 8 ++------ .../ChatConversation/ConversationTimeline.stories.tsx | 2 -- .../components/ChatConversation/chatStore.test.tsx | 2 -- .../AgentsPage/components/QueuedMessagesList.stories.tsx | 3 --- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index e87c37daabd..eb6bc03c777 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -319,9 +319,7 @@ describe("reconcilePromotedQueueHead", () => { it("does not suppress the rotated head when a queue_update already applied", () => { const store = createChatStore(); - // Pre-send queue was [a, b]; a was promoted and c was queued, - // and the authoritative post-promotion snapshot [b, c] landed - // before the send response. Re-appending c must not duplicate it. + // The authoritative [b, c] snapshot arrives before the send response. const a = buildQueuedMessage(1, "A"); const b = buildQueuedMessage(2, "B"); const c = buildQueuedMessage(3, "C"); @@ -335,9 +333,7 @@ describe("reconcilePromotedQueueHead", () => { expect(snapshot.suppressedQueuedMessageIDs.has(b.id)).toBe(false); expect(snapshot.suppressedQueuedMessageIDs.has(c.id)).toBe(false); - // A late pre-promotion snapshot must not resurrect the - // promoted row, while the post-promotion snapshot clears the - // suppression entry. + // A late pre-promotion snapshot must not resurrect the promoted row. store.applyAuthoritativeQueuedMessages([a, b, c]); expect(store.getSnapshot().queuedMessages.map((m) => m.id)).toEqual([ b.id, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 5bb5cb28189..58aa717daa2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -464,8 +464,6 @@ export const LifecycleHookNoticeOnUserMessage: Story = { expect(notice).toBeVisible(); expect(within(notice).getByText("Lifecycle hook")).toBeVisible(); expect(canvas.getByText("original prompt")).toBeVisible(); - // The user-message notice must receive the timeline's - // urlTransform even through the sticky message wrapper. const link = within(notice).getByRole("link", { name: "policy" }); expect(link).toHaveAttribute("href", "https://proxy.example.com/policy"); }, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index f81b455ba1e..53fbfcdf540 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1591,8 +1591,6 @@ describe("useChatStore", () => { expect(watchChat).toHaveBeenCalledWith(chatID, 1); }); - // Promote the queued message, then deliver a stale queue_update - // that still contains it. act(() => { result.current.store.suppressQueuedMessageID(queuedMessage.id); }); diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx index caca89806b0..72ebb3444f9 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.stories.tsx @@ -202,9 +202,6 @@ export const MixedQueueWithAttachments: Story = { }, }; -// A queued message carrying a lifecycle hook notice shows an info -// indicator whose accessible name includes the notice text and whose -// tooltip opens on keyboard focus. export const HookNotice: Story = { args: { messages: [ From da1a282c3fabdc2de6de46c10bbfa8602540ac35 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:13:39 +0000 Subject: [PATCH 62/86] test(site/src/pages/AgentsPage): cover promoted queued sends with an interaction story --- .../AgentsPage/AgentChatPage.stories.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index e629f5030e7..490095ca01f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2848,3 +2848,81 @@ export const SlashCompactYieldsToPersonalSkill: Story = { expect(compactSpy).not.toHaveBeenCalled(); }, }; + +const promotedQueueHeadChat: TypesGen.Chat = { + id: CHAT_ID, + ...baseChatFields, + title: "Promoted queue head", + status: "error", +}; + +const promotedQueueHeadMessages: TypesGen.ChatMessagesResponse = { + messages: compactCommandMessages.messages, + queued_messages: [ + { + ...MockChatQueuedMessage, + id: 41, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Queued head prompt" }], + }, + ], + has_more: false, +}; + +/** A queued send on an errored chat can promote the previous queue head: + * the inserted batch lands in the transcript, the new send becomes the + * queued tail, and the stale error flips to a running Thinking state. */ +export const QueuedSendPromotesPreviousHead: Story = { + parameters: { + queries: buildQueries(promotedQueueHeadChat, promotedQueueHeadMessages, { + diffUrl: undefined, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const promotedHead: TypesGen.ChatMessage = { + ...MockChatMessage, + id: 42, + chat_id: CHAT_ID, + role: "user", + created_at: "2024-01-01T00:01:00Z", + content: [{ type: "text", text: "Queued head prompt" }], + }; + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockResolvedValue({ + queued: true, + messages: [promotedHead], + queued_message: { + ...MockChatQueuedMessage, + id: 43, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Follow-up prompt" }], + }, + }); + + expect(await canvas.findByText("Queued head prompt")).toBeVisible(); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Follow-up prompt"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + // The promoted head moves from the queue into the transcript and + // the new send replaces it as the only queued row. + await waitFor(() => { + expect(canvas.getAllByText("Queued head prompt")).toHaveLength(1); + expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1); + }); + // The promotion started a turn: the Thinking indicator replaces + // the stale error state. + expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); + }, +}; From f3de57b8faa70e820ffc54463f786781597de1fa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:06:52 +0000 Subject: [PATCH 63/86] fix(site): surface tool error reasons for denied and failed tool calls Thread the extracted execute result error into the failure tooltip instead of the hardcoded 'Command failed', and make write_file errors render honestly: an error label instead of 'Wrote ', the result error text in the expanded view, and no args-derived synthetic diff for content that was never written. Covers lifecycle hook denials, which previously looked like successful writes or opaque command failures. --- .../ChatElements/tools/ExecuteTool.tsx | 4 +- .../ChatElements/tools/Tool.stories.tsx | 52 +++++++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 1 + .../ChatElements/tools/WriteFileTool.tsx | 20 +++++-- .../ChatElements/tools/toolVisibility.ts | 2 + 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 329773e0561..7389610ce61 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -36,6 +36,7 @@ type ExecuteToolProps = { transcriptBlocks: readonly ExecuteTranscriptBlock[]; status: ToolStatus; isError: boolean; + errorText?: string; durationMs?: number; isBackgrounded?: boolean; killedBySignal?: "kill" | "terminate"; @@ -49,6 +50,7 @@ export const ExecuteTool: React.FC = ({ transcriptBlocks, status, isError, + errorText, durationMs, isBackgrounded = false, killedBySignal, @@ -88,7 +90,7 @@ export const ExecuteTool: React.FC = ({ className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5" status={status} isError={isError} - errorMessage="Command failed" + errorMessage={errorText || "Command failed"} hasContent defaultView={defaultView} ariaLabel={(expanded) => diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 45f7b4b7e76..b064219b03f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -401,6 +401,28 @@ export const ExecuteError: Story = { }, }; +export const ExecuteDeniedByHook: Story = { + args: { + name: "execute", + status: "error", + isError: true, + args: { command: "cat /etc/secrets" }, + result: { + error: + "Tool call denied by the deployment's lifecycle hook policy. Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the denial to the user and adjust your approach.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("img", { + name: /denied by the deployment's lifecycle hook policy/, + }), + ).toBeVisible(); + expect(canvas.getByText(/Reason: secret reads are blocked/)).toBeVisible(); + }, +}; + export const ExecuteBackgrounded: Story = { args: { name: "execute", @@ -1783,6 +1805,36 @@ export const WriteFileAlwaysExpanded: Story = { }, }; +export const WriteFileDeniedByHook: Story = { + args: { + name: "write_file", + status: "error", + isError: true, + codeDiffDisplayMode: "auto", + args: { + path: "src/utils/helpers.ts", + content: "export const helper = true;\n", + }, + result: { + error: + "Tool call denied by the deployment's lifecycle hook policy. Reason: writes to src are blocked.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Failed to write helpers\.ts/)).toBeInTheDocument(); + await userEvent.click( + canvas.getByRole("button", { name: /Failed to write helpers\.ts/ }), + ); + await waitFor(() => { + expect( + canvas.getByText(/denied by the deployment's lifecycle hook policy/), + ).toBeVisible(); + }); + expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // EditFiles stories // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index c55f76160f8..018d23d4319 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -247,6 +247,7 @@ const ExecuteRenderer: FC = ({ transcriptBlocks={data.transcriptBlocks} status={status} isError={isError} + errorText={data.errorText} durationMs={data.durationMs} isBackgrounded={data.isBackgrounded} killedBySignal={killedBySignal} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index 0a9d41480c4..8b9f652d709 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -39,7 +39,16 @@ export const WriteFileTool: React.FC<{ ); const filename = getPathBasename(path); - const label = isRunning ? `Writing ${filename}…` : `Wrote ${filename}`; + let label = `Wrote ${filename}`; + if (isRunning) { + label = `Writing ${filename}…`; + } else if (isError) { + label = `Failed to write ${filename}`; + } + // The diff is synthesized from the tool args, so on error it would + // show content that was never written. + const showDiff = hasDiff && !isError; + const errorDetail = isError ? errorMessage?.trim() : undefined; return ( - {hasDiff && ( + {errorDetail && ( +
+						{errorDetail}
+					
+ )} + {showDiff && ( Date: Fri, 24 Jul 2026 13:23:30 +0000 Subject: [PATCH 64/86] fix(site): surface edit_files failures and cover the execute errorText field Label failed edit_files calls 'Failed to edit' with the result error text in the expanded view, matching the write_file error rendering, and include the new errorText field in the execute render data unit test. --- .../ChatElements/tools/EditFilesTool.tsx | 32 ++++++++++--------- .../ChatElements/tools/Tool.stories.tsx | 5 ++- .../ChatElements/tools/toolVisibility.test.ts | 1 + 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index a2b40c0a80c..015d9c65bfb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -39,23 +39,20 @@ export const EditFilesTool: React.FC<{ EDIT_FILES_AUTO_DISPLAY_STATE, ); - let label: string; + let verb = "Edited"; if (isRunning) { - if (files.length === 1) { - label = `Editing ${getPathBasename(files[0].path)}…`; - } else if (files.length > 1) { - label = `Editing ${files.length} files…`; - } else { - label = "Editing files…"; - } - } else if (files.length === 1) { - const filename = getPathBasename(files[0].path); - label = `Edited ${filename}`; + verb = "Editing"; + } else if (isError) { + verb = "Failed to edit"; + } + let subject = "files"; + if (files.length === 1) { + subject = getPathBasename(files[0].path); } else if (files.length > 1) { - label = `Edited ${files.length} files`; - } else { - label = "Edited files"; + subject = `${files.length} files`; } + const label = isRunning ? `${verb} ${subject}…` : `${verb} ${subject}`; + const errorDetail = isError ? errorMessage?.trim() : undefined; return ( + {errorDetail && ( +
+						{errorDetail}
+					
+ )}
{diffs.map((diff, i) => diff ? ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index b064219b03f..9ad8dc6fdd6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -2008,7 +2008,10 @@ export const EditFilesError: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/Edited missing\.ts/)).toBeInTheDocument(); + expect(canvas.getByText(/Failed to edit missing\.ts/)).toBeInTheDocument(); + await waitFor(() => { + expect(canvas.getByText("File not found")).toBeVisible(); + }); // On error, no diff body: the synthetic fallback would // misrepresent a rejected edit as applied. expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index 31f68826e00..fe38335b5c5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -22,6 +22,7 @@ describe("toolVisibility", () => { ).toEqual({ command: "git fetch origin", transcriptBlocks: [{ kind: "output", text: "fetched" }], + errorText: "", durationMs: 47200, isBackgrounded: true, authenticateURL: "https://example.com/auth", From 731c4a041227b5b996a792fd210ca3b86ccc8acd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:54:36 +0000 Subject: [PATCH 65/86] fix(site): align hook denial story fixtures with the external policy wording --- .../components/ChatElements/tools/Tool.stories.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 9ad8dc6fdd6..8ff9f529d88 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -409,14 +409,14 @@ export const ExecuteDeniedByHook: Story = { args: { command: "cat /etc/secrets" }, result: { error: - "Tool call denied by the deployment's lifecycle hook policy. Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the denial to the user and adjust your approach.", + "This tool usage was blocked by an external policy (the deployment's lifecycle hook). Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the policy block to the user and adjust your approach.", }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( canvas.getByRole("img", { - name: /denied by the deployment's lifecycle hook policy/, + name: /blocked by an external policy/, }), ).toBeVisible(); expect(canvas.getByText(/Reason: secret reads are blocked/)).toBeVisible(); @@ -1817,7 +1817,7 @@ export const WriteFileDeniedByHook: Story = { }, result: { error: - "Tool call denied by the deployment's lifecycle hook policy. Reason: writes to src are blocked.", + "This tool usage was blocked by an external policy (the deployment's lifecycle hook). Reason: writes to src are blocked.", }, }, play: async ({ canvasElement }) => { @@ -1827,9 +1827,7 @@ export const WriteFileDeniedByHook: Story = { canvas.getByRole("button", { name: /Failed to write helpers\.ts/ }), ); await waitFor(() => { - expect( - canvas.getByText(/denied by the deployment's lifecycle hook policy/), - ).toBeVisible(); + expect(canvas.getByText(/blocked by an external policy/)).toBeVisible(); }); expect(canvas.queryByTestId("write-file-diff")).not.toBeInTheDocument(); }, From cf7c9ac9027a75eedbd42a7557d5eb110456af8e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:26:07 +0000 Subject: [PATCH 66/86] fix(site): align hook denial fixtures with the not-executed wording --- .../AgentsPage/components/ChatElements/tools/Tool.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 8ff9f529d88..7413f39a636 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -409,7 +409,7 @@ export const ExecuteDeniedByHook: Story = { args: { command: "cat /etc/secrets" }, result: { error: - "This tool usage was blocked by an external policy (the deployment's lifecycle hook). Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the policy block to the user and adjust your approach.", + "This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the policy block to the user and adjust your approach.", }, }, play: async ({ canvasElement }) => { @@ -1817,7 +1817,7 @@ export const WriteFileDeniedByHook: Story = { }, result: { error: - "This tool usage was blocked by an external policy (the deployment's lifecycle hook). Reason: writes to src are blocked.", + "This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: writes to src are blocked.", }, }, play: async ({ canvasElement }) => { From 1164044833563e768ed3d401bacb8475ba80afa6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:51:12 +0000 Subject: [PATCH 67/86] fix(site): make dimmed timeline messages inert during edits --- .../ConversationTimeline.stories.tsx | 34 +++++++++++++++++++ .../ChatConversation/ConversationTimeline.tsx | 1 + 2 files changed, 35 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 58aa717daa2..529dc062b8e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -469,6 +469,40 @@ export const LifecycleHookNoticeOnUserMessage: Story = { }, }; +export const LifecycleHookNoticeAfterEditedMessage: Story = { + args: { + ...defaultArgs, + editingMessageId: 1, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [{ type: "text", text: "prompt being edited" }], + }, + { + ...baseMessage, + id: 2, + role: "user", + content: [ + { type: "text", text: "later prompt" }, + { + type: "hook-notice", + text: "Deployment context was added: [policy](http://localhost:3000/policy)", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("prompt being edited")).toBeVisible(); + const link = canvas.getByRole("link", { name: "policy" }); + link.focus(); + expect(link).not.toHaveFocus(); + }, +}; + export const DurableListTemplatesToolLifecycle: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 14381b53164..61912f4c4af 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -650,6 +650,7 @@ const ChatMessageItem = memo<{ isAfterEditingMessage && "opacity-40 pointer-events-none", "group/msg relative transition-opacity duration-200", )} + inert={isAfterEditingMessage ? true : undefined} > {parsed.hookNotices.map((notice, index) => ( Date: Sat, 25 Jul 2026 08:57:17 +0000 Subject: [PATCH 68/86] fix(site/src/pages/AgentsPage): ignore stale queue snapshots after a promotion --- site/src/pages/AgentsPage/AgentChatPage.tsx | 6 +- .../chatStore.createStore.test.ts | 60 +++++++++++++++- .../components/ChatConversation/chatStore.ts | 72 +++++++++++++++++-- 3 files changed, 127 insertions(+), 11 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 8382b219039..ea2b8a00a7a 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -238,7 +238,7 @@ export const runPromoteQueuedMessage = async (params: { export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, - "batch" | "getSnapshot" | "setQueuedMessages" | "suppressQueuedMessageID" + "batch" | "getSnapshot" | "setQueuedMessages" | "markQueuedMessagePromoted" >, insertedMessages: readonly TypesGen.ChatMessage[], promotedHeadID: number | undefined, @@ -258,7 +258,9 @@ export const reconcilePromotedQueueHead = ( ? [...remaining, queuedTail] : remaining; store.batch(() => { - store.suppressQueuedMessageID(promotedHeadID); + // The response carried the promoted user row, so the server has + // already deleted its queue row. + store.markQueuedMessagePromoted(promotedHeadID); store.setQueuedMessages(next); }); return next; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 02ea1467792..c5eb97d6f55 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -439,8 +439,8 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.suppressQueuedMessageID(b.id); expect(store.getSnapshot().suppressedQueuedMessageIDs.has(b.id)).toBe(true); - // Transient reordered queue from the running-case backend - // must not surface the suppressed message. + // The running-case promote only reorders the queue, so the backend + // still reports the suppressed message. store.applyAuthoritativeQueuedMessages([b, a, c]); expect( store.getSnapshot().queuedMessages.map((message) => message.id), @@ -470,6 +470,62 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { ).toEqual([a.id, c.id]); }); + it("still applies newly queued messages while a suppressed message stays queued", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const d = makeQueuedMessage(4, "D"); + + store.setQueuedMessages([b]); + store.suppressQueuedMessageID(a.id); + + store.applyAuthoritativeQueuedMessages([a, b, d]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, d.id]); + expect(store.getSnapshot().suppressedQueuedMessageIDs.has(a.id)).toBe(true); + }); + + it("ignores stale snapshots that still list a promoted message", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); + + // A was promoted into history and C was queued by the same send. + store.setQueuedMessages([b, c]); + store.markQueuedMessagePromoted(a.id); + + // This snapshot predates both the promotion and C. + store.applyAuthoritativeQueuedMessages([a, b]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, c.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.has(a.id)).toBe(true); + + store.applyAuthoritativeQueuedMessages([b, c]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([b.id, c.id]); + expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); + expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); + }); + + it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + store.markQueuedMessagePromoted(a.id); + store.unsuppressQueuedMessageID(a.id); + expect(store.getSnapshot().promotedQueuedMessageIDs.size).toBe(0); + + store.applyAuthoritativeQueuedMessages([a, b]); + expect( + store.getSnapshot().queuedMessages.map((message) => message.id), + ).toEqual([a.id, b.id]); + }); + it("unsuppressQueuedMessageID removes IDs from the suppression set", () => { const store = createChatStore(); store.suppressQueuedMessageID(42); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 5f82c01c0ac..762cd66a201 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -158,6 +158,10 @@ export type ChatStoreState = { // the running-case promote, where the backend reorders the // queued message to the front before auto-promoting it. suppressedQueuedMessageIDs: ReadonlySet; + // Suppressed IDs whose queue row the server has provably deleted, + // because the send response carried the promoted user row. A + // snapshot that still lists one predates that promotion. + promotedQueuedMessageIDs: ReadonlySet; subagentStatusOverrides: Map; }; @@ -186,6 +190,8 @@ export type ChatStore = { queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; suppressQueuedMessageID: (id: number) => void; + // Suppresses id and records that its queue row is already gone. + markQueuedMessagePromoted: (id: number) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -215,6 +221,7 @@ const createInitialState = (): ChatStoreState => ({ reconnectState: null, queuedMessages: [], suppressedQueuedMessageIDs: new Set(), + promotedQueuedMessageIDs: new Set(), subagentStatusOverrides: new Map(), }); @@ -423,6 +430,16 @@ export const createChatStore = (): ChatStore => { applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; setState((current) => { + // A snapshot listing an ID whose row the server already + // deleted predates that deletion, so applying it would both + // revert the queue and drop messages queued since. + if ( + incoming.some((message) => + current.promotedQueuedMessageIDs.has(message.id), + ) + ) { + return current; + } let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -449,13 +466,19 @@ export const createChatStore = (): ChatStore => { ); const sameSuppressed = nextSuppressed === current.suppressedQueuedMessageIDs; - if (sameQueue && sameSuppressed) { + const nextPromoted = + current.promotedQueuedMessageIDs.size === 0 + ? current.promotedQueuedMessageIDs + : new Set(); + const samePromoted = nextPromoted === current.promotedQueuedMessageIDs; + if (sameQueue && sameSuppressed && samePromoted) { return current; } return { ...current, queuedMessages: sameQueue ? current.queuedMessages : filtered, suppressedQueuedMessageIDs: nextSuppressed, + promotedQueuedMessageIDs: nextPromoted, }; }); }, @@ -469,22 +492,57 @@ export const createChatStore = (): ChatStore => { return { ...current, suppressedQueuedMessageIDs: next }; }); }, + markQueuedMessagePromoted: (id) => { + setState((current) => { + if ( + current.suppressedQueuedMessageIDs.has(id) && + current.promotedQueuedMessageIDs.has(id) + ) { + return current; + } + const suppressed = new Set(current.suppressedQueuedMessageIDs); + suppressed.add(id); + const promoted = new Set(current.promotedQueuedMessageIDs); + promoted.add(id); + return { + ...current, + suppressedQueuedMessageIDs: suppressed, + promotedQueuedMessageIDs: promoted, + }; + }); + }, unsuppressQueuedMessageID: (id) => { setState((current) => { - if (!current.suppressedQueuedMessageIDs.has(id)) { + if ( + !current.suppressedQueuedMessageIDs.has(id) && + !current.promotedQueuedMessageIDs.has(id) + ) { return current; } - const next = new Set(current.suppressedQueuedMessageIDs); - next.delete(id); - return { ...current, suppressedQueuedMessageIDs: next }; + const suppressed = new Set(current.suppressedQueuedMessageIDs); + suppressed.delete(id); + const promoted = new Set(current.promotedQueuedMessageIDs); + promoted.delete(id); + return { + ...current, + suppressedQueuedMessageIDs: suppressed, + promotedQueuedMessageIDs: promoted, + }; }); }, clearSuppressedQueuedMessageIDs: () => { setState((current) => { - if (current.suppressedQueuedMessageIDs.size === 0) { + if ( + current.suppressedQueuedMessageIDs.size === 0 && + current.promotedQueuedMessageIDs.size === 0 + ) { return current; } - return { ...current, suppressedQueuedMessageIDs: new Set() }; + return { + ...current, + suppressedQueuedMessageIDs: new Set(), + promotedQueuedMessageIDs: new Set(), + }; }); }, setChatStatus: (status) => { From 98cd3fa6bafc95f340422c1ece86fd2be001a387 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:58:16 +0000 Subject: [PATCH 69/86] test(site/src/pages/AgentsPage): scope promotion story assertions and trim comments --- site/src/pages/AgentsPage/AgentChatPage.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 490095ca01f..41121b32d97 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2915,14 +2915,14 @@ export const QueuedSendPromotesPreviousHead: Story = { expect(sendSpy).toHaveBeenCalledTimes(1); }); - // The promoted head moves from the queue into the transcript and - // the new send replaces it as the only queued row. + const timeline = within(await canvas.findByTestId("conversation-timeline")); await waitFor(() => { + expect(timeline.getByText("Queued head prompt")).toBeVisible(); expect(canvas.getAllByText("Queued head prompt")).toHaveLength(1); expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1); + expect(timeline.queryByText("Follow-up prompt")).not.toBeInTheDocument(); }); - // The promotion started a turn: the Thinking indicator replaces - // the stale error state. + // Promotion starts a turn, so the Thinking indicator replaces the error. expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); }, }; From 3e1edb1b4ebf98b1686cd7160421ebe2e9b3159d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:01:22 +0000 Subject: [PATCH 70/86] style(site/src/pages/AgentsPage): tighten hook queue and story comments --- site/src/pages/AgentsPage/AgentChatPage.stories.tsx | 1 - site/src/pages/AgentsPage/AgentChatPage.tsx | 7 +++---- .../ChatConversation/chatStore.createStore.test.ts | 5 +---- .../components/ChatConversation/chatStore.ts | 12 +++++------- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 41121b32d97..a2bcb675d47 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2922,7 +2922,6 @@ export const QueuedSendPromotesPreviousHead: Story = { expect(canvas.getAllByText("Follow-up prompt")).toHaveLength(1); expect(timeline.queryByText("Follow-up prompt")).not.toBeInTheDocument(); }); - // Promotion starts a turn, so the Thinking indicator replaces the error. expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index ea2b8a00a7a..4ba0ba4f286 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -233,8 +233,8 @@ export const runPromoteQueuedMessage = async (params: { } }; -// promotedHeadID must be the queue head captured before the send because -// queue updates can rotate the current head before the response arrives. +// Use the pre-send queue head because queue updates may rotate it before +// the response arrives. export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, @@ -258,8 +258,7 @@ export const reconcilePromotedQueueHead = ( ? [...remaining, queuedTail] : remaining; store.batch(() => { - // The response carried the promoted user row, so the server has - // already deleted its queue row. + // The promoted user row proves the server deleted its queue row. store.markQueuedMessagePromoted(promotedHeadID); store.setQueuedMessages(next); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index c5eb97d6f55..c95544c2c9a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -439,8 +439,7 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { store.suppressQueuedMessageID(b.id); expect(store.getSnapshot().suppressedQueuedMessageIDs.has(b.id)).toBe(true); - // The running-case promote only reorders the queue, so the backend - // still reports the suppressed message. + // Running-case promotion only reorders the queue; the backend still reports the row. store.applyAuthoritativeQueuedMessages([b, a, c]); expect( store.getSnapshot().queuedMessages.map((message) => message.id), @@ -492,11 +491,9 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { const b = makeQueuedMessage(2, "B"); const c = makeQueuedMessage(3, "C"); - // A was promoted into history and C was queued by the same send. store.setQueuedMessages([b, c]); store.markQueuedMessagePromoted(a.id); - // This snapshot predates both the promotion and C. store.applyAuthoritativeQueuedMessages([a, b]); expect( store.getSnapshot().queuedMessages.map((message) => message.id), diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 762cd66a201..9c576feaafc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -158,9 +158,8 @@ export type ChatStoreState = { // the running-case promote, where the backend reorders the // queued message to the front before auto-promoting it. suppressedQueuedMessageIDs: ReadonlySet; - // Suppressed IDs whose queue row the server has provably deleted, - // because the send response carried the promoted user row. A - // snapshot that still lists one predates that promotion. + // IDs confirmed deleted from the queue because the send response + // contained their promoted user rows. promotedQueuedMessageIDs: ReadonlySet; subagentStatusOverrides: Map; }; @@ -190,7 +189,7 @@ export type ChatStore = { queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, ) => void; suppressQueuedMessageID: (id: number) => void; - // Suppresses id and records that its queue row is already gone. + // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; @@ -430,9 +429,8 @@ export const createChatStore = (): ChatStore => { applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; setState((current) => { - // A snapshot listing an ID whose row the server already - // deleted predates that deletion, so applying it would both - // revert the queue and drop messages queued since. + // A snapshot containing a confirmed promoted ID predates its queue + // deletion. Applying it would also drop newer queued messages. if ( incoming.some((message) => current.promotedQueuedMessageIDs.has(message.id), From ec8631c63a016de58179e2235d00caee9c9aeb13 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:18:01 +0000 Subject: [PATCH 71/86] fix(site/src/pages/AgentsPage): keep post-send reconciliation from clobbering newer server state --- .../pages/AgentsPage/AgentChatPage.test.ts | 18 ++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 22 +++++++++++---- .../chatStore.createStore.test.ts | 28 +++++++++++++++++++ .../components/ChatConversation/chatStore.ts | 12 ++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index eb6bc03c777..4f1b167d21b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -343,6 +343,24 @@ describe("reconcilePromotedQueueHead", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); + it("omits the response tail when a newer queue update was observed", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + // The caller withholds the tail once it has seen a newer queue, + // because that snapshot may already have deleted it. + store.setQueuedMessages([a]); + + const next = reconcilePromotedQueueHead( + store, + [userMessage], + a.id, + undefined, + ); + + expect(next).toEqual([]); + expect(store.getSnapshot().queuedMessages).toEqual([]); + }); + it("does nothing when no user row was inserted", () => { const store = createChatStore(); const a = buildQueuedMessage(1, "A"); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4ba0ba4f286..c0e3275aa81 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1676,6 +1676,8 @@ const AgentChatPage: FC = () => { // Capture the queue head before sending because an errored chat may promote it. const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; + const queueVersionBeforeSend = store.getAuthoritativeQueueVersion(); + const statusVersionBeforeSend = store.getChatStatusVersion(); // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle @@ -1718,19 +1720,27 @@ const AgentChatPage: FC = () => { store.upsertDurableMessages(insertedMessages); upsertCacheMessages(insertedMessages); if (response.queued) { + // A queue update during the request already accounts for the + // tail, and may have deleted it, so merging it back would + // resurrect a phantom entry. + const sawNewerQueue = + store.getAuthoritativeQueueVersion() !== queueVersionBeforeSend; const reconciledQueue = reconcilePromotedQueueHead( store, insertedMessages, queueHeadIDBeforeSend, - response.queued_message, + sawNewerQueue ? undefined : response.queued_message, ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); - // A promoted head means a turn just started; clear the - // stale error status so the Thinking indicator can show - // before the status websocket event arrives. - store.clearStreamState(); - store.setChatStatus("running"); + // A promoted head means a turn just started, so clear the + // stale error status before the status websocket event + // arrives. A status event during the request is already + // newer than this optimistic value. + if (store.getChatStatusVersion() === statusVersionBeforeSend) { + store.clearStreamState(); + store.setChatStatus("running"); + } } } } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index c95544c2c9a..0014de83a15 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -508,6 +508,34 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); + it("tracks authoritative queue and status versions for in-flight requests", () => { + const store = createChatStore(); + const a = makeQueuedMessage(1, "A"); + const b = makeQueuedMessage(2, "B"); + + expect(store.getAuthoritativeQueueVersion()).toBe(0); + store.applyAuthoritativeQueuedMessages([a, b]); + const afterApply = store.getAuthoritativeQueueVersion(); + expect(afterApply).toBeGreaterThan(0); + + // Optimistic writes are not server observations. + store.setQueuedMessages([b]); + expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + + // An ignored stale snapshot is not an observation either. + store.markQueuedMessagePromoted(a.id); + store.applyAuthoritativeQueuedMessages([a, b]); + expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + + expect(store.getChatStatusVersion()).toBe(0); + store.setChatStatus("running"); + expect(store.getChatStatusVersion()).toBe(1); + store.setChatStatus("running"); + expect(store.getChatStatusVersion()).toBe(1); + store.setChatStatus("error"); + expect(store.getChatStatusVersion()).toBe(2); + }); + it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => { const store = createChatStore(); const a = makeQueuedMessage(1, "A"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 9c576feaafc..53289349cb6 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -191,6 +191,10 @@ export type ChatStore = { suppressQueuedMessageID: (id: number) => void; // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; + // Counters for detecting that the server reported a newer queue or + // status while a request was in flight. + getAuthoritativeQueueVersion: () => number; + getChatStatusVersion: () => number; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -226,6 +230,10 @@ const createInitialState = (): ChatStoreState => ({ export const createChatStore = (): ChatStore => { let state = createInitialState(); + // Bookkeeping, deliberately outside the rendered state so observing a + // server event cannot trigger a re-render. + let authoritativeQueueVersion = 0; + let chatStatusVersion = 0; const listeners = new Set<() => void>(); const emit = (): void => { @@ -438,6 +446,7 @@ export const createChatStore = (): ChatStore => { ) { return current; } + authoritativeQueueVersion++; let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -480,6 +489,8 @@ export const createChatStore = (): ChatStore => { }; }); }, + getAuthoritativeQueueVersion: () => authoritativeQueueVersion, + getChatStatusVersion: () => chatStatusVersion, suppressQueuedMessageID: (id) => { setState((current) => { if (current.suppressedQueuedMessageIDs.has(id)) { @@ -547,6 +558,7 @@ export const createChatStore = (): ChatStore => { if (state.chatStatus === status) { return; } + chatStatusVersion++; setState((current) => ({ ...current, chatStatus: status, From d26d308238d9ddce5c41be976d401fc3f74e6938 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:34:33 +0000 Subject: [PATCH 72/86] fix(site/src/pages/AgentsPage): give hook dispatch failures their own error title --- .../AgentsPage/AgentChatPage.stories.tsx | 45 +++++++++++++++++++ .../ChatConversation/chatStatusHelpers.ts | 2 + 2 files changed, 47 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index a2bcb675d47..38c7e412173 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2925,3 +2925,48 @@ export const QueuedSendPromotesPreviousHead: Story = { expect(await canvas.findByTestId("live-activity-slot")).toBeVisible(); }, }; + +/** A send rejected with the structured 502 hook-dispatch-failure body must + * render the lifecycle-hook title and the server's detail text, not the + * generic request-failure fallback. */ +export const SendRejectedByHookDispatchFailure: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Hook failure", + status: "waiting", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + spyOn(API.experimental, "createChatMessage").mockRejectedValue({ + isAxiosError: true, + response: { + status: 502, + data: { + message: "Lifecycle hook dispatch failed.", + detail: "Dispatch 0f2c1f3e timed out after 1.5s.", + kind: "hook_dispatch_failed", + }, + }, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Trigger the hook failure"); + await userEvent.keyboard("{Enter}"); + + expect(await canvas.findByText("Lifecycle hook failed")).toBeVisible(); + expect( + await canvas.findByText("Dispatch 0f2c1f3e timed out after 1.5s."), + ).toBeVisible(); + expect(canvas.queryByText("Request failed")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 51550c5042c..137dc5cb263 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -48,6 +48,8 @@ export const getErrorTitle = ( return "Provider disabled"; case "content_filter": return "Response blocked"; + case "hook_dispatch_failed": + return "Lifecycle hook failed"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } From fab7ea99858f2e61ae5f7ff9b9668b4c8069ca24 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:50:08 +0000 Subject: [PATCH 73/86] refactor(site/src/pages/AgentsPage): narrow the hook response guard without a cast --- site/src/pages/AgentsPage/utils/usageLimitMessage.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 5ff7ea77220..c15d7c22bfe 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -87,11 +87,12 @@ export function isChatUsageLimitExceededResponse( export function isChatHookDispatchFailedResponse( value: unknown, ): value is TypesGen.ChatHookDispatchFailedResponse { - if (value == null || typeof value !== "object") { - return false; - } - const obj = value as Record; - return obj.kind === "hook_dispatch_failed"; + return ( + typeof value === "object" && + value !== null && + "kind" in value && + value.kind === "hook_dispatch_failed" + ); } /** From ad1ad96e5be270f220ce4fb4083bf375db6ea041 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:18:29 +0000 Subject: [PATCH 74/86] fix(site/src/pages/AgentsPage): apply the refetched status after a failed request --- site/src/pages/AgentsPage/AgentChatPage.tsx | 3 + .../ChatConversation/chatStore.test.tsx | 59 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 7 +++ 3 files changed, 69 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index c0e3275aa81..a5cf7975d27 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1137,6 +1137,7 @@ const AgentChatPage: FC = () => { const aiGatewayDisabled = !useAIGatewayEnabled(); const { store, + acceptServerChatStatus, clearStreamError, setCacheQueuedMessages, upsertCacheMessages, @@ -1639,6 +1640,7 @@ const AgentChatPage: FC = () => { // A failed edit can park the chat in error server-side // (hook dispatch failures); refresh so the status is not // stale if the websocket event is missed. + acceptServerChatStatus(); void queryClient.invalidateQueries({ queryKey: chatKey(agentId), exact: true, @@ -1689,6 +1691,7 @@ const AgentChatPage: FC = () => { } catch (error) { handleUsageLimitError(error); // Refresh chat details in case the failed request changed server state. + acceptServerChatStatus(); void queryClient.invalidateQueries({ queryKey: chatKey(agentId), exact: true, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 53fbfcdf540..5687a552ea9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2290,6 +2290,65 @@ describe("useChatStore", () => { }); }); + it("applies a refetched status after acceptServerChatStatus", async () => { + const chatID = "chat-resync"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + + const { result, rerender } = renderHook( + ({ status }: { status: TypesGen.ChatStatus }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status }, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: vi.fn(), + clearChatErrorReason: vi.fn(), + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper, initialProps: { status: "waiting" as TypesGen.ChatStatus } }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + + // The socket becomes authoritative, so a refetched status is ignored. + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + rerender({ status: "error" }); + expect(result.current.chatStatus).toBe("running"); + + // A failed request opts back in, so the next refetch applies. + act(() => { + result.current.acceptServerChatStatus(); + }); + rerender({ status: "waiting" }); + rerender({ status: "error" }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("error"); + }); + }); + it("sets chatStatus to error and populates streamError on error event", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index edfc151df9c..34e18a683cf 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -95,6 +95,7 @@ export const useChatStore = ( options: UseChatStoreOptions, ): { store: ChatStore; + acceptServerChatStatus: () => void; clearStreamError: () => void; setCacheQueuedMessages: ( queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined, @@ -753,6 +754,12 @@ export const useChatStore = ( clearStreamError: () => { store.clearStreamError(); }, + // A failed request can change server-side chat status while the + // socket is down, and the socket having already delivered a status + // otherwise makes the refetched one inert. + acceptServerChatStatus: () => { + wsStatusReceivedRef.current = false; + }, setCacheQueuedMessages: (queuedMessages) => { writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); }, From 9f7c512c0105cf5a1bb23228c7302e875824d41b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:27:15 +0000 Subject: [PATCH 75/86] fix(site/src/pages/AgentsPage): key the queued tail on server observation --- .../pages/AgentsPage/AgentChatPage.test.ts | 28 +++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 26 ++++++++-------- .../chatStore.createStore.test.ts | 30 ++++++++++++------- .../components/ChatConversation/chatStore.ts | 17 +++++++---- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 4f1b167d21b..b5eb080f0fd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -343,6 +343,34 @@ describe("reconcilePromotedQueueHead", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); + it("keeps the response tail when a stale snapshot arrived mid-request", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const b = buildQueuedMessage(2, "B"); + const c = buildQueuedMessage(3, "C"); + // A pre-send snapshot lands while the POST is in flight; it cannot + // mention the tail the send just created. + store.setQueuedMessages([a]); + store.applyAuthoritativeQueuedMessages([a, b]); + + const next = reconcilePromotedQueueHead(store, [userMessage], a.id, c); + + expect(next?.map((m) => m.id)).toEqual([b.id, c.id]); + }); + + it("drops the response tail once the server reported and removed it", () => { + const store = createChatStore(); + const a = buildQueuedMessage(1, "A"); + const c = buildQueuedMessage(3, "C"); + // The server acknowledged the tail, then a later snapshot deleted it. + store.applyAuthoritativeQueuedMessages([a, c]); + store.applyAuthoritativeQueuedMessages([a]); + + const next = reconcilePromotedQueueHead(store, [userMessage], a.id, c); + + expect(next).toEqual([]); + }); + it("omits the response tail when a newer queue update was observed", () => { const store = createChatStore(); const a = buildQueuedMessage(1, "A"); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index a5cf7975d27..717252c1204 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -238,7 +238,11 @@ export const runPromoteQueuedMessage = async (params: { export const reconcilePromotedQueueHead = ( store: Pick< ChatStore, - "batch" | "getSnapshot" | "setQueuedMessages" | "markQueuedMessagePromoted" + | "batch" + | "getSnapshot" + | "setQueuedMessages" + | "markQueuedMessagePromoted" + | "hasObservedQueuedMessageID" >, insertedMessages: readonly TypesGen.ChatMessage[], promotedHeadID: number | undefined, @@ -253,10 +257,14 @@ export const reconcilePromotedQueueHead = ( const remaining = store .getSnapshot() .queuedMessages.filter((message) => message.id !== promotedHeadID); - const next = - queuedTail && !remaining.some((message) => message.id === queuedTail.id) - ? [...remaining, queuedTail] - : remaining; + // Append the tail only while the server has never reported it. Once a + // snapshot has listed it, its later absence means it was deleted, so + // re-adding it would resurrect a phantom row. + const tailPending = + queuedTail !== undefined && + !remaining.some((message) => message.id === queuedTail.id) && + !store.hasObservedQueuedMessageID(queuedTail.id); + const next = tailPending ? [...remaining, queuedTail] : remaining; store.batch(() => { // The promoted user row proves the server deleted its queue row. store.markQueuedMessagePromoted(promotedHeadID); @@ -1678,7 +1686,6 @@ const AgentChatPage: FC = () => { // Capture the queue head before sending because an errored chat may promote it. const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; - const queueVersionBeforeSend = store.getAuthoritativeQueueVersion(); const statusVersionBeforeSend = store.getChatStatusVersion(); // Don't clear stream state before the POST completes. @@ -1723,16 +1730,11 @@ const AgentChatPage: FC = () => { store.upsertDurableMessages(insertedMessages); upsertCacheMessages(insertedMessages); if (response.queued) { - // A queue update during the request already accounts for the - // tail, and may have deleted it, so merging it back would - // resurrect a phantom entry. - const sawNewerQueue = - store.getAuthoritativeQueueVersion() !== queueVersionBeforeSend; const reconciledQueue = reconcilePromotedQueueHead( store, insertedMessages, queueHeadIDBeforeSend, - sawNewerQueue ? undefined : response.queued_message, + response.queued_message, ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 0014de83a15..48b0ad4fea1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -508,24 +508,32 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.getSnapshot().suppressedQueuedMessageIDs.size).toBe(0); }); - it("tracks authoritative queue and status versions for in-flight requests", () => { + it("records queued IDs the server has reported", () => { const store = createChatStore(); const a = makeQueuedMessage(1, "A"); const b = makeQueuedMessage(2, "B"); + const c = makeQueuedMessage(3, "C"); - expect(store.getAuthoritativeQueueVersion()).toBe(0); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); store.applyAuthoritativeQueuedMessages([a, b]); - const afterApply = store.getAuthoritativeQueueVersion(); - expect(afterApply).toBeGreaterThan(0); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); + expect(store.hasObservedQueuedMessageID(b.id)).toBe(true); - // Optimistic writes are not server observations. - store.setQueuedMessages([b]); - expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + // A later snapshot dropping A does not unlearn that A existed. + store.applyAuthoritativeQueuedMessages([b]); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(true); - // An ignored stale snapshot is not an observation either. - store.markQueuedMessagePromoted(a.id); - store.applyAuthoritativeQueuedMessages([a, b]); - expect(store.getAuthoritativeQueueVersion()).toBe(afterApply); + // Optimistic writes are not server reports. + store.setQueuedMessages([b, c]); + expect(store.hasObservedQueuedMessageID(c.id)).toBe(false); + + // Observations are per-chat. + store.clearSuppressedQueuedMessageIDs(); + expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); + }); + + it("tracks chat status versions for in-flight requests", () => { + const store = createChatStore(); expect(store.getChatStatusVersion()).toBe(0); store.setChatStatus("running"); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 53289349cb6..4db8d077d83 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -191,9 +191,11 @@ export type ChatStore = { suppressQueuedMessageID: (id: number) => void; // Suppresses id and records that its queue row is already deleted. markQueuedMessagePromoted: (id: number) => void; - // Counters for detecting that the server reported a newer queue or - // status while a request was in flight. - getAuthoritativeQueueVersion: () => number; + // Reports whether any authoritative snapshot has listed id. A tail the + // server never mentioned is still in flight; one it mentioned and then + // dropped was deleted. + hasObservedQueuedMessageID: (id: number) => boolean; + // Detects that the server reported a newer status mid-request. getChatStatusVersion: () => number; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; @@ -232,7 +234,7 @@ export const createChatStore = (): ChatStore => { let state = createInitialState(); // Bookkeeping, deliberately outside the rendered state so observing a // server event cannot trigger a re-render. - let authoritativeQueueVersion = 0; + let observedQueuedMessageIDs = new Set(); let chatStatusVersion = 0; const listeners = new Set<() => void>(); @@ -436,6 +438,9 @@ export const createChatStore = (): ChatStore => { }, applyAuthoritativeQueuedMessages: (queuedMessages) => { const incoming = queuedMessages ?? []; + for (const message of incoming) { + observedQueuedMessageIDs.add(message.id); + } setState((current) => { // A snapshot containing a confirmed promoted ID predates its queue // deletion. Applying it would also drop newer queued messages. @@ -446,7 +451,6 @@ export const createChatStore = (): ChatStore => { ) { return current; } - authoritativeQueueVersion++; let nextSuppressed = current.suppressedQueuedMessageIDs; if (current.suppressedQueuedMessageIDs.size > 0) { const incomingIDs = new Set(incoming.map((message) => message.id)); @@ -489,7 +493,7 @@ export const createChatStore = (): ChatStore => { }; }); }, - getAuthoritativeQueueVersion: () => authoritativeQueueVersion, + hasObservedQueuedMessageID: (id) => observedQueuedMessageIDs.has(id), getChatStatusVersion: () => chatStatusVersion, suppressQueuedMessageID: (id) => { setState((current) => { @@ -540,6 +544,7 @@ export const createChatStore = (): ChatStore => { }); }, clearSuppressedQueuedMessageIDs: () => { + observedQueuedMessageIDs = new Set(); setState((current) => { if ( current.suppressedQueuedMessageIDs.size === 0 && From 7f9b71756ed532b1726ff48d8e18005c04a397fc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:12:57 +0000 Subject: [PATCH 76/86] test(site/src/pages/AgentsPage): type the status fixture without a cast --- .../AgentsPage/components/ChatConversation/chatStore.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 5687a552ea9..ce248fcca2e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2297,6 +2297,7 @@ describe("useChatStore", () => { const queryClient = createTestQueryClient(); const wrapper = createWrapper(queryClient); + const initialProps: { status: TypesGen.ChatStatus } = { status: "waiting" }; const { result, rerender } = renderHook( ({ status }: { status: TypesGen.ChatStatus }) => { const { store, acceptServerChatStatus } = useChatStore({ @@ -2317,7 +2318,7 @@ describe("useChatStore", () => { chatStatus: useChatSelector(store, selectChatStatus), }; }, - { wrapper, initialProps: { status: "waiting" as TypesGen.ChatStatus } }, + { wrapper, initialProps }, ); await waitFor(() => { From b871933bae0406567be5318236778590a4a1ebd9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:35:04 +0000 Subject: [PATCH 77/86] fix(site/src/pages/AgentsPage): count repeated server status reports as newer --- site/src/pages/AgentsPage/AgentChatPage.tsx | 4 ++-- .../chatStore.createStore.test.ts | 21 +++++++++++------ .../components/ChatConversation/chatStore.ts | 23 +++++++++++++++---- .../ChatConversation/useChatStore.ts | 4 ++-- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 717252c1204..2c88f87103e 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1686,7 +1686,7 @@ const AgentChatPage: FC = () => { // Capture the queue head before sending because an errored chat may promote it. const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; - const statusVersionBeforeSend = store.getChatStatusVersion(); + const statusVersionBeforeSend = store.getServerChatStatusVersion(); // Don't clear stream state before the POST completes. // For queued sends the WebSocket status events handle @@ -1742,7 +1742,7 @@ const AgentChatPage: FC = () => { // stale error status before the status websocket event // arrives. A status event during the request is already // newer than this optimistic value. - if (store.getChatStatusVersion() === statusVersionBeforeSend) { + if (store.getServerChatStatusVersion() === statusVersionBeforeSend) { store.clearStreamState(); store.setChatStatus("running"); } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts index 48b0ad4fea1..88f6f3c3a02 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.createStore.test.ts @@ -532,16 +532,23 @@ describe("suppressQueuedMessageID / applyAuthoritativeQueuedMessages", () => { expect(store.hasObservedQueuedMessageID(a.id)).toBe(false); }); - it("tracks chat status versions for in-flight requests", () => { + it("counts every server status report, including repeats", () => { const store = createChatStore(); - expect(store.getChatStatusVersion()).toBe(0); - store.setChatStatus("running"); - expect(store.getChatStatusVersion()).toBe(1); + expect(store.getServerChatStatusVersion()).toBe(0); + + // Optimistic writes are not server reports. store.setChatStatus("running"); - expect(store.getChatStatusVersion()).toBe(1); - store.setChatStatus("error"); - expect(store.getChatStatusVersion()).toBe(2); + expect(store.getServerChatStatusVersion()).toBe(0); + + store.applyServerChatStatus("error"); + expect(store.getServerChatStatusVersion()).toBe(1); + expect(store.getSnapshot().chatStatus).toBe("error"); + + // A repeat of the current value is still the server speaking. + store.applyServerChatStatus("error"); + expect(store.getServerChatStatusVersion()).toBe(2); + expect(store.getSnapshot().chatStatus).toBe("error"); }); it("unsuppressQueuedMessageID clears a promoted marker after a failed promotion", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 4db8d077d83..4357325edac 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -195,8 +195,12 @@ export type ChatStore = { // server never mentioned is still in flight; one it mentioned and then // dropped was deleted. hasObservedQueuedMessageID: (id: number) => boolean; - // Detects that the server reported a newer status mid-request. - getChatStatusVersion: () => number; + // Counts server-reported status events, including repeats of the + // current value, so a caller can tell that the server spoke during a + // request even when the status did not change. + getServerChatStatusVersion: () => number; + // Records a server-reported status; always counts as an observation. + applyServerChatStatus: (status: TypesGen.ChatStatus | null) => void; unsuppressQueuedMessageID: (id: number) => void; clearSuppressedQueuedMessageIDs: () => void; setChatStatus: (status: TypesGen.ChatStatus | null) => void; @@ -235,7 +239,7 @@ export const createChatStore = (): ChatStore => { // Bookkeeping, deliberately outside the rendered state so observing a // server event cannot trigger a re-render. let observedQueuedMessageIDs = new Set(); - let chatStatusVersion = 0; + let serverChatStatusVersion = 0; const listeners = new Set<() => void>(); const emit = (): void => { @@ -494,7 +498,6 @@ export const createChatStore = (): ChatStore => { }); }, hasObservedQueuedMessageID: (id) => observedQueuedMessageIDs.has(id), - getChatStatusVersion: () => chatStatusVersion, suppressQueuedMessageID: (id) => { setState((current) => { if (current.suppressedQueuedMessageIDs.has(id)) { @@ -563,7 +566,17 @@ export const createChatStore = (): ChatStore => { if (state.chatStatus === status) { return; } - chatStatusVersion++; + setState((current) => ({ + ...current, + chatStatus: status, + })); + }, + getServerChatStatusVersion: () => serverChatStatusVersion, + applyServerChatStatus: (status) => { + serverChatStatusVersion++; + if (state.chatStatus === status) { + return; + } setState((current) => ({ ...current, chatStatus: status, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 34e18a683cf..b381b1e5ce0 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -612,7 +612,7 @@ export const useChatStore = ( wsStatusReceivedRef.current = true; store.clearRetryState(); - store.setChatStatus(nextStatus); + store.applyServerChatStatus(nextStatus); if (nextStatus === "waiting") { discardBufferedParts(); } @@ -631,7 +631,7 @@ export const useChatStore = ( kind: "generic", message: "Chat processing failed.", }; - store.setChatStatus("error"); + store.applyServerChatStatus("error"); store.setStreamError(reason); store.clearRetryState(); setChatErrorReasonEvent(chatID, reason); From 7e280124e6f4dc3279541e53d5247538fa0b313a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:45:06 +0000 Subject: [PATCH 78/86] test(site/src/api/queries): clarify why the reconciled page order inverts --- site/src/api/queries/chatMessageEdits.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/queries/chatMessageEdits.test.ts b/site/src/api/queries/chatMessageEdits.test.ts index 8314f157e3b..480bfaa82a6 100644 --- a/site/src/api/queries/chatMessageEdits.test.ts +++ b/site/src/api/queries/chatMessageEdits.test.ts @@ -89,7 +89,7 @@ describe("reconcileEditedMessageInCache", () => { }); const ids = reconciled?.pages[0]?.messages.map((message) => message.id); - // The first page is ordered newest first. + // Reversed from responseMessages: the first page is newest first. expect(ids).toEqual([replacement.id, newNotice.id]); }); }); From bf86b183278341e7387754d6644edc59ce22f76d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:13:03 +0000 Subject: [PATCH 79/86] fix(site/src/pages/AgentsPage): hydrate an unchanged status after a resync --- .../ChatConversation/chatStore.test.tsx | 54 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 11 +++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index ce248fcca2e..70c04f209a9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2350,6 +2350,60 @@ describe("useChatStore", () => { }); }); + it("hydrates a refetched status that never changed value", async () => { + const chatID = "chat-resync-same"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + + // The cache already holds "error" while the socket pushes "running", + // so opting back in must apply the cached value without it changing. + const { result } = renderHook( + () => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status: "error" }, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: vi.fn(), + clearChatErrorReason: vi.fn(), + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + + act(() => { + result.current.acceptServerChatStatus(); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("error"); + }); + }); + it("sets chatStatus to error and populates streamError on error event", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index b381b1e5ce0..6cc87840a6f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -131,6 +131,7 @@ export const useChatStore = ( // stale value like "waiting", causing shouldApplyMessagePart() // to drop all incoming parts. const wsStatusReceivedRef = useRef(false); + const [pendingStatusResync, setPendingStatusResync] = useState(false); const activeChatIDRef = useRef(null); const prevChatIDRef = useRef(chatID); // Snapshot of the chatMessages elements from the last sync effect @@ -306,10 +307,15 @@ export const useChatStore = ( // a status event yet. Once the WS is the authoritative // source, a stale REST refetch must not overwrite the // fresher WS-delivered value. - if (!wsStatusReceivedRef.current) { + if (!wsStatusReceivedRef.current || pendingStatusResync) { store.setChatStatus(chatRecord?.status ?? null); } - }, [chatRecord?.status, store]); + // A resync must apply the cached status even when its value never + // changed, which happens when the store drifted ahead of it. + if (pendingStatusResync) { + setPendingStatusResync(false); + } + }, [chatRecord?.status, store, pendingStatusResync]); useEffect(() => { queuedMessagesHydratedChatIDRef.current = null; @@ -759,6 +765,7 @@ export const useChatStore = ( // otherwise makes the refetched one inert. acceptServerChatStatus: () => { wsStatusReceivedRef.current = false; + setPendingStatusResync(true); }, setCacheQueuedMessages: (queuedMessages) => { writeQueuedMessagesToCache(queryClient, chatID, queuedMessages); From 96a432de22c551b013e018ae420eb52e11225871 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:24:35 +0000 Subject: [PATCH 80/86] fix(site/src/pages/AgentsPage): guard send responses by chat --- site/src/pages/AgentsPage/AgentChatPage.tsx | 83 +++++++++++++------ .../ChatConversation/chatStore.test.tsx | 35 +++++++- .../components/ChatConversation/chatStore.ts | 7 ++ .../ChatConversation/useChatStore.ts | 3 + 4 files changed, 101 insertions(+), 27 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 2c88f87103e..be91ed3a8ee 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -233,6 +233,29 @@ export const runPromoteQueuedMessage = async (params: { } }; +const buildPromotedQueueReconciliation = ( + queuedMessages: readonly TypesGen.ChatQueuedMessage[], + insertedMessages: readonly TypesGen.ChatMessage[], + promotedHeadID: number | undefined, + queuedTail: TypesGen.ChatQueuedMessage | undefined, + hasObservedQueuedMessageID: (id: number) => boolean, +): readonly TypesGen.ChatQueuedMessage[] | undefined => { + if (promotedHeadID === undefined) { + return undefined; + } + if (!insertedMessages.some((message) => message.role === "user")) { + return undefined; + } + const remaining = queuedMessages.filter( + (message) => message.id !== promotedHeadID, + ); + const tailPending = + queuedTail !== undefined && + !remaining.some((message) => message.id === queuedTail.id) && + !hasObservedQueuedMessageID(queuedTail.id); + return tailPending ? [...remaining, queuedTail] : remaining; +}; + // Use the pre-send queue head because queue updates may rotate it before // the response arrives. export const reconcilePromotedQueueHead = ( @@ -248,23 +271,16 @@ export const reconcilePromotedQueueHead = ( promotedHeadID: number | undefined, queuedTail: TypesGen.ChatQueuedMessage | undefined, ): readonly TypesGen.ChatQueuedMessage[] | undefined => { - if (promotedHeadID === undefined) { - return undefined; - } - if (!insertedMessages.some((message) => message.role === "user")) { - return undefined; + const next = buildPromotedQueueReconciliation( + store.getSnapshot().queuedMessages, + insertedMessages, + promotedHeadID, + queuedTail, + store.hasObservedQueuedMessageID, + ); + if (!next || promotedHeadID === undefined) { + return next; } - const remaining = store - .getSnapshot() - .queuedMessages.filter((message) => message.id !== promotedHeadID); - // Append the tail only while the server has never reported it. Once a - // snapshot has listed it, its later absence means it was deleted, so - // re-adding it would resurrect a phantom row. - const tailPending = - queuedTail !== undefined && - !remaining.some((message) => message.id === queuedTail.id) && - !store.hasObservedQueuedMessageID(queuedTail.id); - const next = tailPending ? [...remaining, queuedTail] : remaining; store.batch(() => { // The promoted user row proves the server deleted its queue row. store.markQueuedMessagePromoted(promotedHeadID); @@ -1685,7 +1701,8 @@ const AgentChatPage: FC = () => { scrollToBottomRef.current?.(); // Capture the queue head before sending because an errored chat may promote it. - const queueHeadIDBeforeSend = store.getSnapshot().queuedMessages[0]?.id; + const queuedMessagesBeforeSend = store.getSnapshot().queuedMessages; + const queueHeadIDBeforeSend = queuedMessagesBeforeSend[0]?.id; const statusVersionBeforeSend = store.getServerChatStatusVersion(); // Don't clear stream state before the POST completes. @@ -1705,10 +1722,11 @@ const AgentChatPage: FC = () => { }); throw error; } + const isActiveChat = store.getActiveChatID() === agentId; // When the server accepts the message immediately (not // queued), clear the stream so the timeline updates without // waiting for the WebSocket stream. - if (!response.queued) { + if (!response.queued && isActiveChat) { store.clearStreamState(); // Optimistically set status to "running" so the // Thinking indicator appears immediately. @@ -1727,22 +1745,35 @@ const AgentChatPage: FC = () => { const insertedMessages = response.messages ?? (response.message ? [response.message] : []); if (insertedMessages.length > 0) { - store.upsertDurableMessages(insertedMessages); upsertCacheMessages(insertedMessages); + if (isActiveChat) { + store.upsertDurableMessages(insertedMessages); + } if (response.queued) { - const reconciledQueue = reconcilePromotedQueueHead( - store, - insertedMessages, - queueHeadIDBeforeSend, - response.queued_message, - ); + const reconciledQueue = isActiveChat + ? reconcilePromotedQueueHead( + store, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + ) + : buildPromotedQueueReconciliation( + queuedMessagesBeforeSend, + insertedMessages, + queueHeadIDBeforeSend, + response.queued_message, + () => false, + ); if (reconciledQueue) { setCacheQueuedMessages(reconciledQueue); // A promoted head means a turn just started, so clear the // stale error status before the status websocket event // arrives. A status event during the request is already // newer than this optimistic value. - if (store.getServerChatStatusVersion() === statusVersionBeforeSend) { + if ( + isActiveChat && + store.getServerChatStatusVersion() === statusVersionBeforeSend + ) { store.clearStreamState(); store.setChatStatus("running"); } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 70c04f209a9..abc8c732450 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -34,10 +34,11 @@ import type { FC, PropsWithChildren } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { MockChat } from "#/testHelpers/chatEntities"; +import { MockChat, MockChatMessage } from "#/testHelpers/chatEntities"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket"; import { + createChatStore, selectChatStatus, selectIsAwaitingFirstStreamChunk, selectMessagesByID, @@ -264,6 +265,38 @@ afterEach(() => { vi.mocked(watchChat).mockReset(); }); +describe("createChatStore", () => { + it("guards send response mutations by active chat", () => { + const store = createChatStore(); + const sendChatID = "chat-old"; + const activeMessage = { + ...MockChatMessage, + id: 1, + chat_id: "chat-new", + content: [{ type: "text" as const, text: "New chat message" }], + }; + const staleResponseMessage = { + ...MockChatMessage, + id: 2, + chat_id: sendChatID, + content: [{ type: "text" as const, text: "Old chat message" }], + }; + + store.setActiveChatID(sendChatID); + store.setActiveChatID("chat-new"); + store.replaceMessages([activeMessage]); + store.setChatStatus("waiting"); + + if (store.getActiveChatID() === sendChatID) { + store.upsertDurableMessages([staleResponseMessage]); + store.setChatStatus("running"); + } + + expect(store.getSnapshot().orderedMessageIDs).toEqual([activeMessage.id]); + expect(store.getSnapshot().chatStatus).toBe("waiting"); + }); +}); + describe("useChatStore", () => { it("does not clear in-progress stream parts for duplicate snapshot messages", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts index 4357325edac..0fa2e6273cc 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.ts @@ -195,6 +195,8 @@ export type ChatStore = { // server never mentioned is still in flight; one it mentioned and then // dropped was deleted. hasObservedQueuedMessageID: (id: number) => boolean; + setActiveChatID: (chatID: string | null) => void; + getActiveChatID: () => string | null; // Counts server-reported status events, including repeats of the // current value, so a caller can tell that the server spoke during a // request even when the status did not change. @@ -240,6 +242,7 @@ export const createChatStore = (): ChatStore => { // server event cannot trigger a re-render. let observedQueuedMessageIDs = new Set(); let serverChatStatusVersion = 0; + let activeChatID: string | null = null; const listeners = new Set<() => void>(); const emit = (): void => { @@ -571,6 +574,10 @@ export const createChatStore = (): ChatStore => { chatStatus: status, })); }, + setActiveChatID: (chatID) => { + activeChatID = chatID; + }, + getActiveChatID: () => activeChatID, getServerChatStatusVersion: () => serverChatStatusVersion, applyServerChatStatus: (status) => { serverChatStatusVersion++; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 6cc87840a6f..3c57a6fbb2a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -318,6 +318,7 @@ export const useChatStore = ( }, [chatRecord?.status, store, pendingStatusResync]); useEffect(() => { + store.setActiveChatID(chatID ?? null); queuedMessagesHydratedChatIDRef.current = null; wsQueueUpdateReceivedRef.current = false; wsStatusReceivedRef.current = false; @@ -388,6 +389,7 @@ export const useChatStore = ( store.resetTransientState(); activeChatIDRef.current = chatID ?? null; + store.setActiveChatID(chatID ?? null); if (!chatID || !initialDataLoaded || aiGatewayDisabled) { return; @@ -745,6 +747,7 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); } activeChatIDRef.current = null; + store.setActiveChatID(null); }; }, [ aiGatewayDisabled, From 2d7e4a57a5ef8f46667d0ffa02f501e44cb4fdbd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:48:02 +0000 Subject: [PATCH 81/86] fix(site/src/pages/AgentsPage): resync status only after fresh chat data acceptServerChatStatus armed a resync that applied the currently cached chatRecord.status immediately, so a failed send or edit could replace a live websocket "running" with a stale REST "waiting" and make shouldApplyMessagePart drop assistant parts. The resync now waits for the chat query's dataUpdatedAt to advance past the value captured when it was armed. Object identity does not work here because TanStack Query structural sharing preserves the chatRecord reference when a refetch returns value-equal data, which would leave the resync armed forever and never apply an unchanged status. Also covers the cross-chat send guard with an interaction story and drops a store test that restated the guard instead of exercising it. --- .../AgentsPage/AgentChatPage.stories.tsx | 124 +++++++++++++++++- site/src/pages/AgentsPage/AgentChatPage.tsx | 3 +- .../ChatConversation/chatStore.test.tsx | 67 +++------- .../ChatConversation/useChatStore.ts | 31 +++-- 4 files changed, 167 insertions(+), 58 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 38c7e412173..21f27250c46 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { useRef } from "react"; -import { Outlet } from "react-router"; +import { Outlet, useNavigate } from "react-router"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterOutlet, @@ -83,8 +83,25 @@ const AgentChatPageLayout: FC = () => { // Shared mock data // --------------------------------------------------------------------------- const CHAT_ID = "chat-1"; +const SWITCHED_CHAT_ID = "chat-2"; const MODEL_CONFIG_ID = "model-config-1"; +const AgentChatSwitchHarness: FC = () => { + const navigate = useNavigate(); + return ( + <> + + + + ); +}; + const mockWorkspace: TypesGen.Workspace = { ...MockWorkspace, id: "workspace-1", @@ -2926,6 +2943,111 @@ export const QueuedSendPromotesPreviousHead: Story = { }, }; +const switchedChat: TypesGen.Chat = { + id: SWITCHED_CHAT_ID, + ...baseChatFields, + title: "Switched chat", + status: "waiting", +}; + +const switchedChatMessage: TypesGen.ChatMessage = { + ...MockChatMessage, + id: 50, + chat_id: SWITCHED_CHAT_ID, + role: "assistant", + content: [{ type: "text", text: "Current chat message" }], +}; + +export const SendResponseAfterChatSwitch: Story = { + render: () => , + parameters: { + queries: [ + ...buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Original chat", + status: "waiting", + }, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + { key: chatKey(SWITCHED_CHAT_ID), data: switchedChat }, + { + key: chatMessagesKey(SWITCHED_CHAT_ID), + data: { + pages: [ + { + messages: [switchedChatMessage], + queued_messages: [], + has_more: false, + }, + ], + pageParams: [undefined], + }, + }, + { + key: chatPromptsKey(SWITCHED_CHAT_ID), + data: { prompts: [] } satisfies TypesGen.ChatPromptsResponse, + }, + { + key: chatDiffContentsKey(SWITCHED_CHAT_ID), + data: { chat_id: SWITCHED_CHAT_ID } satisfies TypesGen.ChatDiffContents, + }, + ], + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let releaseSend: (() => void) | undefined; + const sendGate = new Promise((resolve) => { + releaseSend = resolve; + }); + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockImplementation(async () => { + await sendGate; + return { + queued: false, + message: { + ...MockChatMessage, + id: 51, + chat_id: CHAT_ID, + role: "user", + content: [ + { type: "text", text: "Stale response from previous chat" }, + ], + }, + }; + }); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.type(editor, "Send before switching"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + await userEvent.click(canvas.getByRole("button", { name: "Switch chat" })); + const timeline = within(await canvas.findByTestId("conversation-timeline")); + expect(await timeline.findByText("Current chat message")).toBeVisible(); + + releaseSend?.(); + await waitFor(() => { + expect( + timeline.queryByText("Stale response from previous chat"), + ).not.toBeInTheDocument(); + expect( + canvas.queryByTestId("live-activity-slot"), + ).not.toBeInTheDocument(); + }); + }, +}; + /** A send rejected with the structured 502 hook-dispatch-failure body must * render the lifecycle-hook title and the server's detail text, not the * generic request-failure fallback. */ diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index be91ed3a8ee..75f18edf650 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1169,6 +1169,7 @@ const AgentChatPage: FC = () => { chatID: agentId, chatMessages: chatMessagesList, chatRecord, + chatRecordUpdatedAt: chatQuery.dataUpdatedAt, chatMessagesData, chatQueuedMessages, setChatErrorReason, @@ -1741,7 +1742,7 @@ const AgentChatPage: FC = () => { // Prefer the full inserted batch: queued sends can insert // messages beyond the user row, such as a promoted queue head // on an errored chat, and a stream reconnect keyed on the - // highest cached ID would skip them, so upsert unconditionally. + // highest cached ID would skip them, so upsert while this chat is active. const insertedMessages = response.messages ?? (response.message ? [response.message] : []); if (insertedMessages.length > 0) { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index abc8c732450..b560cb9116f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -34,11 +34,10 @@ import type { FC, PropsWithChildren } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { MockChat, MockChatMessage } from "#/testHelpers/chatEntities"; +import { MockChat } from "#/testHelpers/chatEntities"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket"; import { - createChatStore, selectChatStatus, selectIsAwaitingFirstStreamChunk, selectMessagesByID, @@ -265,38 +264,6 @@ afterEach(() => { vi.mocked(watchChat).mockReset(); }); -describe("createChatStore", () => { - it("guards send response mutations by active chat", () => { - const store = createChatStore(); - const sendChatID = "chat-old"; - const activeMessage = { - ...MockChatMessage, - id: 1, - chat_id: "chat-new", - content: [{ type: "text" as const, text: "New chat message" }], - }; - const staleResponseMessage = { - ...MockChatMessage, - id: 2, - chat_id: sendChatID, - content: [{ type: "text" as const, text: "Old chat message" }], - }; - - store.setActiveChatID(sendChatID); - store.setActiveChatID("chat-new"); - store.replaceMessages([activeMessage]); - store.setChatStatus("waiting"); - - if (store.getActiveChatID() === sendChatID) { - store.upsertDurableMessages([staleResponseMessage]); - store.setChatStatus("running"); - } - - expect(store.getSnapshot().orderedMessageIDs).toEqual([activeMessage.id]); - expect(store.getSnapshot().chatStatus).toBe("waiting"); - }); -}); - describe("useChatStore", () => { it("does not clear in-progress stream parts for duplicate snapshot messages", async () => { immediateAnimationFrame(); @@ -2330,13 +2297,17 @@ describe("useChatStore", () => { const queryClient = createTestQueryClient(); const wrapper = createWrapper(queryClient); - const initialProps: { status: TypesGen.ChatStatus } = { status: "waiting" }; + const initialProps: { status: TypesGen.ChatStatus; updatedAt: number } = { + status: "waiting", + updatedAt: 1, + }; const { result, rerender } = renderHook( - ({ status }: { status: TypesGen.ChatStatus }) => { + ({ status, updatedAt }: typeof initialProps) => { const { store, acceptServerChatStatus } = useChatStore({ chatID, chatMessages: [], chatRecord: { ...buildChat(chatID), status }, + chatRecordUpdatedAt: updatedAt, chatMessagesData: { messages: [], queued_messages: [], @@ -2369,35 +2340,35 @@ describe("useChatStore", () => { await waitFor(() => { expect(result.current.chatStatus).toBe("running"); }); - rerender({ status: "error" }); + rerender({ status: "error", updatedAt: 1 }); expect(result.current.chatStatus).toBe("running"); - // A failed request opts back in, so the next refetch applies. act(() => { result.current.acceptServerChatStatus(); }); - rerender({ status: "waiting" }); - rerender({ status: "error" }); + rerender({ status: "waiting", updatedAt: 1 }); + expect(result.current.chatStatus).toBe("running"); + rerender({ status: "error", updatedAt: 2 }); await waitFor(() => { expect(result.current.chatStatus).toBe("error"); }); }); - it("hydrates a refetched status that never changed value", async () => { + it("hydrates an unchanged status after a successful refetch", async () => { const chatID = "chat-resync-same"; const mockSocket = createMockSocket(); mockWatchChatReturn(mockSocket); const queryClient = createTestQueryClient(); const wrapper = createWrapper(queryClient); + const chatRecord = { ...buildChat(chatID), status: "error" as const }; - // The cache already holds "error" while the socket pushes "running", - // so opting back in must apply the cached value without it changing. - const { result } = renderHook( - () => { + const { result, rerender } = renderHook( + ({ updatedAt }: { updatedAt: number }) => { const { store, acceptServerChatStatus } = useChatStore({ chatID, chatMessages: [], - chatRecord: { ...buildChat(chatID), status: "error" }, + chatRecord, + chatRecordUpdatedAt: updatedAt, chatMessagesData: { messages: [], queued_messages: [], @@ -2412,7 +2383,7 @@ describe("useChatStore", () => { chatStatus: useChatSelector(store, selectChatStatus), }; }, - { wrapper }, + { wrapper, initialProps: { updatedAt: 1 } }, ); await waitFor(() => { @@ -2432,6 +2403,8 @@ describe("useChatStore", () => { act(() => { result.current.acceptServerChatStatus(); }); + expect(result.current.chatStatus).toBe("running"); + rerender({ updatedAt: 2 }); await waitFor(() => { expect(result.current.chatStatus).toBe("error"); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 3c57a6fbb2a..9c999a00a43 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -84,6 +84,7 @@ interface UseChatStoreOptions { chatID: string | undefined; chatMessages: readonly TypesGen.ChatMessage[] | undefined; chatRecord: TypesGen.Chat | undefined; + chatRecordUpdatedAt?: number; chatMessagesData: TypesGen.ChatMessagesResponse | undefined; chatQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined; setChatErrorReason: (chatID: string, reason: ChatDetailError) => void; @@ -106,6 +107,7 @@ export const useChatStore = ( chatID, chatMessages, chatRecord, + chatRecordUpdatedAt = 0, chatMessagesData, chatQueuedMessages, setChatErrorReason, @@ -132,6 +134,7 @@ export const useChatStore = ( // to drop all incoming parts. const wsStatusReceivedRef = useRef(false); const [pendingStatusResync, setPendingStatusResync] = useState(false); + const pendingStatusResyncUpdatedAtRef = useRef(null); const activeChatIDRef = useRef(null); const prevChatIDRef = useRef(chatID); // Snapshot of the chatMessages elements from the last sync effect @@ -303,25 +306,34 @@ export const useChatStore = ( }, [chatID, chatMessages, store]); useEffect(() => { + if (pendingStatusResync) { + const armedAt = pendingStatusResyncUpdatedAtRef.current; + // dataUpdatedAt advances after a fetch even when structural sharing + // preserves chatRecord. + if (armedAt === null || chatRecordUpdatedAt <= armedAt) { + return; + } + store.setChatStatus(chatRecord?.status ?? null); + pendingStatusResyncUpdatedAtRef.current = null; + wsStatusReceivedRef.current = false; + setPendingStatusResync(false); + return; + } // Only hydrate from REST when the WebSocket hasn't delivered // a status event yet. Once the WS is the authoritative // source, a stale REST refetch must not overwrite the // fresher WS-delivered value. - if (!wsStatusReceivedRef.current || pendingStatusResync) { + if (!wsStatusReceivedRef.current) { store.setChatStatus(chatRecord?.status ?? null); } - // A resync must apply the cached status even when its value never - // changed, which happens when the store drifted ahead of it. - if (pendingStatusResync) { - setPendingStatusResync(false); - } - }, [chatRecord?.status, store, pendingStatusResync]); + }, [chatRecord?.status, chatRecordUpdatedAt, store, pendingStatusResync]); useEffect(() => { - store.setActiveChatID(chatID ?? null); queuedMessagesHydratedChatIDRef.current = null; wsQueueUpdateReceivedRef.current = false; wsStatusReceivedRef.current = false; + pendingStatusResyncUpdatedAtRef.current = null; + setPendingStatusResync(false); store.setQueuedMessages([]); // Suppression entries are scoped to the current chat; clear // them on chat change so a stale promote suppression doesn't @@ -639,6 +651,7 @@ export const useChatStore = ( kind: "generic", message: "Chat processing failed.", }; + wsStatusReceivedRef.current = true; store.applyServerChatStatus("error"); store.setStreamError(reason); store.clearRetryState(); @@ -767,7 +780,7 @@ export const useChatStore = ( // socket is down, and the socket having already delivered a status // otherwise makes the refetched one inert. acceptServerChatStatus: () => { - wsStatusReceivedRef.current = false; + pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt; setPendingStatusResync(true); }, setCacheQueuedMessages: (queuedMessages) => { From 790e71dde71caa87c8ea48e0fa4bc3e534d7a5aa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:37:09 +0000 Subject: [PATCH 82/86] fix(site/src/pages/AgentsPage): keep websocket status through a resync A status or error event arriving after acceptServerChatStatus armed the resync but before the invalidated chat query resolved was overwritten by the older REST status, which could strand the turn and make shouldApplyMessagePart drop the retry's assistant deltas. The resync now captures the server status version when armed and skips the overwrite when the websocket advanced it in the meantime. --- .../ChatConversation/chatStore.test.tsx | 56 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 16 +++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index b560cb9116f..cb3de452f7f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2410,6 +2410,62 @@ describe("useChatStore", () => { }); }); + it("keeps a websocket status delivered while the resync refetch is in flight", async () => { + const chatID = "chat-resync-ws-race"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + const chatRecord = { ...buildChat(chatID), status: "error" as const }; + + const { result, rerender } = renderHook( + ({ updatedAt }: { updatedAt: number }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord, + chatRecordUpdatedAt: updatedAt, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: () => {}, + clearChatErrorReason: () => {}, + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper, initialProps: { updatedAt: 1 } }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, undefined); + }); + + act(() => { + result.current.acceptServerChatStatus(); + }); + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + + rerender({ updatedAt: 2 }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + }); + it("sets chatStatus to error and populates streamError on error event", async () => { immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 9c999a00a43..bf3da420978 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -135,6 +135,7 @@ export const useChatStore = ( const wsStatusReceivedRef = useRef(false); const [pendingStatusResync, setPendingStatusResync] = useState(false); const pendingStatusResyncUpdatedAtRef = useRef(null); + const pendingStatusResyncVersionRef = useRef(null); const activeChatIDRef = useRef(null); const prevChatIDRef = useRef(chatID); // Snapshot of the chatMessages elements from the last sync effect @@ -313,9 +314,17 @@ export const useChatStore = ( if (armedAt === null || chatRecordUpdatedAt <= armedAt) { return; } - store.setChatStatus(chatRecord?.status ?? null); + // A websocket status delivered while the refetch was in flight is + // newer than its response, so the resync must not undo it. + const wsAdvanced = + store.getServerChatStatusVersion() !== + pendingStatusResyncVersionRef.current; + if (!wsAdvanced) { + store.setChatStatus(chatRecord?.status ?? null); + wsStatusReceivedRef.current = false; + } pendingStatusResyncUpdatedAtRef.current = null; - wsStatusReceivedRef.current = false; + pendingStatusResyncVersionRef.current = null; setPendingStatusResync(false); return; } @@ -333,6 +342,7 @@ export const useChatStore = ( wsQueueUpdateReceivedRef.current = false; wsStatusReceivedRef.current = false; pendingStatusResyncUpdatedAtRef.current = null; + pendingStatusResyncVersionRef.current = null; setPendingStatusResync(false); store.setQueuedMessages([]); // Suppression entries are scoped to the current chat; clear @@ -781,6 +791,8 @@ export const useChatStore = ( // otherwise makes the refetched one inert. acceptServerChatStatus: () => { pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt; + pendingStatusResyncVersionRef.current = + store.getServerChatStatusVersion(); setPendingStatusResync(true); }, setCacheQueuedMessages: (queuedMessages) => { From 00e0f1ac4ffd7094098b6c9e8cc15e5aafdb2577 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:58:19 +0000 Subject: [PATCH 83/86] fix(site/src/pages/AgentsPage): scope the status resync to its own chat The send and edit failure paths armed a resync from the render that started the request, so a rejection arriving after the user navigated away captured the previous chat's dataUpdatedAt. The newly active chat's higher cached timestamp then satisfied the freshness check at once, overwriting a websocket-delivered status and clearing the websocket-authoritative guard. acceptServerChatStatus now ignores calls whose chat is no longer the active one, which covers both failure paths at their single shared entry point. --- .../ChatConversation/chatStore.test.tsx | 64 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 6 ++ 2 files changed, 70 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index cb3de452f7f..0e9a9ac174f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -2410,6 +2410,70 @@ describe("useChatStore", () => { }); }); + it("ignores a resync armed by a request from a chat the user left", async () => { + const leftChatID = "chat-resync-left"; + const activeChatID = "chat-resync-active"; + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + + const { result, rerender } = renderHook( + ({ chatID, updatedAt }: { chatID: string; updatedAt: number }) => { + const { store, acceptServerChatStatus } = useChatStore({ + chatID, + chatMessages: [], + chatRecord: { ...buildChat(chatID), status: "waiting" }, + chatRecordUpdatedAt: updatedAt, + chatMessagesData: { + messages: [], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason: () => {}, + clearChatErrorReason: () => {}, + }); + return { + acceptServerChatStatus, + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { + wrapper, + initialProps: { chatID: leftChatID, updatedAt: 1 }, + }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(leftChatID, undefined); + }); + // The in-flight request holds the callback from the render it started in. + const staleAcceptServerChatStatus = result.current.acceptServerChatStatus; + + rerender({ chatID: activeChatID, updatedAt: 5 }); + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(activeChatID, undefined); + }); + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: activeChatID, + status: { status: "running" }, + }); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + + act(() => { + staleAcceptServerChatStatus(); + }); + await waitFor(() => { + expect(result.current.chatStatus).toBe("running"); + }); + }); + it("keeps a websocket status delivered while the resync refetch is in flight", async () => { const chatID = "chat-resync-ws-race"; const mockSocket = createMockSocket(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index bf3da420978..d5f045155c4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -790,6 +790,12 @@ export const useChatStore = ( // socket is down, and the socket having already delivered a status // otherwise makes the refetched one inert. acceptServerChatStatus: () => { + // A request that resolves after the user navigates away belongs to + // the previous chat, whose freshness and status are unrelated to + // the one now displayed by this shared store. + if (store.getActiveChatID() !== (chatID ?? null)) { + return; + } pendingStatusResyncUpdatedAtRef.current = chatRecordUpdatedAt; pendingStatusResyncVersionRef.current = store.getServerChatStatusVersion(); From 29e20d75e4bd8bb68655503e8c10de4ddb2a479f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:24:41 +0000 Subject: [PATCH 84/86] fix: require a configured audience for agent hook consumers The SDK handler derived the expected audience from the incoming request, so a caller controlling the request URI, the Host header, or forwarding headers could satisfy the audience check with a token minted for a different listener. NewHTTPHandler now takes the audience Coder is configured to dispatch to and compares the aud claim against it, and a handler built without an audience rejects every request. WithTrustForwardedHeaders and the request-derived audience path are removed with it. --- coderd/exp_chats_hooks_test.go | 29 ++++++-- codersdk/x/agenthooks/agenthooks_test.go | 95 +++++++++++------------- codersdk/x/agenthooks/http.go | 75 +++++-------------- scripts/agenthooks-server/main.go | 19 ++--- 4 files changed, 92 insertions(+), 126 deletions(-) diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index a0e0cf3d5f0..1fd479a9dbe 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -139,14 +139,14 @@ func TestChatPromptHookContextHiddenFromAPI(t *testing.T) { t.Parallel() const secret = "test-hook-secret-32-bytes-minimum!!" - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ + 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) { @@ -217,7 +217,7 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { recordHook := func(event agenthooks.EventType) { hookEvents <- event } - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ + consumer := newHookConsumer(t, secret, agenthooks.Hooks{ SessionStart: func(context.Context, agenthooks.Meta, agenthooks.SessionStartData) (agenthooks.Response, error) { recordHook(agenthooks.EventSessionStart) return agenthooks.Response{}, nil @@ -254,7 +254,7 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { recordHook(agenthooks.EventStop) return agenthooks.Response{}, nil }, - })) + }) t.Cleanup(consumer.Close) client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { @@ -370,7 +370,7 @@ func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { } return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) }) - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ + 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{ @@ -380,7 +380,7 @@ func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { } return agenthooks.Response{}, nil }, - })) + }) t.Cleanup(consumer.Close) client, api := newChatClientWithAPI(t, func(opts *coderdtest.Options) { @@ -457,7 +457,7 @@ func TestChatHookNoticeMessagesInResponses(t *testing.T) { return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...) }) - consumer := httptest.NewServer(agenthooks.NewHTTPHandler([]byte(secret), agenthooks.Hooks{ + consumer := newHookConsumer(t, secret, agenthooks.Hooks{ SessionStart: func(context.Context, agenthooks.Meta, agenthooks.SessionStartData) (agenthooks.Response, error) { return agenthooks.Response{UserMessage: "session notice"}, nil }, @@ -468,7 +468,7 @@ func TestChatHookNoticeMessagesInResponses(t *testing.T) { } return response, nil }, - })) + }) t.Cleanup(consumer.Close) client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { @@ -572,3 +572,16 @@ func TestChatHookNoticeMessagesInResponses(t *testing.T) { } 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 +} diff --git a/codersdk/x/agenthooks/agenthooks_test.go b/codersdk/x/agenthooks/agenthooks_test.go index 9f4eaf19338..5db3155c0c5 100644 --- a/codersdk/x/agenthooks/agenthooks_test.go +++ b/codersdk/x/agenthooks/agenthooks_test.go @@ -22,6 +22,10 @@ import ( var testSecret = []byte("0123456789abcdef0123456789abcdef") +// testAudience is the URL the handler is configured to accept, kept +// independent of the test server address it is reached on. +const testAudience = "https://hooks.example.com" + func TestSignClaimsVerify(t *testing.T) { t.Parallel() @@ -213,7 +217,7 @@ func TestHTTPHandlerRoutesEvents(t *testing.T) { called := false var h agenthooks.Hooks test.install(t, &h, &called) - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, h)) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, testAudience, h)) t.Cleanup(server.Close) response := postEvent(t, server.URL, test.event, test.data, nil, nil) @@ -232,7 +236,7 @@ func TestHTTPHandlerUnencodableResponseFailsClosed(t *testing.T) { // An empty 200 reads as allow, so a response that cannot be marshaled // must not reach the dispatcher as one. - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{ + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, testAudience, agenthooks.Hooks{ Stop: func(context.Context, agenthooks.Meta, agenthooks.StopData) (agenthooks.Response, error) { return agenthooks.Response{ Permission: &agenthooks.Permission{ @@ -252,7 +256,7 @@ func TestHTTPHandlerUnencodableResponseFailsClosed(t *testing.T) { func TestHTTPHandlerNoOpHookDoesNotDecodeData(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, testAudience, agenthooks.Hooks{})) t.Cleanup(server.Close) response := postEvent(t, server.URL, agenthooks.EventStop, "unused", nil, nil) defer response.Body.Close() @@ -305,7 +309,7 @@ func TestHTTPHandlerRejectsMismatches(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, testAudience, agenthooks.Hooks{})) t.Cleanup(server.Close) response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, test.updateRequest, test.updateClaims) defer response.Body.Close() @@ -319,6 +323,7 @@ func TestHTTPHandlerExpectedIssuer(t *testing.T) { server := httptest.NewServer(agenthooks.NewHTTPHandler( testSecret, + testAudience, agenthooks.Hooks{}, agenthooks.WithExpectedIssuer("deployment-a"), )) @@ -340,61 +345,59 @@ func TestHTTPHandlerExpectedIssuer(t *testing.T) { func TestHTTPHandlerAcceptsTrailingSlashAudience(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, testAudience, agenthooks.Hooks{})) t.Cleanup(server.Close) response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { - claims.Audience = server.URL + "/" + claims.Audience = testAudience + "/" }) defer response.Body.Close() require.Equal(t, http.StatusOK, response.StatusCode) } -func TestHTTPHandlerHonorsForwardedProto(t *testing.T) { +func TestHTTPHandlerRejectsRequestDerivedAudience(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{}, agenthooks.WithTrustForwardedHeaders())) - t.Cleanup(server.Close) - httpsAudience := "https" + strings.TrimPrefix(server.URL, "http") - response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { - claims.Audience = httpsAudience - }, func(r *http.Request) { - r.Header.Set("X-Forwarded-Proto", "https, http") - }) - defer response.Body.Close() - require.Equal(t, http.StatusOK, response.StatusCode) + // Every request-controlled source of an audience names an attacker host, so + // a token minted for another listener sharing this secret would pass an + // audience check that reads any of them. + const spoofed = "https://hooks.attacker.example" + handler := agenthooks.NewHTTPHandler(testSecret, testAudience, agenthooks.Hooks{}) + body, token := signedEvent(t, spoofed, agenthooks.EventStop, agenthooks.StopData{}, nil, nil) + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, spoofed, bytes.NewReader(body)) + require.NoError(t, err) + request.Host = "hooks.attacker.example" + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("X-Forwarded-Proto", "https") + request.Header.Set("X-Forwarded-Host", "hooks.attacker.example") + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code) } -func TestHTTPHandlerHonorsForwardedHost(t *testing.T) { +func TestHTTPHandlerWithoutAudienceRejectsEveryRequest(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{}, agenthooks.WithTrustForwardedHeaders())) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, "", agenthooks.Hooks{})) t.Cleanup(server.Close) - response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { - claims.Audience = "https://hooks.example.com" - }, func(r *http.Request) { - r.Header.Set("X-Forwarded-Proto", "https") - r.Header.Set("X-Forwarded-Host", "hooks.example.com, internal-lb") - }) + response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, nil) defer response.Body.Close() - require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, http.StatusInternalServerError, response.StatusCode) } -func TestHTTPHandlerIgnoresForwardedHeadersByDefault(t *testing.T) { - t.Parallel() +func postEvent(t *testing.T, target string, eventType agenthooks.EventType, data any, updateRequest func(*agenthooks.Request), updateClaims func(*agenthooks.Claims)) *http.Response { + t.Helper() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) - t.Cleanup(server.Close) - response := postEvent(t, server.URL, agenthooks.EventStop, agenthooks.StopData{}, nil, func(claims *agenthooks.Claims) { - claims.Audience = "https://hooks.example.com" - }, func(r *http.Request) { - r.Header.Set("X-Forwarded-Proto", "https") - r.Header.Set("X-Forwarded-Host", "hooks.example.com") - }) - defer response.Body.Close() - require.Equal(t, http.StatusBadRequest, response.StatusCode) + body, token := signedEvent(t, testAudience, eventType, data, updateRequest, updateClaims) + httpRequest, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(body)) + require.NoError(t, err) + httpRequest.Header.Set("Authorization", "Bearer "+token) + response, err := http.DefaultClient.Do(httpRequest) + require.NoError(t, err) + return response } -func postEvent(t *testing.T, target string, eventType agenthooks.EventType, data any, updateRequest func(*agenthooks.Request), updateClaims func(*agenthooks.Claims), updateHTTPRequest ...func(*http.Request)) *http.Response { +func signedEvent(t *testing.T, audience string, eventType agenthooks.EventType, data any, updateRequest func(*agenthooks.Request), updateClaims func(*agenthooks.Claims)) ([]byte, string) { t.Helper() dataJSON, err := json.Marshal(data) @@ -411,7 +414,7 @@ func postEvent(t *testing.T, target string, eventType agenthooks.EventType, data }, Data: dataJSON, } - claims := validClaims(t, target, eventType, &request) + claims := validClaims(t, audience, eventType, &request) if updateRequest != nil { updateRequest(&request) } @@ -424,15 +427,7 @@ func postEvent(t *testing.T, target string, eventType agenthooks.EventType, data } token, err := agenthooks.SignClaims(testSecret, claims) require.NoError(t, err) - httpRequest, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(body)) - require.NoError(t, err) - httpRequest.Header.Set("Authorization", "Bearer "+token) - for _, update := range updateHTTPRequest { - update(httpRequest) - } - response, err := http.DefaultClient.Do(httpRequest) - require.NoError(t, err) - return response + return body, token } func validClaims(t *testing.T, audience string, eventType agenthooks.EventType, request *agenthooks.Request) agenthooks.Claims { @@ -460,7 +455,7 @@ func validClaims(t *testing.T, audience string, eventType agenthooks.EventType, func TestHTTPHandlerRejectsOversizedBody(t *testing.T) { t.Parallel() - server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, agenthooks.Hooks{})) + server := httptest.NewServer(agenthooks.NewHTTPHandler(testSecret, testAudience, agenthooks.Hooks{})) t.Cleanup(server.Close) // A correctly signed body over the limit must be rejected by size diff --git a/codersdk/x/agenthooks/http.go b/codersdk/x/agenthooks/http.go index cace5a531cb..12322a395b7 100644 --- a/codersdk/x/agenthooks/http.go +++ b/codersdk/x/agenthooks/http.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "net/url" - "strings" "golang.org/x/xerrors" ) @@ -32,8 +31,7 @@ type Hooks struct { type HandlerOption func(*handlerOptions) type handlerOptions struct { - expectedIssuer string - trustForwardedHeaders bool + expectedIssuer string } // WithExpectedIssuer requires the verified iss claim to match issuer. @@ -45,24 +43,24 @@ func WithExpectedIssuer(issuer string) HandlerOption { } } -// WithTrustForwardedHeaders reconstructs the audience from -// X-Forwarded-Proto and X-Forwarded-Host. Enable it only behind a -// trusted proxy that strips client-supplied forwarding headers; -// otherwise a caller could spoof them to satisfy the audience check -// for a token signed for a different listener. -func WithTrustForwardedHeaders() HandlerOption { - return func(options *handlerOptions) { - options.trustForwardedHeaders = true - } -} - // NewHTTPHandler verifies hook POSTs, binds their claims to each request, -// and routes events to their configured callbacks. -func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Handler { +// and routes events to their configured callbacks. expectedAudience must be +// the URL Coder dispatches to, which is the value it signs into the aud +// claim. Deriving it from the request instead would let a caller replay a +// token minted for a different listener, because the request URL, the Host +// header, and any forwarding headers are all caller-controlled. A handler +// built with an empty audience rejects every request. +func NewHTTPHandler(secret []byte, expectedAudience string, hooks Hooks, opts ...HandlerOption) http.Handler { var options handlerOptions for _, opt := range opts { opt(&options) } + if expectedAudience == "" { + return http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + http.Error(rw, "hook audience is not configured", http.StatusInternalServerError) + }) + } + audience := canonicalAudience(expectedAudience) return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { rw.Header().Set("Allow", http.MethodPost) @@ -95,7 +93,7 @@ func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Hand http.Error(rw, "decode request body", http.StatusBadRequest) return } - if err := options.verifyBody(r, body, claims, request); err != nil { + if err := verifyBody(body, claims, request, audience); err != nil { http.Error(rw, err.Error(), http.StatusBadRequest) return } @@ -120,13 +118,13 @@ func NewHTTPHandler(secret []byte, hooks Hooks, opts ...HandlerOption) http.Hand }) } -func (options handlerOptions) verifyBody(r *http.Request, body []byte, claims Claims, request Request) error { +func verifyBody(body []byte, claims Claims, request Request, audience string) error { digest := sha256.Sum256(body) if claims.BodySHA256 != hex.EncodeToString(digest[:]) { return xerrors.New("request body does not match body_sha256 claim") } - if canonicalAudience(claims.Audience) != options.requestAudience(r) { - return xerrors.New("request URL does not match audience claim") + if canonicalAudience(claims.Audience) != audience { + return xerrors.New("audience claim does not match the configured audience") } if request.Meta.SchemaVersion != SchemaVersion { return xerrors.New("unsupported schema version") @@ -147,43 +145,6 @@ func (options handlerOptions) verifyBody(r *http.Request, body []byte, claims Cl return nil } -func (options handlerOptions) requestAudience(r *http.Request) string { - requestURL := *r.URL - if requestURL.Scheme == "" { - requestURL.Scheme = "http" - if r.TLS != nil { - requestURL.Scheme = "https" - } - if options.trustForwardedHeaders { - if proto := forwardedProto(r); proto != "" { - requestURL.Scheme = proto - } - } - } - if requestURL.Host == "" { - requestURL.Host = r.Host - if options.trustForwardedHeaders { - if host := forwardedHost(r); host != "" { - requestURL.Host = host - } - } - } - return canonicalAudience(requestURL.String()) -} - -func forwardedProto(r *http.Request) string { - proto := r.Header.Get("X-Forwarded-Proto") - // Trusted proxies append values, so the first is client-facing. - proto, _, _ = strings.Cut(proto, ",") - return strings.ToLower(strings.TrimSpace(proto)) -} - -func forwardedHost(r *http.Request) string { - host := r.Header.Get("X-Forwarded-Host") - host, _, _ = strings.Cut(host, ",") - return strings.TrimSpace(host) -} - func canonicalAudience(audience string) string { parsed, err := url.Parse(audience) if err != nil { diff --git a/scripts/agenthooks-server/main.go b/scripts/agenthooks-server/main.go index db61977144d..2175c9547e9 100644 --- a/scripts/agenthooks-server/main.go +++ b/scripts/agenthooks-server/main.go @@ -24,12 +24,12 @@ import ( type config struct { listen string + audience string secret string issuer string tlsCert string tlsKey string logOnly bool - trustForwarded bool denyToolPattern string redactPrompt string } @@ -122,6 +122,11 @@ func run() error { if cfg.secret == "" { return xerrors.New("secret is required through --secret or CODER_AGENTHOOKS_SECRET") } + // The bind address is not the audience: it may be a wildcard or an + // ephemeral port, and behind a proxy the signed audience is the proxy URL. + if cfg.audience == "" { + return xerrors.New("audience is required through --audience or CODER_AGENTHOOKS_AUDIENCE") + } if len(cfg.secret) < agenthooks.MinSecretLen { return xerrors.Errorf("secret must be at least %d bytes", agenthooks.MinSecretLen) } @@ -251,10 +256,7 @@ func run() error { if cfg.issuer != "" { handlerOpts = append(handlerOpts, agenthooks.WithExpectedIssuer(cfg.issuer)) } - if cfg.trustForwarded { - handlerOpts = append(handlerOpts, agenthooks.WithTrustForwardedHeaders()) - } - handler := agenthooks.NewHTTPHandler([]byte(cfg.secret), consumerHooks, handlerOpts...) + handler := agenthooks.NewHTTPHandler([]byte(cfg.secret), cfg.audience, consumerHooks, handlerOpts...) server := &http.Server{ Addr: cfg.listen, Handler: handler, @@ -284,20 +286,15 @@ func parseFlags() (config, error) { if err != nil { return config{}, err } - trustForwarded, err := envBool("CODER_AGENTHOOKS_TRUST_FORWARDED_HEADERS", false) - if err != nil { - return config{}, err - } var cfg config cfg.logOnly = logOnly - cfg.trustForwarded = trustForwarded flag.StringVar(&cfg.listen, "listen", envOrDefault("CODER_AGENTHOOKS_LISTEN", "127.0.0.1:8081"), "Listen address (CODER_AGENTHOOKS_LISTEN)") + flag.StringVar(&cfg.audience, "audience", os.Getenv("CODER_AGENTHOOKS_AUDIENCE"), "Expected aud claim, which is the deployment's CODER_CHAT_HOOK_URL, required (CODER_AGENTHOOKS_AUDIENCE)") flag.StringVar(&cfg.secret, "secret", os.Getenv("CODER_AGENTHOOKS_SECRET"), "Shared HS256 secret, required (CODER_AGENTHOOKS_SECRET)") flag.StringVar(&cfg.issuer, "issuer", os.Getenv("CODER_AGENTHOOKS_ISSUER"), "Expected iss claim, normally the Coder deployment ID (CODER_AGENTHOOKS_ISSUER)") flag.StringVar(&cfg.tlsCert, "tls-cert", os.Getenv("CODER_AGENTHOOKS_TLS_CERT"), "TLS certificate path (CODER_AGENTHOOKS_TLS_CERT)") flag.StringVar(&cfg.tlsKey, "tls-key", os.Getenv("CODER_AGENTHOOKS_TLS_KEY"), "TLS private key path (CODER_AGENTHOOKS_TLS_KEY)") flag.BoolVar(&cfg.logOnly, "log-only", cfg.logOnly, "Return an empty response for every event (CODER_AGENTHOOKS_LOG_ONLY)") - flag.BoolVar(&cfg.trustForwarded, "trust-forwarded-headers", cfg.trustForwarded, "Trust X-Forwarded-Proto/Host for the audience check; enable only behind a trusted proxy (CODER_AGENTHOOKS_TRUST_FORWARDED_HEADERS)") flag.StringVar(&cfg.denyToolPattern, "deny-tool-pattern", os.Getenv("CODER_AGENTHOOKS_DENY_TOOL_PATTERN"), "Example regexp for denied tool names (CODER_AGENTHOOKS_DENY_TOOL_PATTERN)") flag.StringVar(&cfg.redactPrompt, "redact-prompt-pattern", os.Getenv("CODER_AGENTHOOKS_REDACT_PROMPT_PATTERN"), "Example regexp to redact in prompts (CODER_AGENTHOOKS_REDACT_PROMPT_PATTERN)") flag.Parse() From 8b25c8b98808930accbca283eb11730babe71b4f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:26:05 +0000 Subject: [PATCH 85/86] fix(scripts/agenthooks-server): decide pre_tool_use duplicates under one lock The remembered-decision lookup, the policy decision, and the store each took the mutex separately, so concurrent duplicate deliveries of the same tool_use_id could both miss the cache, both decide, and overwrite each other's entry. decidePreToolUse now performs all three under one lock. --- scripts/agenthooks-server/main.go | 83 +++++++++++++++---------------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/scripts/agenthooks-server/main.go b/scripts/agenthooks-server/main.go index 2175c9547e9..7d3b725c1ab 100644 --- a/scripts/agenthooks-server/main.go +++ b/scripts/agenthooks-server/main.go @@ -72,37 +72,54 @@ func newConsumerState() *consumerState { } } -func (s *consumerState) rememberedDecision(chatID, toolUseID string) (agenthooks.Response, bool) { +// decidePreToolUse resolves one pre_tool_use delivery and reports whether the +// response was already remembered. The lookup, the policy decision, and the +// store share one lock, so concurrent duplicate deliveries of the same +// tool_use_id cannot each decide and then overwrite each other. +func (s *consumerState) decidePreToolUse(chatID, toolUseID, toolName string, logOnly bool, denyTool *regexp.Regexp) (agenthooks.Response, bool) { s.mu.Lock() defer s.mu.Unlock() - response, ok := s.preToolDecisions[chatID+"\x00"+toolUseID] - return response, ok -} + key := chatID + "\x00" + toolUseID + if response, ok := s.preToolDecisions[key]; ok { + return response, true + } + + var response agenthooks.Response + deniedTool := "" + switch { + case logOnly: + case s.isBlockedLocked(chatID, toolName): + response = agenthooks.Response{Permission: &agenthooks.Permission{ + Decision: agenthooks.PermissionDeny, + Reason: "use of this tool is blocked for this chat", + }} + case denyTool != nil && denyTool.MatchString(toolName): + deniedTool = toolName + response = agenthooks.Response{Permission: &agenthooks.Permission{ + Decision: agenthooks.PermissionDeny, + Reason: "use of this tool is denied by this deployment's policy", + }} + } -func (s *consumerState) rememberDecision(chatID, toolUseID string, response agenthooks.Response, deniedTool string) { - s.mu.Lock() - defer s.mu.Unlock() if len(s.preToolDecisions) >= maxRememberedDecisions { // Both maps grow per chat, so evict them together to keep a // long-running consumer bounded. s.preToolDecisions = make(map[string]agenthooks.Response) s.blockedTools = make(map[string]map[string]struct{}) } - s.preToolDecisions[chatID+"\x00"+toolUseID] = response - if deniedTool == "" { - return - } - blocked := s.blockedTools[chatID] - if blocked == nil { - blocked = make(map[string]struct{}) - s.blockedTools[chatID] = blocked + s.preToolDecisions[key] = response + if deniedTool != "" { + blocked := s.blockedTools[chatID] + if blocked == nil { + blocked = make(map[string]struct{}) + s.blockedTools[chatID] = blocked + } + blocked[deniedTool] = struct{}{} } - blocked[deniedTool] = struct{}{} + return response, false } -func (s *consumerState) isBlocked(chatID, toolName string) bool { - s.mu.Lock() - defer s.mu.Unlock() +func (s *consumerState) isBlockedLocked(chatID, toolName string) bool { _, ok := s.blockedTools[chatID][toolName] return ok } @@ -207,31 +224,9 @@ func run() error { entry.ToolUseID = data.ToolUseID entry.ToolName = data.ToolName entry.ToolInput = data.ToolInput - if response, ok := state.rememberedDecision(entry.ChatID, data.ToolUseID); ok { - entry.Duplicate = true - return response, logEvent(entry) - } - if err := logEvent(entry); err != nil { - return agenthooks.Response{}, err - } - var response agenthooks.Response - deniedTool := "" - switch { - case cfg.logOnly: - case state.isBlocked(entry.ChatID, data.ToolName): - response = agenthooks.Response{Permission: &agenthooks.Permission{ - Decision: agenthooks.PermissionDeny, - Reason: "use of this tool is blocked for this chat", - }} - case denyTool != nil && denyTool.MatchString(data.ToolName): - deniedTool = data.ToolName - response = agenthooks.Response{Permission: &agenthooks.Permission{ - Decision: agenthooks.PermissionDeny, - Reason: "use of this tool is denied by this deployment's policy", - }} - } - state.rememberDecision(entry.ChatID, data.ToolUseID, response, deniedTool) - return response, nil + response, duplicate := state.decidePreToolUse(entry.ChatID, data.ToolUseID, data.ToolName, cfg.logOnly, denyTool) + entry.Duplicate = duplicate + return response, logEvent(entry) }, PostToolUse: func(_ context.Context, meta agenthooks.Meta, data agenthooks.PostToolUseData) (agenthooks.Response, error) { entry := baseEvent(agenthooks.EventPostToolUse, meta) From c4820d9ba5fa3389395aad68f534b014dd7866e9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:27:11 +0000 Subject: [PATCH 86/86] docs(docs/admin/setup): document the configured hook audience Consumers now compare the aud claim against a configured audience instead of one derived from the request, so the forwarded-header guidance no longer applies and the reference consumer needs --audience. --- docs/admin/setup/chat-lifecycle-hooks.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 84fe9d3d7e9..d504f2f1661 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -44,8 +44,9 @@ Rotation is a hard cutover: Coder signs with exactly one secret, so dispatches f Rotate during a maintenance window, or temporarily set `CODER_CHAT_HOOK_ENABLED=false` for the cutover if blocked chats are worse than unreviewed ones for your deployment. Coder requires the configured URL to use HTTPS. A TLS terminator can forward the request to a consumer over plain HTTP on a trusted local network. -The proxy must set `X-Forwarded-Proto: https` and either preserve the original `Host` header or carry it in `X-Forwarded-Host` for the SDK handler's audience check. -The SDK handler ignores forwarded headers unless the consumer opts in with `agenthooks.WithTrustForwardedHeaders`, because the audience check is then only as strong as the proxy boundary: the proxy must strip or overwrite client-supplied forwarded headers, and the consumer must not be reachable except through the proxy. +Configure the consumer with the same `CODER_CHAT_HOOK_URL` value, because that URL is the audience Coder signs into every dispatch. +The consumer compares the `aud` claim against its configured audience and rejects a mismatch. +It derives nothing from the request URL, the `Host` header, or forwarding headers, so the number of proxy hops in front of it doesn't affect the check. ## Handle lifecycle events @@ -89,6 +90,7 @@ A consumer must apply all of the following checks before it uses the body: The Go consumer SDK in `codersdk/x/agenthooks` implements the wire types, JWT verification, body binding, audience checks, and event routing. Use `agenthooks.NewHTTPHandler` to build an `http.Handler` from callbacks for the events your consumer handles. +Pass the deployment's `CODER_CHAT_HOOK_URL` as the expected audience, because a handler built without one rejects every request. Pass `agenthooks.WithExpectedIssuer` with the deployment ID associated with the secret to enforce the `iss` check. Without it, `NewHTTPHandler` accepts any non-empty `iss` signed with the shared secret, so use a secret dedicated to one deployment or always set the expected issuer. @@ -187,6 +189,7 @@ Run the consumer from a Coder source checkout: CODER_AGENTHOOKS_SECRET='' \ go run ./scripts/agenthooks-server \ --listen 127.0.0.1:8081 \ + --audience 'https://hooks.example.com' \ --log-only=true ``` @@ -197,7 +200,7 @@ Agent hooks server listening on 127.0.0.1:8081 ``` The reference server accepts optional TLS certificate and key paths. -For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs, and pass `--trust-forwarded-headers` so the audience check uses the proxy's forwarded scheme and host. +For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs, and pass the proxy's URL as `--audience`. Run `go run ./scripts/agenthooks-server --help` for all flags and environment variable names. ## Audit dispatches