mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Adds generic chat state and query capabilities that the lifecycle hooks integration (#27429) builds on. Part of the lifecycle hooks stack (#27401, #27429, #27430). - `chatstate`: `EditMessage` accepts caller-provided suffix messages inserted after the replacement in the same transaction, transitions can carry a typed error kind, and `FinishError` is also allowed from waiting chats so admission-time failures can park an idle chat in error. - `chatstate`: `ValidateToolResults` holds the submitted-tool-result rules (duplicate, invalid JSON, missing, unexpected) in one place, so `CompleteRequiresAction` and API-level prechecks reject the same payloads with the same typed causes. - `database`: `InsertChat` accepts an optional caller-provided ID. No hook-specific state or behavior is introduced here; these primitives are usable by any caller. An earlier revision added a message-content rewrite primitive so a `pre_tool_use` override could update an already-committed tool call. Message content is immutable by design, and @hugodutka pushed back on changing that. The rewrite is gone: #27429 now dispatches the hook before the assistant message is stored, so the stored input is the one that runs and nothing needs updating. > This PR was written by Mux, an AI coding agent, on Mike's behalf.
86 lines
2.5 KiB
Go
86 lines
2.5 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|