diff --git a/coderd/util/xnet/xnet.go b/coderd/util/xnet/xnet.go
index 2957b9dd393..d0595e6c257 100644
--- a/coderd/util/xnet/xnet.go
+++ b/coderd/util/xnet/xnet.go
@@ -7,6 +7,8 @@ import (
"io"
"net"
"syscall"
+
+ "golang.org/x/net/http2"
)
// IsTimeoutError reports whether err indicates the peer did not respond in
@@ -31,5 +33,30 @@ func IsConnectionError(err error) bool {
return true
}
var opErr *net.OpError
- return errors.As(err, &opErr)
+ if errors.As(err, &opErr) {
+ return true
+ }
+
+ // net/http bundles its own HTTP/2 implementation with unexported error
+ // types, and net/http/h2_error.go bridges only the struct form of a
+ // stream error to this package's type. A pointer target never matches,
+ // and GOAWAY errors have no such bridge.
+ var streamErr http2.StreamError
+ return errors.As(err, &streamErr) && isTransientHTTP2Error(streamErr.Code)
+}
+
+// isTransientHTTP2Error reports whether an HTTP/2 error code can result from a
+// transient peer or transport condition. Deterministic protocol failures stay
+// terminal so a malformed consumer response is not retried.
+func isTransientHTTP2Error(code http2.ErrCode) bool {
+ switch code {
+ case http2.ErrCodeNo,
+ http2.ErrCodeInternal,
+ http2.ErrCodeRefusedStream,
+ http2.ErrCodeCancel,
+ http2.ErrCodeEnhanceYourCalm:
+ return true
+ default:
+ return false
+ }
}
diff --git a/coderd/util/xnet/xnet_test.go b/coderd/util/xnet/xnet_test.go
index cc731ace455..43beb0da835 100644
--- a/coderd/util/xnet/xnet_test.go
+++ b/coderd/util/xnet/xnet_test.go
@@ -4,10 +4,14 @@ import (
"context"
"io"
"net"
+ "net/http"
+ "net/http/httptest"
"syscall"
"testing"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "golang.org/x/net/http2"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/util/xnet"
@@ -32,4 +36,54 @@ func TestIsConnectionError(t *testing.T) {
require.True(t, xnet.IsConnectionError(io.ErrUnexpectedEOF))
require.True(t, xnet.IsConnectionError(xerrors.Errorf("write: %w", syscall.EPIPE)))
require.True(t, xnet.IsConnectionError(&net.OpError{Op: "read", Err: syscall.ECONNRESET}))
+
+ for _, err := range []error{
+ http2.StreamError{Code: http2.ErrCodeNo},
+ http2.StreamError{Code: http2.ErrCodeInternal},
+ http2.StreamError{Code: http2.ErrCodeRefusedStream},
+ http2.StreamError{Code: http2.ErrCodeCancel},
+ http2.StreamError{Code: http2.ErrCodeEnhanceYourCalm},
+ xerrors.Errorf("read response: %w", http2.StreamError{Code: http2.ErrCodeInternal}),
+ } {
+ require.True(t, xnet.IsConnectionError(err), err)
+ }
+ require.False(t, xnet.IsConnectionError(http2.StreamError{Code: http2.ErrCodeProtocol}))
+ require.False(t, xnet.IsConnectionError(http2.StreamError{Code: http2.ErrCodeFlowControl}))
+}
+
+func TestIsConnectionErrorHTTPResponseAbort(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ enableHTTP2 bool
+ proto string
+ }{
+ {name: "http1", proto: "HTTP/1.1"},
+ {name: "http2", enableHTTP2: true, proto: "HTTP/2.0"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(`{"partial":`))
+ assert.NoError(t, err)
+ assert.NoError(t, http.NewResponseController(w).Flush())
+ panic(http.ErrAbortHandler)
+ }))
+ server.EnableHTTP2 = tc.enableHTTP2
+ server.StartTLS()
+ t.Cleanup(server.Close)
+
+ request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+ response, err := server.Client().Do(request)
+ require.NoError(t, err)
+ require.Equal(t, tc.proto, response.Proto)
+ _, err = io.ReadAll(response.Body)
+ require.Error(t, err)
+ require.NoError(t, response.Body.Close())
+ require.True(t, xnet.IsConnectionError(err), err)
+ })
+ }
}
diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go
index f8ec2018d56..db7e1566e1c 100644
--- a/coderd/x/agenthooks/dispatch/dispatcher.go
+++ b/coderd/x/agenthooks/dispatch/dispatcher.go
@@ -231,7 +231,7 @@ func (d *Dispatcher) finish(
slog.F("dispatch_id", dispatchID),
slog.F("event", event.Type),
slog.F("result", outcome.result),
- slog.Error(outcome.err),
+ slog.F("error", outcome.err.Error()),
)
} else {
d.logger.Debug(context.WithoutCancel(ctx), "chat hook dispatched",
diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go
index 78527720a75..320fd33b14c 100644
--- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go
+++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go
@@ -252,6 +252,51 @@ func TestDispatcherRetriesConnectionErrorWithSameJTI(t *testing.T) {
require.Equal(t, event.ChatID, chatID)
}
+func TestDispatcherRetriesHTTP2AbortWithSameDispatchID(t *testing.T) {
+ t.Parallel()
+
+ event := newTestEvent(t, agenthooks.EventPostCompact, agenthooks.PostCompactData{})
+ type attemptIdentity struct {
+ jti uuid.UUID
+ dispatchID uuid.UUID
+ }
+ identities := make(chan attemptIdentity, 2)
+ var attempts atomic.Int32
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, 2, r.ProtoMajor)
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ claims, err := agenthooks.Verify(r.Header.Get("Authorization"), []byte(testSecret))
+ assert.NoError(t, err)
+ var request agenthooks.Request
+ assert.NoError(t, json.Unmarshal(body, &request))
+ identities <- attemptIdentity{jti: claims.JTI, dispatchID: request.Meta.DispatchID}
+
+ if attempts.Add(1) == 1 {
+ _, err = w.Write([]byte(`{"partial":`))
+ assert.NoError(t, err)
+ assert.NoError(t, http.NewResponseController(w).Flush())
+ panic(http.ErrAbortHandler)
+ }
+ _, err = w.Write([]byte(`{}`))
+ assert.NoError(t, err)
+ }))
+ server.EnableHTTP2 = true
+ server.StartTLS()
+ t.Cleanup(server.Close)
+
+ _, dispatchID, err := newTestDispatcher(t, server.Client(), server.URL, time.Second).Dispatch(
+ testutil.Context(t, testutil.WaitLong), event,
+ )
+ require.NoError(t, err)
+ require.Equal(t, int32(2), attempts.Load())
+ first := <-identities
+ second := <-identities
+ require.Equal(t, first, second)
+ require.Equal(t, dispatchID, first.jti)
+ require.Equal(t, dispatchID, first.dispatchID)
+}
+
func TestDispatcherRetriesMidBodyConnectionError(t *testing.T) {
t.Parallel()
diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md
index efe90a66ded..1487cf7f56f 100644
--- a/docs/admin/setup/chat-lifecycle-hooks.md
+++ b/docs/admin/setup/chat-lifecycle-hooks.md
@@ -141,7 +141,9 @@ Coder dispatches `pre_tool_use` once per tool call, after the model finishes pro
Two consequences follow:
-- Clients stream the model's proposed tool input while the dispatch is in flight, then converge on the stored input once the message is committed. A rewritten call briefly displays the original input.
+- Clients stream the model's proposed tool input while the dispatch is in flight, then converge on the stored input once the message is committed.
+ A rewritten call keeps displaying the original input until the whole batch is admitted, and the row spinner remains after the live **Thinking** indicator clears.
+ Coder dispatches a batch sequentially, so that window is bounded by `CODER_CHAT_HOOK_TIMEOUT` multiplied by the number of tool calls in the step, not by a single timeout.
- A tool call that is already in chat history was admitted before it was stored, so Coder executes it with the stored input instead of dispatching a second decision. If a consumer's policy changes between those two points, the change applies to later calls, not to calls already admitted. A call stored before hooks were configured is likewise not admitted retroactively, the same way an earlier prompt isn't.
The per-chat debug endpoint records what the model proposed, including tool input that a consumer replaced. It reports provider behavior and is not part of the chat transcript.
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
index 529dc062b8e..2e120d34a57 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
@@ -463,7 +463,11 @@ export const LifecycleHookNoticeOnUserMessage: Story = {
const notice = canvas.getByRole("note");
expect(notice).toBeVisible();
expect(within(notice).getByText("Lifecycle hook")).toBeVisible();
- expect(canvas.getByText("original prompt")).toBeVisible();
+ const prompt = canvas.getByText("original prompt");
+ expect(prompt).toBeVisible();
+ expect(
+ prompt.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
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 61912f4c4af..27c621d4345 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
@@ -652,14 +652,6 @@ const ChatMessageItem = memo<{
)}
inert={isAfterEditingMessage ? true : undefined}
>
- {parsed.hookNotices.map((notice, index) => (
-
- {notice}
-
- ))}
{isUser ? (
)}
+ {parsed.hookNotices.map((notice, index) => (
+
+ {notice}
+
+ ))}
{!hideActions &&
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx
index 7389610ce61..128a18f613e 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx
@@ -74,6 +74,8 @@ export const ExecuteTool: React.FC = ({
modelIntent,
parsedCommands,
durationLabel,
+ isRunning,
+ isError,
});
const defaultView = resolveAgentDisplayState(
shellToolDisplayMode,
@@ -159,6 +161,8 @@ type ShellCommandLineInput = {
modelIntent?: string;
parsedCommands?: readonly string[][];
durationLabel: string;
+ isRunning: boolean;
+ isError: boolean;
};
const getShellCommandLine = ({
@@ -166,6 +170,8 @@ const getShellCommandLine = ({
modelIntent,
parsedCommands,
durationLabel,
+ isRunning,
+ isError,
}: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => {
const intentLabel = sanitizeExecuteModelIntent(modelIntent, command);
const summary =
@@ -173,9 +179,12 @@ const getShellCommandLine = ({
? summarizeParsedCommands(parsedCommands)
: "";
const commandDisplay = summary || command;
- const commandLabel = intentLabel
+ let commandLabel = intentLabel
? `${intentLabel} using ${commandDisplay}`
: `Ran ${commandDisplay}`;
+ if (!isRunning && isError) {
+ commandLabel = `Failed to run ${commandDisplay}`;
+ }
return {
commandLabel,
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 7413f39a636..a0f06ac9d9a 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
@@ -414,6 +414,10 @@ export const ExecuteDeniedByHook: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ expect(canvas.getByText(/Failed to run cat \/etc\/secrets/)).toBeVisible();
+ expect(
+ canvas.queryByText(/Ran cat \/etc\/secrets/),
+ ).not.toBeInTheDocument();
await expect(
canvas.getByRole("img", {
name: /blocked by an external policy/,