refactor(coderd/x/chatd/chatstate): separate last-assistant loading from call policy

lastAssistantTurn loads the last assistant message and the tool calls
already answered; outstandingToolCalls keeps the eligibility rules.
Test fixtures share one chatMessage builder, and the waiting-state
cancellation test now covers provider-executed, answered, and
unparsable cases and part order.
This commit is contained in:
Michael Suchacz
2026-10-15 21:30:08 +00:00
parent 7b3405fdd1
commit 062f56941d
4 changed files with 76 additions and 107 deletions
@@ -23,20 +23,7 @@ import (
// non-dynamic tools.
func nonDynamicAssistantToolCallMessage(t *testing.T, modelID uuid.UUID, callID string) chatstate.Message {
t.Helper()
raw, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: callID,
ToolName: "non_dynamic_tool",
Args: json.RawMessage(`{}`),
}})
require.NoError(t, err)
return chatstate.Message{
Role: database.ChatMessageRoleAssistant,
Content: raw,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: modelID, Valid: true},
}
return assistantToolCallMessage(t, modelID, "non_dynamic_tool", callID)
}
// assertToolResultForCall asserts that msg is a tool-result message
@@ -60,25 +47,26 @@ func assertToolResultForCall(t *testing.T, msg database.ChatMessage, wantCallID
}
// commitAssistantToolCall pushes an assistant message that calls
// `tool_name` with `callID` into history via CommitStep. Returns the
// inserted assistant ChatMessage. Use the dynamic-tools chat fixture
// (createTestChatWithDynamicTools) when dynamicOnly cancellation
// paths are exercised.
// `tool_name` with `callID`, followed by any toolMessages, into history
// via CommitStep. Returns the inserted assistant ChatMessage. Use the
// dynamic-tools chat fixture (createTestChatWithDynamicTools) when
// dynamicOnly cancellation paths are exercised.
func commitAssistantToolCall(
t *testing.T,
f *testFixture,
m *chatstate.ChatMachine,
msg chatstate.Message,
toolMessages ...chatstate.Message,
) database.ChatMessage {
t.Helper()
ctx := testutil.Context(t, testutil.WaitShort)
var step chatstate.CommitStepResult
require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
var err error
step, err = tx.CommitStep(chatstate.CommitStepInput{Messages: []chatstate.Message{msg}})
step, err = tx.CommitStep(chatstate.CommitStepInput{Messages: append([]chatstate.Message{msg}, toolMessages...)})
return err
}))
require.Len(t, step.InsertedMessages, 1)
require.Len(t, step.InsertedMessages, 1+len(toolMessages))
return step.InsertedMessages[0]
}
@@ -134,15 +122,6 @@ func testSendMessageDirectWSynthesizesToolCancellations(t *testing.T) {
created := createTestChat(t, f)
m := chatstate.NewChatMachine(f.DB, f.Pub, created.Chat.ID)
message := func(role database.ChatMessageRole, content pqtype.NullRawMessage) chatstate.Message {
return chatstate.Message{
Role: role,
Content: content,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: f.Model.ID, Valid: true},
}
}
call := func(id string) codersdk.ChatMessagePart {
return codersdk.ChatMessageToolCall(id, "non_dynamic_tool", json.RawMessage(`{}`))
}
@@ -151,18 +130,16 @@ func testSendMessageDirectWSynthesizesToolCancellations(t *testing.T) {
}
providerExecuted := call("provider-executed")
providerExecuted.ProviderExecuted = true
require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, _ database.Store) error {
_, err := tx.CommitStep(chatstate.CommitStepInput{Messages: []chatstate.Message{
message(database.ChatMessageRoleAssistant, mustMarshalParts(t, []codersdk.ChatMessagePart{
call("outstanding-1"), providerExecuted,
call("answered-inline"), result("answered-inline"),
call("answered-after"), call("outstanding-2"),
})),
message(database.ChatMessageRoleTool, mustMarshalParts(t, []codersdk.ChatMessagePart{result("answered-after")})),
message(database.ChatMessageRoleTool, pqtype.NullRawMessage{RawMessage: json.RawMessage(`{}`), Valid: true}),
}})
return err
}))
unparsable := chatMessage(t, f.Model.ID, database.ChatMessageRoleTool)
unparsable.Content = pqtype.NullRawMessage{RawMessage: json.RawMessage(`{}`), Valid: true}
commitAssistantToolCall(t, f, m,
chatMessage(t, f.Model.ID, database.ChatMessageRoleAssistant,
call("outstanding-1"), providerExecuted,
call("answered-inline"), result("answered-inline"),
call("answered-after"), call("outstanding-2")),
chatMessage(t, f.Model.ID, database.ChatMessageRoleTool, result("answered-after")),
unparsable,
)
// R0 -> W.
landInW(t, f, m)
+45 -34
View File
@@ -92,31 +92,62 @@ func outstandingToolCalls(ctx context.Context, store database.Store, chat databa
return nil, nil
}
}
parts, answered, err := lastAssistantTurn(ctx, store, chat.ID)
if err != nil {
return nil, err
}
var out []codersdk.ChatMessagePart
for _, p := range parts {
// Provider-executed tool calls are answered server-side by the
// LLM provider. A synthetic client tool-result for them is
// invalid replay history: Anthropic rejects a plain tool_result
// block that references a server_tool_use ID.
if p.Type != codersdk.ChatMessagePartTypeToolCall || p.ProviderExecuted || answered[p.ToolCallID] {
continue
}
if dynamicOnly && !dynamicToolNames[p.ToolName] {
continue
}
out = append(out, p)
}
return out, nil
}
// lastAssistantTurn returns the parts of the chat's last assistant
// message and the IDs of the tool calls answered in that message or by
// a tool message after it. Provider-executed tool results (e.g.
// web_search) are persisted inside the assistant message itself.
func lastAssistantTurn(ctx context.Context, store database.Store, chatID uuid.UUID) ([]codersdk.ChatMessagePart, map[string]bool, error) {
lastAssistant, err := store.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chat.ID,
ChatID: chatID,
Role: database.ChatMessageRoleAssistant,
})
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, nil
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, xerrors.Errorf("get last assistant message: %w", err)
return nil, nil, xerrors.Errorf("get last assistant message: %w", err)
}
parts, err := chatprompt.ParseContent(lastAssistant)
if err != nil {
return nil, xerrors.Errorf("parse assistant message: %w", err)
return nil, nil, xerrors.Errorf("parse assistant message: %w", err)
}
afterMsgs, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
ChatID: chatID,
AfterID: lastAssistant.ID,
})
if err != nil {
return nil, xerrors.Errorf("get messages after assistant: %w", err)
return nil, nil, xerrors.Errorf("get messages after assistant: %w", err)
}
// Provider-executed tool results (e.g. web_search) are persisted
// inside the assistant message itself, so they count as handled
// alongside the tool messages after it.
resultParts := parts
answered := make(map[string]bool)
markAnswered := func(msgParts []codersdk.ChatMessagePart) {
for _, p := range msgParts {
if p.Type == codersdk.ChatMessagePartTypeToolResult {
answered[p.ToolCallID] = true
}
}
}
markAnswered(parts)
for _, msg := range afterMsgs {
if msg.Role != database.ChatMessageRoleTool {
continue
@@ -127,29 +158,9 @@ func outstandingToolCalls(ctx context.Context, store database.Store, chat databa
// its tool results are treated as unknown.
continue
}
resultParts = append(resultParts, messageParts...)
markAnswered(messageParts)
}
handled := make(map[string]bool)
for _, p := range resultParts {
if p.Type == codersdk.ChatMessagePartTypeToolResult {
handled[p.ToolCallID] = true
}
}
var out []codersdk.ChatMessagePart
for _, p := range parts {
// Provider-executed tool calls are answered server-side by the
// LLM provider. A synthetic client tool-result for them is
// invalid replay history: Anthropic rejects a plain tool_result
// block that references a server_tool_use ID.
if p.Type != codersdk.ChatMessagePartTypeToolCall || p.ProviderExecuted || handled[p.ToolCallID] {
continue
}
if dynamicOnly && !dynamicToolNames[p.ToolName] {
continue
}
out = append(out, p)
}
return out, nil
return parts, answered, nil
}
// parseDynamicToolNamesFromRaw is a private mirror of
+1
View File
@@ -1535,6 +1535,7 @@ func (tx *Tx) FinishInterruption(input FinishInterruptionInput) (FinishInterrupt
if err != nil {
return FinishInterruptionResult{}, xerrors.Errorf("insert interruption partial messages: %w", err)
}
// Any outstanding call, dynamic or not, would leave invalid LLM history.
outstanding, err := outstandingToolCalls(tx.ctx, tx.store, chat, false)
if err != nil {
return FinishInterruptionResult{}, err
@@ -64,43 +64,23 @@ func dynamicToolJSON(name string) []byte {
// messages do not collide.
func assistantToolCallMessage(t *testing.T, modelID uuid.UUID, toolName, callID string) chatstate.Message {
t.Helper()
raw, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: callID,
ToolName: toolName,
Args: json.RawMessage(`{}`),
}})
require.NoError(t, err)
return chatstate.Message{
Role: database.ChatMessageRoleAssistant,
Content: raw,
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: modelID, Valid: true},
}
return chatMessage(t, modelID, database.ChatMessageRoleAssistant,
codersdk.ChatMessageToolCall(callID, toolName, json.RawMessage(`{}`)))
}
func mixedAssistantToolCallMessage(t *testing.T, modelID uuid.UUID, dynamicTool, dynCallID, nonDynCallID string) chatstate.Message {
t.Helper()
parts := []codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: dynCallID,
ToolName: dynamicTool,
Args: json.RawMessage(`{}`),
},
{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: nonDynCallID,
ToolName: "non_dynamic_tool",
Args: json.RawMessage(`{}`),
},
}
raw, err := chatprompt.MarshalParts(parts)
require.NoError(t, err)
return chatMessage(t, modelID, database.ChatMessageRoleAssistant,
codersdk.ChatMessageToolCall(dynCallID, dynamicTool, json.RawMessage(`{}`)),
codersdk.ChatMessageToolCall(nonDynCallID, "non_dynamic_tool", json.RawMessage(`{}`)))
}
// chatMessage builds a chatstate.Message with the given role and parts.
func chatMessage(t *testing.T, modelID uuid.UUID, role database.ChatMessageRole, parts ...codersdk.ChatMessagePart) chatstate.Message {
t.Helper()
return chatstate.Message{
Role: database.ChatMessageRoleAssistant,
Content: raw,
Role: role,
Content: mustMarshalParts(t, parts),
Visibility: database.ChatMessageVisibilityBoth,
ContentVersion: chatprompt.CurrentContentVersion,
ModelConfigID: uuid.NullUUID{UUID: modelID, Valid: true},