Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion coderd/util/xnet/xnet.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"io"
"net"
"syscall"

"golang.org/x/net/http2"
)

// IsTimeoutError reports whether err indicates the peer did not respond in
Expand All @@ -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
}
}
54 changes: 54 additions & 0 deletions coderd/util/xnet/xnet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
})
}
}
2 changes: 1 addition & 1 deletion coderd/x/agenthooks/dispatch/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions coderd/x/agenthooks/dispatch/dispatcher_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 3 additions & 1 deletion docs/admin/setup/chat-lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -652,14 +652,6 @@ const ChatMessageItem = memo<{
)}
inert={isAfterEditingMessage ? true : undefined}
>
{parsed.hookNotices.map((notice, index) => (
<LifecycleHookNotice
key={`${message.id}-hook-notice-${index}`}
urlTransform={urlTransform}
>
{notice}
</LifecycleHookNotice>
))}
<ConversationItem {...conversationItemProps}>
{isUser ? (
<UserMessageContent
Expand Down Expand Up @@ -704,6 +696,14 @@ const ChatMessageItem = memo<{
</Message>
)}
</ConversationItem>
{parsed.hookNotices.map((notice, index) => (
<LifecycleHookNotice
key={`${message.id}-hook-notice-${index}`}
urlTransform={urlTransform}
>
{notice}
</LifecycleHookNotice>
))}
{!hideActions &&
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
modelIntent,
parsedCommands,
durationLabel,
isRunning,
isError,
});
const defaultView = resolveAgentDisplayState(
shellToolDisplayMode,
Expand Down Expand Up @@ -159,23 +161,30 @@ type ShellCommandLineInput = {
modelIntent?: string;
parsedCommands?: readonly string[][];
durationLabel: string;
isRunning: boolean;
isError: boolean;
};

const getShellCommandLine = ({
command,
modelIntent,
parsedCommands,
durationLabel,
isRunning,
isError,
}: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => {
const intentLabel = sanitizeExecuteModelIntent(modelIntent, command);
const summary =
parsedCommands && parsedCommands.length > 0
? 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
Loading