Skip to content

Commit 04ee607

Browse files
committed
turn based message model with full tool call history in conversations
1 parent 37782ad commit 04ee607

25 files changed

+1820
-1073
lines changed

e2e/utils/aiAssistant.js

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -664,15 +664,54 @@ function isTitleRequest(provider, body) {
664664
)
665665
}
666666

667-
function requestMatchesQuestion(provider, body, question) {
667+
function contentToText(value) {
668+
if (typeof value === "string") return value
669+
if (value == null) return ""
670+
671+
if (Array.isArray(value)) {
672+
return value.map((entry) => contentToText(entry)).join("\n")
673+
}
674+
675+
if (typeof value === "object") {
676+
return [
677+
value.text,
678+
value.content,
679+
value.output,
680+
value.arguments,
681+
value.input,
682+
]
683+
.filter((part) => part != null)
684+
.map((part) => contentToText(part))
685+
.join("\n")
686+
}
687+
688+
return String(value)
689+
}
690+
691+
function getUserTexts(provider, body) {
668692
if (provider === "openai") {
669-
return body.input?.[0]?.content === question
693+
const userInputs = (body.input || []).filter(
694+
(item) => item?.role === "user",
695+
)
696+
return userInputs.map((item) => contentToText(item.content)).filter(Boolean)
670697
}
671-
// Both openai-chat-completions and anthropic use messages array
672-
const firstUserMessage = body.messages?.find(
673-
(msg) => msg.role === "user" && typeof msg.content === "string",
698+
699+
const userMessages = (body.messages || []).filter(
700+
(msg) => msg?.role === "user",
701+
)
702+
return userMessages.map((msg) => contentToText(msg.content)).filter(Boolean)
703+
}
704+
705+
function requestMatchesQuestion(provider, body, question) {
706+
const userTexts = getUserTexts(provider, body)
707+
if (userTexts.length === 0) return false
708+
709+
const lastUserText = userTexts[userTexts.length - 1]
710+
return (
711+
userTexts.some((text) => text === question) ||
712+
userTexts.some((text) => text.includes(question)) ||
713+
lastUserText.includes(`User request: ${question}`)
674714
)
675-
return firstUserMessage?.content === question
676715
}
677716

678717
function extractToolOutputContent(provider, body) {
@@ -699,30 +738,68 @@ function extractToolOutputContent(provider, body) {
699738
msg.content.some((c) => c.type === "tool_result"),
700739
)
701740
const latestMessage = toolResultMessages?.[toolResultMessages.length - 1]
702-
const latestToolResult = latestMessage?.content?.find(
703-
(c) => c.type === "tool_result",
704-
)
741+
const latestToolResult = [...(latestMessage?.content || [])]
742+
.reverse()
743+
.find((c) => c.type === "tool_result")
705744
return latestToolResult?.content || null
706745
}
707746

708747
function extractAllInputContent(provider, body) {
709748
if (provider === "openai") {
710-
return body.input?.map((item) => item.content || "").join("\n") || ""
749+
return (
750+
body.input
751+
?.map((item) =>
752+
contentToText(item.content ?? item.output ?? item.arguments ?? ""),
753+
)
754+
.join("\n") || ""
755+
)
711756
}
712757
// Both openai-chat-completions and anthropic use messages
713758
return (
714-
body.messages
715-
?.map((msg) => {
716-
if (typeof msg.content === "string") return msg.content
717-
if (Array.isArray(msg.content)) {
718-
return msg.content.map((c) => c.text || c.content || "").join("\n")
719-
}
720-
return ""
721-
})
722-
.join("\n") || ""
759+
body.messages?.map((msg) => contentToText(msg.content)).join("\n") || ""
723760
)
724761
}
725762

763+
function normalizeRequestBodyForAssertions(provider, body) {
764+
if (!body || typeof body !== "object") return body
765+
766+
if (provider === "openai") {
767+
const input = Array.isArray(body.input) ? body.input : []
768+
return {
769+
...body,
770+
input: input.filter(
771+
(item) =>
772+
item?.type !== "function_call" &&
773+
item?.type !== "function_call_output",
774+
),
775+
}
776+
}
777+
778+
if (provider === "openai-chat-completions") {
779+
const messages = Array.isArray(body.messages) ? body.messages : []
780+
return {
781+
...body,
782+
messages: messages.filter((msg) => msg?.role !== "tool"),
783+
}
784+
}
785+
786+
if (provider === "anthropic") {
787+
const messages = Array.isArray(body.messages) ? body.messages : []
788+
return {
789+
...body,
790+
messages: messages.map((msg) => {
791+
if (msg?.role !== "user" || !Array.isArray(msg.content)) return msg
792+
return {
793+
...msg,
794+
content: msg.content.filter((block) => block?.type !== "tool_result"),
795+
}
796+
}),
797+
}
798+
}
799+
800+
return body
801+
}
802+
726803
// =============================================================================
727804
// TOOL CALL FLOW
728805
// =============================================================================
@@ -891,7 +968,10 @@ function createMultiTurnFlow(config) {
891968

892969
const turn = turns[requestCount]
893970
if (turn) {
894-
requestBodies[requestCount] = req.body
971+
requestBodies[requestCount] = normalizeRequestBodyForAssertions(
972+
provider,
973+
req.body,
974+
)
895975

896976
if (turn.expectSystemMessage) {
897977
const allInputContent = extractAllInputContent(provider, req.body)

src/components/AIStatusIndicator/AssistantModes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export const buildOperationSections = (
239239
}
240240

241241
export const formatDurationMs = (ms: number): string | null => {
242-
if (ms < 0) return null
242+
if (ms <= 0) return null
243243
if (ms < 1000) return `${ms}ms`
244244
return `${Math.round(ms / 1000)}s`
245245
}

src/hooks/useAIQuickActions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const useAIQuickActions = () => {
5959
createConversation,
6060
openChatWindow,
6161
addMessage,
62+
removeMessages,
6263
updateMessage,
6364
updateConversationName,
6465
persistMessages,
@@ -145,6 +146,7 @@ export const useAIQuickActions = () => {
145146
}),
146147
{
147148
addMessage,
149+
removeMessages,
148150
updateMessage,
149151
setStatus,
150152
setIsStreaming,
@@ -186,6 +188,7 @@ export const useAIQuickActions = () => {
186188
}),
187189
{
188190
addMessage,
191+
removeMessages,
189192
updateMessage,
190193
setStatus,
191194
setIsStreaming,
@@ -269,6 +272,7 @@ export const useAIQuickActions = () => {
269272
}),
270273
{
271274
addMessage,
275+
removeMessages,
272276
updateMessage,
273277
setStatus,
274278
setIsStreaming,
@@ -316,6 +320,7 @@ export const useAIQuickActions = () => {
316320
}),
317321
{
318322
addMessage,
323+
removeMessages,
319324
updateMessage,
320325
setStatus,
321326
setIsStreaming,

src/providers/AIConversationProvider/index.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { normalizeSql } from "../../utils/aiAssistant"
3333
import { useDispatch, useSelector } from "react-redux"
3434
import { trackEvent } from "../../modules/ConsoleEventTracker"
3535
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"
36+
import { projectConversationTurns } from "../../utils/ai/turnView"
3637

3738
export type AcceptSuggestionParams = {
3839
conversationId: ConversationId
@@ -95,6 +96,7 @@ type AIConversationContextType = {
9596
message: Omit<ConversationMessage, "id"> & { id?: string },
9697
messageIdsToRemove?: string[],
9798
) => void
99+
removeMessages: (messageIdsToRemove: string[]) => void
98100
updateMessage: (
99101
conversationId: ConversationId,
100102
messageId: string,
@@ -220,13 +222,26 @@ export const AIConversationProvider: React.FC<{
220222
lastAssistantMessage?: ConversationMessage
221223
}> => {
222224
const messages = await aiConversationStore.getMessages(conversationId)
225+
const { visibleEntries, previousVisibleUserByAnchorIndex } =
226+
projectConversationTurns(messages)
227+
const lastEntry = visibleEntries.at(-1)
228+
const lastAssistantMessage =
229+
lastEntry?.type === "assistantTurn"
230+
? lastEntry.anchorMessage
231+
: undefined
232+
233+
let lastUserMessage: ConversationMessage | undefined
234+
if (lastEntry?.type === "assistantTurn") {
235+
lastUserMessage = previousVisibleUserByAnchorIndex.get(
236+
lastEntry.anchorIndex,
237+
)
238+
} else if (lastEntry?.type === "user") {
239+
lastUserMessage = lastEntry.message
240+
}
241+
223242
return {
224-
lastUserMessage: messages
225-
.filter((m) => m.role === "user" && !m.hideFromUI)
226-
.at(-1),
227-
lastAssistantMessage: messages
228-
.filter((m) => m.role === "assistant" && !m.hideFromUI)
229-
.at(-1),
243+
lastUserMessage,
244+
lastAssistantMessage,
230245
}
231246
},
232247
[],
@@ -399,6 +414,13 @@ export const AIConversationProvider: React.FC<{
399414
[],
400415
)
401416

417+
const removeMessages = useCallback((messageIdsToRemove: string[]) => {
418+
if (messageIdsToRemove.length === 0) return
419+
setActiveConversationMessages((prev) =>
420+
prev.filter((message) => !messageIdsToRemove.includes(message.id)),
421+
)
422+
}, [])
423+
402424
const updateMessage = useCallback(
403425
(
404426
conversationId: ConversationId,
@@ -998,6 +1020,7 @@ export const AIConversationProvider: React.FC<{
9981020
closeHistoryView,
9991021
deleteConversation,
10001022
addMessage,
1023+
removeMessages,
10011024
updateMessage,
10021025
replaceConversationMessages,
10031026
updateConversationName,

src/providers/AIConversationProvider/messageContent.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/providers/AIConversationProvider/types.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
import type { PartitionBy } from "../../utils/questdb"
22
import type { QueryKey } from "../../scenes/Editor/Monaco/utils"
3+
import type { Message } from "../../utils/ai/types"
4+
import type { TokenUsage } from "../../utils/aiAssistant"
35
import type { OperationHistory } from "../AIStatusProvider"
46

57
export type { QueryKey }
68

79
export type ConversationId = string
810

9-
export type TokenUsage = {
10-
inputTokens: number
11-
outputTokens: number
12-
}
13-
1411
export type SchemaDisplayData = {
1512
tableName: string
1613
kind: "table" | "matview" | "view"
@@ -32,22 +29,20 @@ export type UserMessageDisplayType =
3229
| "schema_explain_request"
3330
| "health_issue_request"
3431

35-
export type ConversationMessage = {
32+
export type ConversationMessage = Message & {
3633
id: string
37-
role: "user" | "assistant"
38-
content?: string
3934
timestamp: number
4035
error?: string
4136
sql?: string
42-
tokenUsage?: TokenUsage // Token usage for current turn in total, including tool calls that we omit from the history after response
37+
tokenUsage?: TokenUsage
4338
previousSQL?: string // SQL before this change (for diff display)
4439
isRejected?: boolean
40+
contentTimestamp?: number // When text content started streaming
4541
isAccepted?: boolean
46-
hideFromUI?: boolean // User messages for accept/reject and compaction result are hidden
47-
isCompacted?: boolean // When converted to true, we include it in the history for UI, but do not send to the model anymore
42+
hideFromUI?: boolean
43+
isCompacted?: boolean
4844
operationHistory?: OperationHistory
4945
model?: string
50-
// Predefined actions (Fix and Explain)
5146
displayType?: UserMessageDisplayType
5247
displayUserMessage?: string
5348
displaySchemaData?: SchemaDisplayData
Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { ConversationMessage } from "./types"
2-
31
/**
42
* Trims trailing semicolon from SQL for display purposes.
53
* Also ensures the result ends with a newline for Monaco diff editor compatibility.
@@ -14,37 +12,3 @@ export const trimSemicolonForDisplay = (
1412
}
1513
return trimmed + "\n"
1614
}
17-
18-
/**
19-
* Finds the last visible assistant message with an unactioned SQL diff.
20-
* Returns the message if found, null otherwise.
21-
*
22-
*/
23-
export const getLastUnactionedDiff = (
24-
messages: ConversationMessage[],
25-
): ConversationMessage | null => {
26-
// Find last visible message
27-
const visibleMessages = messages.filter((m) => !m.hideFromUI)
28-
if (visibleMessages.length === 0) return null
29-
30-
const lastVisible = visibleMessages[visibleMessages.length - 1]
31-
32-
// Check if it's an assistant message with a real SQL change that hasn't been actioned
33-
const hasUnactionedDiff =
34-
lastVisible.role === "assistant" &&
35-
lastVisible.sql !== undefined &&
36-
lastVisible.previousSQL !== undefined &&
37-
lastVisible.sql.trim() !== lastVisible.previousSQL.trim() &&
38-
!lastVisible.isAccepted &&
39-
!lastVisible.isRejected
40-
41-
return hasUnactionedDiff ? lastVisible : null
42-
}
43-
44-
/**
45-
* Checks if there's an unactioned diff in the conversation messages.
46-
* Simple boolean helper wrapping getLastUnactionedDiff.
47-
*/
48-
export const hasUnactionedDiff = (messages: ConversationMessage[]): boolean => {
49-
return getLastUnactionedDiff(messages) !== null
50-
}

0 commit comments

Comments
 (0)