Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 1988e8d

Browse files
committed
fix: resolve 3 cherry-pick issues from batch-2
Issue A — Missing rooMessage.ts (blocked 31 test files): - Created src/core/task-persistence/rooMessage.ts with standalone type definitions (pre-AI-SDK compatible, no 'ai' package imports) - Added readRooMessages/saveRooMessages/detectFormat to apiMessages.ts with v2 envelope format support - Updated index.ts to export the new functions - Removed dangling exports for missing converters/anthropicToRoo and messageUtils Issue B — ClineProvider.ts syntax error (blocked type check): - Replaced entire reopenParentFromDelegation() method which had severe merge damage: duplicate code sections (steps 3-8 appeared twice), missing parentClineMessages declaration, unclosed for loop, missing outer try block - Fixed getTaskWithId() to use readRooMessages instead of raw JSON.parse to support v2 envelope format - Fixed type cast in validateAndFixToolResultIds call Issue C — gemini.spec.ts mockStreamText references (3 test failures): - Removed import of NoOutputGeneratedError from 'ai' (not in pre-AI-SDK) - Rewrote 3 tests to use generateContentStream mock pattern consistent with other tests in the file instead of AI-SDK mockStreamText - Added saveDelegationMeta mock to history-resume-delegation.spec.ts
1 parent 1044901 commit 1988e8d

6 files changed

Lines changed: 583 additions & 212 deletions

File tree

src/__tests__/history-resume-delegation.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ vi.mock("../core/task-persistence", async (importOriginal) => {
3636
readRooMessages: vi.fn().mockResolvedValue([]),
3737
saveRooMessages: vi.fn().mockResolvedValue(undefined),
3838
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
39+
saveDelegationMeta: vi.fn().mockResolvedValue(undefined),
40+
}
41+
})
42+
vi.mock("../core/task-persistence/delegationMeta", async (importOriginal) => {
43+
const actual = await importOriginal<typeof import("../core/task-persistence/delegationMeta")>()
44+
return {
45+
...actual,
46+
saveDelegationMeta: vi.fn().mockResolvedValue(undefined),
47+
readDelegationMeta: vi.fn().mockResolvedValue(null),
3948
}
4049
})
4150

src/api/providers/__tests__/gemini.spec.ts

Lines changed: 30 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
// npx vitest run src/api/providers/__tests__/gemini.spec.ts
22

3-
import { NoOutputGeneratedError } from "ai"
4-
53
const mockCaptureException = vitest.fn()
64

75
vitest.mock("@roo-code/telemetry", () => ({
@@ -106,15 +104,11 @@ describe("GeminiHandler", () => {
106104
})
107105

108106
it("should yield informative message when stream produces no text content", async () => {
109-
// Stream with only reasoning (no text-delta) simulates thinking-only response
110-
const mockFullStream = (async function* () {
111-
yield { type: "reasoning-delta", id: "1", text: "thinking..." }
112-
})()
113-
114-
mockStreamText.mockReturnValue({
115-
fullStream: mockFullStream,
116-
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }),
117-
providerMetadata: Promise.resolve({}),
107+
// Stream with only usageMetadata (no text parts) simulates thinking-only response
108+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
109+
[Symbol.asyncIterator]: async function* () {
110+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } }
111+
},
118112
})
119113

120114
const stream = handler.createMessage(systemPrompt, mockMessages)
@@ -124,7 +118,7 @@ describe("GeminiHandler", () => {
124118
chunks.push(chunk)
125119
}
126120

127-
// Should have: reasoning chunk, empty-stream informative message, usage
121+
// Should have: empty-stream informative message, usage
128122
const textChunks = chunks.filter((c) => c.type === "text")
129123
expect(textChunks).toHaveLength(1)
130124
expect(textChunks[0]).toEqual({
@@ -133,27 +127,23 @@ describe("GeminiHandler", () => {
133127
})
134128
})
135129

136-
it("should suppress NoOutputGeneratedError when no text content was yielded", async () => {
137-
// Empty stream - nothing yielded at all
138-
const mockFullStream = (async function* () {
139-
// empty stream
140-
})()
141-
142-
mockStreamText.mockReturnValue({
143-
fullStream: mockFullStream,
144-
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
145-
providerMetadata: Promise.resolve({}),
130+
it("should yield informative message when stream is completely empty", async () => {
131+
// Completely empty stream - no chunks yielded at all
132+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
133+
[Symbol.asyncIterator]: async function* () {
134+
// empty stream
135+
},
146136
})
147137

148138
const stream = handler.createMessage(systemPrompt, mockMessages)
149139
const chunks = []
150140

151-
// Should NOT throw - the error is suppressed
141+
// Should NOT throw
152142
for await (const chunk of stream) {
153143
chunks.push(chunk)
154144
}
155145

156-
// Should have the informative empty-stream message only (no usage since it errored)
146+
// Should have the informative empty-stream message
157147
const textChunks = chunks.filter((c) => c.type === "text")
158148
expect(textChunks).toHaveLength(1)
159149
expect(textChunks[0]).toMatchObject({
@@ -162,25 +152,26 @@ describe("GeminiHandler", () => {
162152
})
163153
})
164154

165-
it("should re-throw NoOutputGeneratedError when text content was yielded", async () => {
166-
// Stream yields text content but usage still throws NoOutputGeneratedError (unexpected)
167-
const mockFullStream = (async function* () {
168-
yield { type: "text-delta", text: "Hello" }
169-
})()
170-
171-
mockStreamText.mockReturnValue({
172-
fullStream: mockFullStream,
173-
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
174-
providerMetadata: Promise.resolve({}),
155+
it("should not yield empty-response message when text content was produced", async () => {
156+
// Stream yields actual text content - should NOT get the informative message
157+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
158+
[Symbol.asyncIterator]: async function* () {
159+
yield { text: "Hello" }
160+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
161+
},
175162
})
176163

177164
const stream = handler.createMessage(systemPrompt, mockMessages)
165+
const chunks = []
178166

179-
await expect(async () => {
180-
for await (const _chunk of stream) {
181-
// consume stream
182-
}
183-
}).rejects.toThrow()
167+
for await (const chunk of stream) {
168+
chunks.push(chunk)
169+
}
170+
171+
// Should have text and usage, but NOT the empty-response informative message
172+
const textChunks = chunks.filter((c) => c.type === "text")
173+
expect(textChunks).toHaveLength(1)
174+
expect(textChunks[0]).toEqual({ type: "text", text: "Hello" })
184175
})
185176

186177
it("should handle API errors", async () => {

src/core/task-persistence/apiMessages.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { fileExistsAtPath } from "../../utils/fs"
88

99
import { GlobalFileNames } from "../../shared/globalFileNames"
1010
import { getTaskDirectoryPath } from "../../utils/storage"
11+
import type { RooMessage } from "./rooMessage"
1112

1213
export type ApiMessage = Anthropic.MessageParam & {
1314
ts?: number
@@ -119,3 +120,114 @@ export async function saveApiMessages({
119120
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
120121
await safeWriteJson(filePath, messages)
121122
}
123+
124+
/**
125+
* Detect the on-disk format of persisted messages.
126+
* Returns "v2" if the data is a versioned RooMessageHistory envelope,
127+
* "legacy" otherwise.
128+
*/
129+
export function detectFormat(parsedData: unknown): "legacy" | "v2" {
130+
if (
131+
parsedData &&
132+
typeof parsedData === "object" &&
133+
"version" in parsedData &&
134+
(parsedData as { version: unknown }).version === 2 &&
135+
"messages" in parsedData &&
136+
Array.isArray((parsedData as { messages: unknown }).messages)
137+
) {
138+
return "v2"
139+
}
140+
return "legacy"
141+
}
142+
143+
/**
144+
* Read messages from disk and return them as RooMessage[].
145+
* Handles both v2 envelope format ({version: 2, messages: [...]}) and
146+
* legacy Anthropic array format.
147+
*/
148+
export async function readRooMessages({
149+
taskId,
150+
globalStoragePath,
151+
}: {
152+
taskId: string
153+
globalStoragePath: string
154+
}): Promise<RooMessage[]> {
155+
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
156+
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
157+
158+
const tryParseFile = async (targetPath: string): Promise<RooMessage[] | null> => {
159+
if (!(await fileExistsAtPath(targetPath))) {
160+
return null
161+
}
162+
163+
const fileContent = await fs.readFile(targetPath, "utf8")
164+
let parsedData: unknown
165+
166+
try {
167+
parsedData = JSON.parse(fileContent)
168+
} catch (error) {
169+
console.warn(
170+
`[readRooMessages] Error parsing file, returning empty. TaskId: ${taskId}, Path: ${targetPath}, Error: ${error}`,
171+
)
172+
return []
173+
}
174+
175+
const format = detectFormat(parsedData)
176+
177+
if (format === "v2") {
178+
return (parsedData as { version: number; messages: RooMessage[] }).messages
179+
}
180+
181+
if (!Array.isArray(parsedData)) {
182+
console.warn(
183+
`[readRooMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${targetPath}`,
184+
)
185+
return []
186+
}
187+
188+
// Legacy format: cast through as compatible
189+
return parsedData as unknown as RooMessage[]
190+
}
191+
192+
const primaryResult = await tryParseFile(filePath)
193+
if (primaryResult !== null) {
194+
return primaryResult
195+
}
196+
197+
const oldPath = path.join(taskDir, "claude_messages.json")
198+
const fallbackResult = await tryParseFile(oldPath)
199+
if (fallbackResult !== null) {
200+
return fallbackResult
201+
}
202+
203+
console.error(
204+
`[Roo-Debug] readRooMessages: API conversation history file not found for taskId: ${taskId}. Expected at: ${filePath}`,
205+
)
206+
return []
207+
}
208+
209+
/**
210+
* Save RooMessage[] to disk.
211+
* In the pre-AI-SDK codebase this is a thin wrapper around saveApiMessages
212+
* that casts through any since the on-disk format is compatible.
213+
*/
214+
export async function saveRooMessages({
215+
messages,
216+
taskId,
217+
globalStoragePath,
218+
}: {
219+
messages: RooMessage[]
220+
taskId: string
221+
globalStoragePath: string
222+
}): Promise<boolean> {
223+
try {
224+
await saveApiMessages({
225+
messages: messages as unknown as ApiMessage[],
226+
taskId,
227+
globalStoragePath,
228+
})
229+
return true
230+
} catch {
231+
return false
232+
}
233+
}

src/core/task-persistence/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
2+
export { detectFormat, readRooMessages, saveRooMessages } from "./apiMessages"
23
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
34
export { taskMetadata } from "./taskMetadata"
45
export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage"
@@ -33,6 +34,4 @@ export {
3334
getToolResultIsError,
3435
setToolResultCallId,
3536
} from "./rooMessage"
36-
export { convertAnthropicToRooMessages } from "./converters/anthropicToRoo"
37-
export { flattenModelMessagesToStringContent } from "./messageUtils"
3837
export { type DelegationMeta, readDelegationMeta, saveDelegationMeta } from "./delegationMeta"

0 commit comments

Comments
 (0)