Files
coder/coderd/x/chatd/chatstate/toolresults_test.go
T
Michael Suchacz 91c7232d97 feat: add chat suffix messages, idle failure, and content update support (#27428)
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.
2026-07-29 13:16:29 +02:00

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)
})
}
}