feat: wire chat lifecycle hooks into chatd - #27429
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 953ead293d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
9fefafe to
e0b7344
Compare
953ead2 to
bbc1484
Compare
|
@codex review |
e0b7344 to
a05cf3c
Compare
bbc1484 to
d0f7244
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d0f7244efd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
a05cf3c to
841eefb
Compare
d0f7244 to
5250a86
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5250a861a5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
5250a86 to
e62369a
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e62369a8af
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
841eefb to
e08f7af
Compare
e62369a to
d835b06
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d835b06b87
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
d835b06 to
a3855c0
Compare
|
@codex review |
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.
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.
…odel 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.
…package 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.
Mechanical follow-up to the SDK move on the backend branch; also documents the strict response body rules in the consumer contract.
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.
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.
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.
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.
…ails A failed user_prompt_submit dispatch during subagent spawn admission returned before the commit, discarding the whole step. A sibling tool in the same batch had already run and mutated the workspace, so its result was lost and the tool would be re-executed on retry. The step now commits and the turn fails afterwards through the existing PostCommitError path, which runs CommitStep and FinishError atomically. Spawn admission takes precedence over a concurrent post_tool_use failure because it is the causal root; the other error is logged. post_tool_use no longer fires for a refused spawn result, since that tool never ran.
…eneration The manual-compaction contract claimed the chat always returns to waiting with no assistant follow-up. The generation decision reads user-visible history, so a post_compact effect that commits a user-visible message leaves the history incomplete and the turn continues. A model-only effect reaches the model without resuming. No behavior change. TestManualCompactionPostCompactEffects pins both sides of the distinction, and the existing hooks-disabled manual compaction test is scoped to that case.
Model-authored tool arguments reach a pre_tool_use consumer as raw JSON
with key spelling preserved, but built-in tools decode the same bytes
with encoding/json, which matches struct fields case-insensitively and
keeps the last match. A policy reading "path" could therefore authorize
one value while the tool executed another, and a lone case variant such
as {"PATH":"/secret"} was invisible to the policy entirely.
Validate a built-in tool's raw input against its schema before the
dispatch and reject input that repeats a key or spells a schema property
with different capitalization. Rejected calls produce a synthetic error
result the model can retry, and unambiguous calls in the same batch still
run. Dynamic and MCP inputs are excluded because the client and the
workspace agent execute those calls rather than coderd. A consumer
authored input_override is rechecked after the dispatch and fails the
turn closed, because the model cannot correct it.
Two paths escaped a schema-only check. Execution resolves a deprecated
tool name to its canonical tool, so the gate resolves aliases before
looking the tool up. The edit_files decoder also accepts "search" and
"replace", which its schema does not advertise, so those aliases are now
matched exactly and their case variants are ignored.
Explain why a hook policy and a built-in tool can read the same JSON differently, and state that Coder rejects duplicate keys and case variants of schema properties before dispatching pre_tool_use. Note the tools this check does not cover, the edit_files aliases a policy must also inspect, and correct the reference server's startup output.
pre_tool_use ran after the assistant message was already committed, so an input_override had to rewrite stored content in place. Message content is immutable, and the rewrite was the only reason the mutation primitive existed. Dispatch pre_tool_use for the calls the provider produced before building the commit instead. The step is persisted once, carrying the input the tool runs with, and synthetic denials commit alongside it. Execution consumes the admitted call from history rather than dispatching a second decision, so each call is decided exactly once. Clients stream the model's proposed input while the dispatch is in flight and converge on the stored input when the message commits, which the chat store already does by clearing stream state on an assistant message.
A hook payload carries tool input as JSON, so a call whose input does not parse cannot be marshaled into the request. The dispatch failed as a protocol error and ended the turn, even though the tool decoder would have reported an ordinary, retryable input error. Reject such calls where ambiguous input is already rejected, producing a synthetic tool error the model can correct. Without hooks configured the tool decoder still reports the failure, so behavior there is unchanged.
…he batch The duplicate-ID check ran inside the preflight dispatch, which receives the batch after ambiguous and invalid calls are removed. When a model repeated a tool-use ID and one of the twins was filtered out, the collision went undetected: the surviving call was authorized under an ID the synthetic result also carried, so an override keyed by that ID applied to both call blocks and the decision could resolve the wrong one. Check the complete pending batch before partitioning, and export the check so the ordering is explicit at the call site.
A hook denial wrote a bare Response with no kind, so clients could not tell a policy decision apart from a generic failure and titled it 'Request failed'. Return a structured 403 carrying hook_denied, mirroring the dispatch-failure response that already carries its own kind. Also correct the stale-input window in the hooks docs: a batch dispatches sequentially before the assistant row commits, so the window scales with the number of tool calls rather than a single hook timeout.
…light Usage limit enforcement was removed from chatd in #27535, so SendMessage and EditMessage no longer return UsageLimitExceededError and those two cases could never reject a prompt. The invalid-model-config and queue-full cases still cover the invariant that a rejected prompt dispatches no hook.

Wires chat lifecycle hooks into chatd, gated by the
agent-lifecycle-hooksexperiment. Part of the lifecycle hooks stack (#27401, #27428, #27430). Seedocs/admin/setup/chat-lifecycle-hooks.mdfor the consumer-facing contract.Summary
When a hook URL is configured, chatd dispatches
session_start,user_prompt_submit,pre_tool_use,post_tool_use,pre_compact,post_compact, andstopevents to the consumer and applies its responses.Design
user_prompt_submitdispatches exactly once per submission (create, send, queue, edit, subagent spawn) and folds its effects into the stored prompt as typed message parts: original-or-overridden user parts, then model-onlyhook-context, then a user-visiblehook-notice. Hook context is stripped from every client-facing conversion; hook notices are excluded from model prompts. The server rejects hook parts in client-submitted content.pre_tool_useallow can override tool input; deny becomes a synthetic denied tool result, with any returned model context persisted as a model-only transcript row so it never reaches clients. The denial text identifies an external policy (the deployment's lifecycle hook) as the source and marks the decision as persistent, so the model explains the denial instead of retrying it or misreporting it as an infrastructure failure.pre_tool_useis dispatched for the calls the model produced, before the assistant message is stored. See "Staged tool admission" below.pre_tool_usedispatch; Coder never reuses an earlier decision on the consumer's behalf. Retries re-dispatch the same logical event.Structure
All hook dispatch flows through one seam: entry points build a
chathooks.Chat(chat identity) and achathooks.Message(event details) and callTrigger.Trigger, the only component that talks to the dispatcher. The integration lives in thecoderd/x/chatd/chathookssubpackage, split by responsibility:trigger.go: the trigger seam; builds the wire envelope per event, normalizes deny into a typed error, and holds the package's single enabled-check.effects.go: pure conversion of hook results into transcript rows and prompt parts.errors.go: failure classification (dispatch error messages, denial mapping, tool-result dispatch-failure scanning).tooluse.go: the tool-call gate (pre_tool_usepreflight,post_tool_usepayloads, applying admitted input to the step).Server-bound glue stays in
coderd/x/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.This PR adopts the
codersdk/x/agenthooksandcoderd/x/agenthooks/dispatchimport paths introduced at the tip of #27401; intermediate commits still reference the pre-move paths and are not individually buildable.Staged tool admission
pre_tool_useoriginally ran at tool execution time, which is after the assistant message carrying the tool call was already committed. Aninput_overridetherefore had to rewrite stored message content in place. @hugodutka pointed out that chatd treats message content as immutable, and that the rewrite was a shortcut rather than a requirement.It was also a correctness problem in its own right: the rewrite only updated the database, so the transcript could show one input while a different one had executed.
The hook now runs before the step is persisted:
The step is inserted once, carrying the input the tool runs with.
UpdateChatMessageContentByIDandTx.UpdateMessageContentare deleted from #27428, so message content stays immutable.Two consequences, both intentional:
The per-chat debug endpoint still records the provider's original tool input. Its purpose is to report provider behavior, and it requires an explicit per-chat debug flag; the invariant here covers the transcript.
Configuration
Adds
chat-hook-url,chat-hook-secret,chat-hook-timeout, andchat-hook-enableddeployment options with startup validation. The flags are hidden fromcoder server --helpwhile the feature is experimental; the setup guide documents them.Tool input validation
Built-in tool arguments reach a consumer as raw JSON with key spelling preserved, but the tools decode those bytes with Go, which matches struct fields case-insensitively and keeps the last match. A policy reading
pathcould therefore authorize one value while the tool executed another, and a lone case variant such as{"PATH":"/secret"}was invisible to a policy checking forpath.Coder now rejects a built-in tool call whose input repeats a key or spells a schema property with different capitalization, before the
pre_tool_usedispatch, so a consumer is never asked to authorize bytes whose meaning depends on the reader. Rejected calls produce an error result the model can retry; unambiguous calls in the same batch still run. A consumer-authoredinput_overrideis rechecked after the dispatch and fails the turn closed, because the model cannot correct it. Dynamic and MCP inputs are excluded because the client and the workspace agent execute those calls rather than coderd.Two paths needed more than a schema check. Execution resolves a deprecated tool name to its canonical tool, so validation resolves aliases first. The
edit_filesdecoder also readssearchandreplace, which its schema does not advertise, so those aliases are now matched exactly and their case variants ignored.A hook denial now returns a structured 403 carrying
kind: "hook_denied", mirroring the dispatch-failure response that already carries its own kind. Without it a client cannot tell a policy decision apart from a generic failure, and the chat UI titled a denial "Request failed". Adding a kind needs no migration:ChatErrorKindis persisted only inside the JSONBchats.last_errorcolumn, whose decoder accepts unknown kinds.The hook docs also correct the tool-input convergence window. A batch dispatches sequentially before the assistant row commits, so the original input stays visible for a span that scales with the number of tool calls in the step rather than a single hook timeout.