|
12 | 12 | * - Recovery: strip thinking/redacted_thinking blocks |
13 | 13 | * |
14 | 14 | * 4. Empty content message (non-empty content required) |
15 | | - * - Recovery: delete the empty message via revert |
| 15 | + * - Recovery: inject text part directly via filesystem |
16 | 16 | */ |
17 | 17 |
|
| 18 | +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" |
| 19 | +import { join } from "node:path" |
| 20 | +import { xdgData } from "xdg-basedir" |
18 | 21 | import type { PluginInput } from "@opencode-ai/plugin" |
19 | 22 | import type { createOpencodeClient } from "@opencode-ai/sdk" |
20 | 23 |
|
21 | 24 | type Client = ReturnType<typeof createOpencodeClient> |
22 | 25 |
|
| 26 | +const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage") |
| 27 | +const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") |
| 28 | +const PART_STORAGE = join(OPENCODE_STORAGE, "part") |
| 29 | + |
23 | 30 | type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null |
24 | 31 |
|
25 | 32 | interface MessageInfo { |
@@ -215,6 +222,140 @@ async function recoverThinkingDisabledViolation( |
215 | 222 | } |
216 | 223 |
|
217 | 224 | const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]) |
| 225 | +const META_TYPES = new Set(["step-start", "step-finish"]) |
| 226 | + |
| 227 | +interface StoredMessageMeta { |
| 228 | + id: string |
| 229 | + sessionID: string |
| 230 | + role: string |
| 231 | + parentID?: string |
| 232 | +} |
| 233 | + |
| 234 | +interface StoredPart { |
| 235 | + id: string |
| 236 | + sessionID: string |
| 237 | + messageID: string |
| 238 | + type: string |
| 239 | + text?: string |
| 240 | +} |
| 241 | + |
| 242 | +function generatePartId(): string { |
| 243 | + const timestamp = Date.now().toString(16) |
| 244 | + const random = Math.random().toString(36).substring(2, 10) |
| 245 | + return `prt_${timestamp}${random}` |
| 246 | +} |
| 247 | + |
| 248 | +function getMessageDir(sessionID: string): string { |
| 249 | + const projectHash = readdirSync(MESSAGE_STORAGE).find((dir) => { |
| 250 | + const sessionDir = join(MESSAGE_STORAGE, dir) |
| 251 | + try { |
| 252 | + return readdirSync(sessionDir).some((f) => f.includes(sessionID.replace("ses_", ""))) |
| 253 | + } catch { |
| 254 | + return false |
| 255 | + } |
| 256 | + }) |
| 257 | + |
| 258 | + if (projectHash) { |
| 259 | + return join(MESSAGE_STORAGE, projectHash, sessionID) |
| 260 | + } |
| 261 | + |
| 262 | + for (const dir of readdirSync(MESSAGE_STORAGE)) { |
| 263 | + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) |
| 264 | + if (existsSync(sessionPath)) { |
| 265 | + return sessionPath |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + return "" |
| 270 | +} |
| 271 | + |
| 272 | +function readMessagesFromStorage(sessionID: string): StoredMessageMeta[] { |
| 273 | + const messageDir = getMessageDir(sessionID) |
| 274 | + if (!messageDir || !existsSync(messageDir)) return [] |
| 275 | + |
| 276 | + const messages: StoredMessageMeta[] = [] |
| 277 | + for (const file of readdirSync(messageDir)) { |
| 278 | + if (!file.endsWith(".json")) continue |
| 279 | + try { |
| 280 | + const content = readFileSync(join(messageDir, file), "utf-8") |
| 281 | + messages.push(JSON.parse(content)) |
| 282 | + } catch { |
| 283 | + continue |
| 284 | + } |
| 285 | + } |
| 286 | + |
| 287 | + return messages.sort((a, b) => a.id.localeCompare(b.id)) |
| 288 | +} |
| 289 | + |
| 290 | +function readPartsFromStorage(messageID: string): StoredPart[] { |
| 291 | + const partDir = join(PART_STORAGE, messageID) |
| 292 | + if (!existsSync(partDir)) return [] |
| 293 | + |
| 294 | + const parts: StoredPart[] = [] |
| 295 | + for (const file of readdirSync(partDir)) { |
| 296 | + if (!file.endsWith(".json")) continue |
| 297 | + try { |
| 298 | + const content = readFileSync(join(partDir, file), "utf-8") |
| 299 | + parts.push(JSON.parse(content)) |
| 300 | + } catch { |
| 301 | + continue |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + return parts |
| 306 | +} |
| 307 | + |
| 308 | +function injectTextPartToStorage(sessionID: string, messageID: string, text: string): boolean { |
| 309 | + const partDir = join(PART_STORAGE, messageID) |
| 310 | + |
| 311 | + if (!existsSync(partDir)) { |
| 312 | + mkdirSync(partDir, { recursive: true }) |
| 313 | + } |
| 314 | + |
| 315 | + const partId = generatePartId() |
| 316 | + const part: StoredPart = { |
| 317 | + id: partId, |
| 318 | + sessionID, |
| 319 | + messageID, |
| 320 | + type: "text", |
| 321 | + text, |
| 322 | + } |
| 323 | + |
| 324 | + try { |
| 325 | + writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) |
| 326 | + return true |
| 327 | + } catch { |
| 328 | + return false |
| 329 | + } |
| 330 | +} |
| 331 | + |
| 332 | +function findEmptyContentMessageFromStorage(sessionID: string): string | null { |
| 333 | + const messages = readMessagesFromStorage(sessionID) |
| 334 | + |
| 335 | + for (let i = 0; i < messages.length; i++) { |
| 336 | + const msg = messages[i] |
| 337 | + if (msg.role !== "assistant") continue |
| 338 | + |
| 339 | + const isLastMessage = i === messages.length - 1 |
| 340 | + if (isLastMessage) continue |
| 341 | + |
| 342 | + const parts = readPartsFromStorage(msg.id) |
| 343 | + const hasContent = parts.some((p) => { |
| 344 | + if (THINKING_TYPES.has(p.type)) return false |
| 345 | + if (META_TYPES.has(p.type)) return false |
| 346 | + if (p.type === "text" && p.text?.trim()) return true |
| 347 | + if (p.type === "tool_use") return true |
| 348 | + if (p.type === "tool_result") return true |
| 349 | + return false |
| 350 | + }) |
| 351 | + |
| 352 | + if (!hasContent && parts.length > 0) { |
| 353 | + return msg.id |
| 354 | + } |
| 355 | + } |
| 356 | + |
| 357 | + return null |
| 358 | +} |
218 | 359 |
|
219 | 360 | function hasNonEmptyOutput(msg: MessageData): boolean { |
220 | 361 | const parts = msg.parts |
@@ -246,65 +387,15 @@ function findEmptyContentMessage(msgs: MessageData[]): MessageData | null { |
246 | 387 | } |
247 | 388 |
|
248 | 389 | async function recoverEmptyContentMessage( |
249 | | - client: Client, |
| 390 | + _client: Client, |
250 | 391 | sessionID: string, |
251 | 392 | failedAssistantMsg: MessageData, |
252 | | - directory: string |
| 393 | + _directory: string |
253 | 394 | ): Promise<boolean> { |
254 | | - try { |
255 | | - const messagesResp = await client.session.messages({ |
256 | | - path: { id: sessionID }, |
257 | | - query: { directory }, |
258 | | - }) |
259 | | - const msgs = (messagesResp as { data?: MessageData[] }).data |
260 | | - |
261 | | - if (!msgs || msgs.length === 0) return false |
262 | | - |
263 | | - const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg |
264 | | - const messageID = emptyMsg.info?.id |
265 | | - if (!messageID) return false |
266 | | - |
267 | | - const existingParts = emptyMsg.parts || [] |
268 | | - const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every( |
269 | | - (p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish" |
270 | | - ) |
271 | | - |
272 | | - if (hasOnlyThinkingOrMeta) { |
273 | | - const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }] |
274 | | - |
275 | | - try { |
276 | | - // @ts-expect-error - Experimental API |
277 | | - await client.message?.update?.({ |
278 | | - path: { id: messageID }, |
279 | | - body: { parts: strippedParts }, |
280 | | - }) |
281 | | - return true |
282 | | - } catch { |
283 | | - // message.update not available |
284 | | - } |
| 395 | + const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id |
| 396 | + if (!emptyMessageID) return false |
285 | 397 |
|
286 | | - try { |
287 | | - // @ts-expect-error - Experimental API |
288 | | - await client.session.patch?.({ |
289 | | - path: { id: sessionID }, |
290 | | - body: { messageID, parts: strippedParts }, |
291 | | - }) |
292 | | - return true |
293 | | - } catch { |
294 | | - // session.patch not available |
295 | | - } |
296 | | - } |
297 | | - |
298 | | - const revertTargetID = emptyMsg.info?.parentID || messageID |
299 | | - await client.session.revert({ |
300 | | - path: { id: sessionID }, |
301 | | - body: { messageID: revertTargetID }, |
302 | | - query: { directory }, |
303 | | - }) |
304 | | - return true |
305 | | - } catch { |
306 | | - return false |
307 | | - } |
| 398 | + return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)") |
308 | 399 | } |
309 | 400 |
|
310 | 401 | async function fallbackRevertStrategy( |
|
0 commit comments