mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user