Skip to content

Commit 498a691

Browse files
committed
refactor(core): extract monolith helpers
Refs #8
1 parent 81afb6c commit 498a691

4 files changed

Lines changed: 235 additions & 209 deletions

File tree

src/core/task/Task.ts

Lines changed: 10 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
132132
import { MessageManager } from "../message-manager"
133133
import { validateAndFixToolResultIds } from "./validateToolResultIds"
134134
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
135+
import { prepareApiConversationMessage } from "./apiConversationHistory"
135136

136137
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
137138
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@@ -861,162 +862,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
861862
}
862863

863864
private async addToApiConversationHistory(message: Anthropic.MessageParam, reasoning?: string) {
864-
// Capture the encrypted_content / thought signatures from the provider (e.g., OpenAI Responses API, Google GenAI) if present.
865-
// We only persist data reported by the current response body.
866-
const handler = this.api as ApiHandler & {
867-
getResponseId?: () => string | undefined
868-
getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined
869-
getThoughtSignature?: () => string | undefined
870-
getSummary?: () => any[] | undefined
871-
getReasoningDetails?: () => any[] | undefined
872-
}
873-
874-
if (message.role === "assistant") {
875-
const responseId = handler.getResponseId?.()
876-
const reasoningData = handler.getEncryptedContent?.()
877-
const thoughtSignature = handler.getThoughtSignature?.()
878-
const reasoningSummary = handler.getSummary?.()
879-
const reasoningDetails = handler.getReasoningDetails?.()
880-
881-
// Only Anthropic's API expects/validates the special `thinking` content block signature.
882-
// Other providers (notably Gemini 3) use different signature semantics (e.g. `thoughtSignature`)
883-
// and require round-tripping the signature in their own format.
884-
const modelId = getModelId(this.apiConfiguration)
885-
const apiProvider = this.apiConfiguration.apiProvider
886-
const apiProtocol = getApiProtocol(
887-
apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined,
888-
modelId,
889-
)
890-
const isAnthropicProtocol = apiProtocol === "anthropic"
891-
892-
// Start from the original assistant message
893-
const messageWithTs: any = {
894-
...message,
895-
...(responseId ? { id: responseId } : {}),
896-
ts: Date.now(),
897-
}
898-
899-
// Store reasoning_details array if present (for models like Gemini 3)
900-
if (reasoningDetails) {
901-
messageWithTs.reasoning_details = reasoningDetails
902-
}
903-
904-
// Store reasoning: Anthropic thinking (with signature), plain text (most providers), or encrypted (OpenAI Native)
905-
// Skip if reasoning_details already contains the reasoning (to avoid duplication)
906-
if (isAnthropicProtocol && reasoning && thoughtSignature && !reasoningDetails) {
907-
// Anthropic provider with extended thinking: Store as proper `thinking` block
908-
// This format passes through anthropic-filter.ts and is properly round-tripped
909-
// for interleaved thinking with tool use (required by Anthropic API)
910-
const thinkingBlock = {
911-
type: "thinking",
912-
thinking: reasoning,
913-
signature: thoughtSignature,
914-
}
915-
916-
if (typeof messageWithTs.content === "string") {
917-
messageWithTs.content = [
918-
thinkingBlock,
919-
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
920-
]
921-
} else if (Array.isArray(messageWithTs.content)) {
922-
messageWithTs.content = [thinkingBlock, ...messageWithTs.content]
923-
} else if (!messageWithTs.content) {
924-
messageWithTs.content = [thinkingBlock]
925-
}
926-
} else if (reasoning && !reasoningDetails) {
927-
// Other providers (non-Anthropic): Store as generic reasoning block
928-
const reasoningBlock = {
929-
type: "reasoning",
930-
text: reasoning,
931-
summary: reasoningSummary ?? ([] as any[]),
932-
}
933-
934-
if (typeof messageWithTs.content === "string") {
935-
messageWithTs.content = [
936-
reasoningBlock,
937-
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
938-
]
939-
} else if (Array.isArray(messageWithTs.content)) {
940-
messageWithTs.content = [reasoningBlock, ...messageWithTs.content]
941-
} else if (!messageWithTs.content) {
942-
messageWithTs.content = [reasoningBlock]
943-
}
944-
} else if (reasoningData?.encrypted_content) {
945-
// OpenAI Native encrypted reasoning
946-
const reasoningBlock = {
947-
type: "reasoning",
948-
summary: [] as any[],
949-
encrypted_content: reasoningData.encrypted_content,
950-
...(reasoningData.id ? { id: reasoningData.id } : {}),
951-
}
952-
953-
if (typeof messageWithTs.content === "string") {
954-
messageWithTs.content = [
955-
reasoningBlock,
956-
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
957-
]
958-
} else if (Array.isArray(messageWithTs.content)) {
959-
messageWithTs.content = [reasoningBlock, ...messageWithTs.content]
960-
} else if (!messageWithTs.content) {
961-
messageWithTs.content = [reasoningBlock]
962-
}
963-
}
964-
965-
// For non-Anthropic providers (e.g., Gemini 3), persist the thought signature as its own
966-
// content block so converters can attach it back to the correct provider-specific fields.
967-
// Note: For Anthropic extended thinking, the signature is already included in the thinking block above.
968-
if (thoughtSignature && !isAnthropicProtocol) {
969-
const thoughtSignatureBlock = {
970-
type: "thoughtSignature",
971-
thoughtSignature,
972-
}
973-
974-
if (typeof messageWithTs.content === "string") {
975-
messageWithTs.content = [
976-
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
977-
thoughtSignatureBlock,
978-
]
979-
} else if (Array.isArray(messageWithTs.content)) {
980-
messageWithTs.content = [...messageWithTs.content, thoughtSignatureBlock]
981-
} else if (!messageWithTs.content) {
982-
messageWithTs.content = [thoughtSignatureBlock]
983-
}
984-
}
985-
986-
this.apiConversationHistory.push(messageWithTs)
987-
} else {
988-
// For user messages, validate tool_result IDs ONLY when the immediately previous *effective* message
989-
// is an assistant message.
990-
//
991-
// If the previous effective message is also a user message (e.g., summary + a new user message),
992-
// validating against any earlier assistant message can incorrectly inject placeholder tool_results.
993-
const effectiveHistoryForValidation = getEffectiveApiHistory(this.apiConversationHistory)
994-
const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1]
995-
const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : []
996-
997-
// If the previous effective message is NOT an assistant, convert tool_result blocks to text blocks.
998-
// This prevents orphaned tool_results from being filtered out by getEffectiveApiHistory.
999-
// This can happen when condensing occurs after the assistant sends tool_uses but before
1000-
// the user responds - the tool_use blocks get condensed away, leaving orphaned tool_results.
1001-
let messageToAdd = message
1002-
if (lastEffective?.role !== "assistant" && Array.isArray(message.content)) {
1003-
messageToAdd = {
1004-
...message,
1005-
content: message.content.map((block) =>
1006-
block.type === "tool_result"
1007-
? {
1008-
type: "text" as const,
1009-
text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`,
1010-
}
1011-
: block,
1012-
),
1013-
}
1014-
}
1015-
1016-
const validatedMessage = validateAndFixToolResultIds(messageToAdd, historyForValidation)
1017-
const messageWithTs = { ...validatedMessage, ts: Date.now() }
1018-
this.apiConversationHistory.push(messageWithTs)
1019-
}
865+
this.apiConversationHistory.push(
866+
prepareApiConversationMessage({
867+
message,
868+
reasoning,
869+
api: this.api,
870+
apiConfiguration: this.apiConfiguration,
871+
apiConversationHistory: this.apiConversationHistory,
872+
}),
873+
)
1020874

1021875
await this.saveApiConversationHistory()
1022876
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Anthropic } from "@anthropic-ai/sdk"
2+
3+
import { type ProviderSettings, getApiProtocol, getModelId, isRetiredProvider } from "@roo-code/types"
4+
5+
import type { ApiHandler } from "../../api"
6+
import { getEffectiveApiHistory } from "../condense"
7+
import type { ApiMessage } from "../task-persistence"
8+
import { validateAndFixToolResultIds } from "./validateToolResultIds"
9+
10+
type ApiHistoryHandler = ApiHandler & {
11+
getResponseId?: () => string | undefined
12+
getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined
13+
getThoughtSignature?: () => string | undefined
14+
getSummary?: () => any[] | undefined
15+
getReasoningDetails?: () => any[] | undefined
16+
}
17+
18+
interface PrepareApiConversationMessageOptions {
19+
message: Anthropic.MessageParam
20+
reasoning?: string
21+
api: ApiHandler
22+
apiConfiguration: ProviderSettings
23+
apiConversationHistory: ApiMessage[]
24+
}
25+
26+
export function prepareApiConversationMessage({
27+
message,
28+
reasoning,
29+
api,
30+
apiConfiguration,
31+
apiConversationHistory,
32+
}: PrepareApiConversationMessageOptions): ApiMessage {
33+
if (message.role === "assistant") {
34+
return prepareAssistantMessage(message, reasoning, api as ApiHistoryHandler, apiConfiguration)
35+
}
36+
37+
return prepareUserMessage(message, apiConversationHistory)
38+
}
39+
40+
function prepareAssistantMessage(
41+
message: Anthropic.MessageParam,
42+
reasoning: string | undefined,
43+
handler: ApiHistoryHandler,
44+
apiConfiguration: ProviderSettings,
45+
): ApiMessage {
46+
const responseId = handler.getResponseId?.()
47+
const reasoningData = handler.getEncryptedContent?.()
48+
const thoughtSignature = handler.getThoughtSignature?.()
49+
const reasoningSummary = handler.getSummary?.()
50+
const reasoningDetails = handler.getReasoningDetails?.()
51+
52+
const modelId = getModelId(apiConfiguration)
53+
const apiProvider = apiConfiguration.apiProvider
54+
const apiProtocol = getApiProtocol(
55+
apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined,
56+
modelId,
57+
)
58+
const isAnthropicProtocol = apiProtocol === "anthropic"
59+
60+
const messageWithTs: any = {
61+
...message,
62+
...(responseId ? { id: responseId } : {}),
63+
ts: Date.now(),
64+
}
65+
66+
if (reasoningDetails) {
67+
messageWithTs.reasoning_details = reasoningDetails
68+
}
69+
70+
if (isAnthropicProtocol && reasoning && thoughtSignature && !reasoningDetails) {
71+
const thinkingBlock = {
72+
type: "thinking",
73+
thinking: reasoning,
74+
signature: thoughtSignature,
75+
}
76+
77+
prependContentBlock(messageWithTs, thinkingBlock)
78+
} else if (reasoning && !reasoningDetails) {
79+
const reasoningBlock = {
80+
type: "reasoning",
81+
text: reasoning,
82+
summary: reasoningSummary ?? ([] as any[]),
83+
}
84+
85+
prependContentBlock(messageWithTs, reasoningBlock)
86+
} else if (reasoningData?.encrypted_content) {
87+
const reasoningBlock = {
88+
type: "reasoning",
89+
summary: [] as any[],
90+
encrypted_content: reasoningData.encrypted_content,
91+
...(reasoningData.id ? { id: reasoningData.id } : {}),
92+
}
93+
94+
prependContentBlock(messageWithTs, reasoningBlock)
95+
}
96+
97+
if (thoughtSignature && !isAnthropicProtocol) {
98+
const thoughtSignatureBlock = {
99+
type: "thoughtSignature",
100+
thoughtSignature,
101+
}
102+
103+
appendContentBlock(messageWithTs, thoughtSignatureBlock)
104+
}
105+
106+
return messageWithTs
107+
}
108+
109+
function prepareUserMessage(message: Anthropic.MessageParam, apiConversationHistory: ApiMessage[]): ApiMessage {
110+
const effectiveHistoryForValidation = getEffectiveApiHistory(apiConversationHistory)
111+
const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1]
112+
const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : []
113+
114+
let messageToAdd = message
115+
if (lastEffective?.role !== "assistant" && Array.isArray(message.content)) {
116+
messageToAdd = {
117+
...message,
118+
content: message.content.map((block) =>
119+
block.type === "tool_result"
120+
? {
121+
type: "text" as const,
122+
text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`,
123+
}
124+
: block,
125+
),
126+
}
127+
}
128+
129+
const validatedMessage = validateAndFixToolResultIds(messageToAdd, historyForValidation)
130+
return { ...validatedMessage, ts: Date.now() }
131+
}
132+
133+
function prependContentBlock(message: any, block: any): void {
134+
if (typeof message.content === "string") {
135+
message.content = [block, { type: "text", text: message.content } satisfies Anthropic.Messages.TextBlockParam]
136+
} else if (Array.isArray(message.content)) {
137+
message.content = [block, ...message.content]
138+
} else if (!message.content) {
139+
message.content = [block]
140+
}
141+
}
142+
143+
function appendContentBlock(message: any, block: any): void {
144+
if (typeof message.content === "string") {
145+
message.content = [{ type: "text", text: message.content } satisfies Anthropic.Messages.TextBlockParam, block]
146+
} else if (Array.isArray(message.content)) {
147+
message.content = [...message.content, block]
148+
} else if (!message.content) {
149+
message.content = [block]
150+
}
151+
}

0 commit comments

Comments
 (0)