Skip to content

Commit 7245680

Browse files
authored
Refactor core monolith helpers (#27)
* refactor(core): extract monolith helpers Refs #8 * fix: address PR 27 review feedback * fix: defensively copy pending edit images * fix: preserve reasoning summary field * test: cover api conversation edge cases * docs: clarify reasoning summary fallback
1 parent 7de61e6 commit 7245680

6 files changed

Lines changed: 487 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: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2+
3+
import { prepareApiConversationMessage } from "../apiConversationHistory.js"
4+
5+
describe("prepareApiConversationMessage", () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers()
8+
vi.setSystemTime(new Date("2026-01-02T03:04:05.000Z"))
9+
})
10+
11+
afterEach(() => {
12+
vi.useRealTimers()
13+
})
14+
15+
it("prepends Anthropic thinking blocks with thought signatures", () => {
16+
const result = prepareApiConversationMessage({
17+
message: { role: "assistant", content: "answer" },
18+
reasoning: "private reasoning",
19+
api: {
20+
getResponseId: () => "response-1",
21+
getThoughtSignature: () => "signature-1",
22+
} as any,
23+
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" } as any,
24+
apiConversationHistory: [],
25+
}) as any
26+
27+
expect(result).toMatchObject({
28+
role: "assistant",
29+
id: "response-1",
30+
ts: Date.now(),
31+
})
32+
expect(result.content).toEqual([
33+
{ type: "thinking", thinking: "private reasoning", signature: "signature-1" },
34+
{ type: "text", text: "answer" },
35+
])
36+
})
37+
38+
it("preserves non-Anthropic reasoning block shape", () => {
39+
const result = prepareApiConversationMessage({
40+
message: { role: "assistant", content: "answer" },
41+
reasoning: "visible reasoning",
42+
api: {} as any,
43+
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
44+
apiConversationHistory: [],
45+
}) as any
46+
47+
expect(result.content).toEqual([
48+
{ type: "reasoning", text: "visible reasoning", summary: [] },
49+
{ type: "text", text: "answer" },
50+
])
51+
})
52+
53+
it("falls back to generic reasoning blocks for Anthropic messages without thought signatures", () => {
54+
const result = prepareApiConversationMessage({
55+
message: { role: "assistant", content: "answer" },
56+
reasoning: "private reasoning",
57+
api: {} as any,
58+
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" } as any,
59+
apiConversationHistory: [],
60+
}) as any
61+
62+
expect(result.content).toEqual([
63+
{ type: "reasoning", text: "private reasoning", summary: [] },
64+
{ type: "text", text: "answer" },
65+
])
66+
expect(result.ts).toBe(Date.now())
67+
})
68+
69+
it("preserves encrypted reasoning content", () => {
70+
const result = prepareApiConversationMessage({
71+
message: { role: "assistant", content: [{ type: "text", text: "answer" }] },
72+
api: {
73+
getEncryptedContent: () => ({ encrypted_content: "encrypted", id: "reasoning-1" }),
74+
} as any,
75+
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
76+
apiConversationHistory: [],
77+
}) as any
78+
79+
expect(result.content).toEqual([
80+
{ type: "reasoning", summary: [], encrypted_content: "encrypted", id: "reasoning-1" },
81+
{ type: "text", text: "answer" },
82+
])
83+
})
84+
85+
it("appends thought signatures for non-Anthropic protocols", () => {
86+
const result = prepareApiConversationMessage({
87+
message: { role: "assistant", content: "answer" },
88+
api: {
89+
getThoughtSignature: () => "signature-1",
90+
getReasoningDetails: () => [{ type: "reasoning", text: "detail" }],
91+
} as any,
92+
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
93+
apiConversationHistory: [],
94+
}) as any
95+
96+
expect(result.reasoning_details).toEqual([{ type: "reasoning", text: "detail" }])
97+
expect(result.content).toEqual([
98+
{ type: "text", text: "answer" },
99+
{ type: "thoughtSignature", thoughtSignature: "signature-1" },
100+
])
101+
})
102+
103+
it("validates user tool_result blocks against the last effective assistant message", () => {
104+
const result = prepareApiConversationMessage({
105+
message: {
106+
role: "user",
107+
content: [{ type: "tool_result", tool_use_id: "wrong-id", content: "done" }],
108+
},
109+
api: {} as any,
110+
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
111+
apiConversationHistory: [
112+
{
113+
role: "assistant",
114+
content: [{ type: "tool_use", id: "tool-1", name: "read_file", input: {} }],
115+
} as any,
116+
],
117+
}) as any
118+
119+
expect(result.content).toEqual([{ type: "tool_result", tool_use_id: "tool-1", content: "done" }])
120+
expect(result.ts).toBe(Date.now())
121+
})
122+
123+
it("converts user tool_result blocks to text when the last effective message is not assistant", () => {
124+
const result = prepareApiConversationMessage({
125+
message: {
126+
role: "user",
127+
content: [
128+
{ type: "tool_result", tool_use_id: "tool-1", content: "done" },
129+
{ type: "text", text: "next step" },
130+
],
131+
},
132+
api: {} as any,
133+
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" } as any,
134+
apiConversationHistory: [{ role: "user", content: "previous user message" } as any],
135+
}) as any
136+
137+
expect(result.content).toEqual([
138+
{ type: "text", text: "Tool result:\ndone" },
139+
{ type: "text", text: "next step" },
140+
])
141+
expect(result.ts).toBe(Date.now())
142+
})
143+
})

0 commit comments

Comments
 (0)