|
1 | | -/** |
2 | | - * Session Recovery - Message State Error Recovery |
3 | | - * |
4 | | - * Handles FOUR specific scenarios: |
5 | | - * 1. tool_use block exists without tool_result |
6 | | - * - Recovery: inject tool_result with "cancelled" content |
7 | | - * |
8 | | - * 2. Thinking block order violation (first block must be thinking) |
9 | | - * - Recovery: prepend empty thinking block |
10 | | - * |
11 | | - * 3. Thinking disabled but message contains thinking blocks |
12 | | - * - Recovery: strip thinking/redacted_thinking blocks |
13 | | - * |
14 | | - * 4. Empty content message (non-empty content required) |
15 | | - * - Recovery: inject text part directly via filesystem |
16 | | - */ |
17 | | - |
18 | | -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" |
19 | | -import { join } from "node:path" |
20 | | -import { xdgData } from "xdg-basedir" |
21 | 1 | import type { PluginInput } from "@opencode-ai/plugin" |
22 | 2 | import type { createOpencodeClient } from "@opencode-ai/sdk" |
| 3 | +import { findFirstEmptyMessage, injectTextPart } from "./storage" |
| 4 | +import type { MessageData } from "./types" |
23 | 5 |
|
24 | 6 | type Client = ReturnType<typeof createOpencodeClient> |
25 | 7 |
|
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 | | - |
30 | | -type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null |
| 8 | +type RecoveryErrorType = |
| 9 | + | "tool_result_missing" |
| 10 | + | "thinking_block_order" |
| 11 | + | "thinking_disabled_violation" |
| 12 | + | "empty_content_message" |
| 13 | + | null |
31 | 14 |
|
32 | 15 | interface MessageInfo { |
33 | 16 | id?: string |
@@ -58,11 +41,6 @@ interface MessagePart { |
58 | 41 | input?: Record<string, unknown> |
59 | 42 | } |
60 | 43 |
|
61 | | -interface MessageData { |
62 | | - info?: MessageInfo |
63 | | - parts?: MessagePart[] |
64 | | -} |
65 | | - |
66 | 44 | function getErrorMessage(error: unknown): string { |
67 | 45 | if (!error) return "" |
68 | 46 | if (typeof error === "string") return error.toLowerCase() |
@@ -120,7 +98,7 @@ async function recoverToolResultMissing( |
120 | 98 | try { |
121 | 99 | await client.session.prompt({ |
122 | 100 | path: { id: sessionID }, |
123 | | - // @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it |
| 101 | + // @ts-expect-error - SDK types may not include tool_result parts |
124 | 102 | body: { parts: toolResultParts }, |
125 | 103 | }) |
126 | 104 |
|
@@ -150,26 +128,17 @@ async function recoverThinkingBlockOrder( |
150 | 128 | path: { id: messageID }, |
151 | 129 | body: { parts: patchedParts }, |
152 | 130 | }) |
153 | | - |
154 | 131 | return true |
155 | | - } catch { |
156 | | - // message.update not available |
157 | | - } |
| 132 | + } catch {} |
158 | 133 |
|
159 | 134 | try { |
160 | 135 | // @ts-expect-error - Experimental API |
161 | 136 | await client.session.patch?.({ |
162 | 137 | path: { id: sessionID }, |
163 | | - body: { |
164 | | - messageID, |
165 | | - parts: patchedParts, |
166 | | - }, |
| 138 | + body: { messageID, parts: patchedParts }, |
167 | 139 | }) |
168 | | - |
169 | 140 | return true |
170 | | - } catch { |
171 | | - // session.patch not available |
172 | | - } |
| 141 | + } catch {} |
173 | 142 |
|
174 | 143 | return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory) |
175 | 144 | } |
@@ -197,205 +166,31 @@ async function recoverThinkingDisabledViolation( |
197 | 166 | path: { id: messageID }, |
198 | 167 | body: { parts: strippedParts }, |
199 | 168 | }) |
200 | | - |
201 | 169 | return true |
202 | | - } catch { |
203 | | - // message.update not available |
204 | | - } |
| 170 | + } catch {} |
205 | 171 |
|
206 | 172 | try { |
207 | 173 | // @ts-expect-error - Experimental API |
208 | 174 | await client.session.patch?.({ |
209 | 175 | path: { id: sessionID }, |
210 | | - body: { |
211 | | - messageID, |
212 | | - parts: strippedParts, |
213 | | - }, |
| 176 | + body: { messageID, parts: strippedParts }, |
214 | 177 | }) |
215 | | - |
216 | 178 | return true |
217 | | - } catch { |
218 | | - // session.patch not available |
219 | | - } |
| 179 | + } catch {} |
220 | 180 |
|
221 | 181 | return false |
222 | 182 | } |
223 | 183 |
|
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" || p.type === "tool") return true |
348 | | - if (p.type === "tool_result") return true |
349 | | - return false |
350 | | - }) |
351 | | - |
352 | | - if (!hasContent) { |
353 | | - return msg.id |
354 | | - } |
355 | | - } |
356 | | - |
357 | | - return null |
358 | | -} |
359 | | - |
360 | | -function hasNonEmptyOutput(msg: MessageData): boolean { |
361 | | - const parts = msg.parts |
362 | | - if (!parts || parts.length === 0) return false |
363 | | - |
364 | | - return parts.some((p) => { |
365 | | - if (THINKING_TYPES.has(p.type)) return false |
366 | | - if (p.type === "step-start" || p.type === "step-finish") return false |
367 | | - if (p.type === "text" && p.text && p.text.trim()) return true |
368 | | - if ((p.type === "tool_use" || p.type === "tool") && p.id) return true |
369 | | - if (p.type === "tool_result") return true |
370 | | - return false |
371 | | - }) |
372 | | -} |
373 | | - |
374 | | -function findEmptyContentMessage(msgs: MessageData[]): MessageData | null { |
375 | | - for (let i = 0; i < msgs.length; i++) { |
376 | | - const msg = msgs[i] |
377 | | - const isLastMessage = i === msgs.length - 1 |
378 | | - const isAssistant = msg.info?.role === "assistant" |
379 | | - |
380 | | - if (isLastMessage && isAssistant) continue |
381 | | - |
382 | | - if (!hasNonEmptyOutput(msg)) { |
383 | | - return msg |
384 | | - } |
385 | | - } |
386 | | - return null |
387 | | -} |
388 | | - |
389 | 184 | async function recoverEmptyContentMessage( |
390 | 185 | _client: Client, |
391 | 186 | sessionID: string, |
392 | 187 | failedAssistantMsg: MessageData, |
393 | 188 | _directory: string |
394 | 189 | ): Promise<boolean> { |
395 | | - const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id |
| 190 | + const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id |
396 | 191 | if (!emptyMessageID) return false |
397 | 192 |
|
398 | | - return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)") |
| 193 | + return injectTextPart(sessionID, emptyMessageID, "(interrupted)") |
399 | 194 | } |
400 | 195 |
|
401 | 196 | async function fallbackRevertStrategy( |
@@ -508,16 +303,14 @@ export function createSessionRecoveryHook(ctx: PluginInput) { |
508 | 303 | tool_result_missing: "Injecting cancelled tool results...", |
509 | 304 | thinking_block_order: "Fixing message structure...", |
510 | 305 | thinking_disabled_violation: "Stripping thinking blocks...", |
511 | | - empty_content_message: "Deleting empty message...", |
| 306 | + empty_content_message: "Fixing empty message...", |
512 | 307 | } |
513 | | - const toastTitle = toastTitles[errorType] |
514 | | - const toastMessage = toastMessages[errorType] |
515 | 308 |
|
516 | 309 | await ctx.client.tui |
517 | 310 | .showToast({ |
518 | 311 | body: { |
519 | | - title: toastTitle, |
520 | | - message: toastMessage, |
| 312 | + title: toastTitles[errorType], |
| 313 | + message: toastMessages[errorType], |
521 | 314 | variant: "warning", |
522 | 315 | duration: 3000, |
523 | 316 | }, |
|
0 commit comments