Skip to content

Commit 64b3564

Browse files
committed
refactor(session-recovery): extract storage utilities to separate module
Split session-recovery.ts into modular structure: - types.ts: SDK-aligned type definitions - constants.ts: storage paths and part type sets - storage.ts: reusable read/write operations - index.ts: main recovery hook logic
1 parent 0df7e9b commit 64b3564

File tree

4 files changed

+250
-227
lines changed

4 files changed

+250
-227
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { join } from "node:path"
2+
import { xdgData } from "xdg-basedir"
3+
4+
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
5+
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
6+
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
7+
8+
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
9+
export const META_TYPES = new Set(["step-start", "step-finish"])
10+
export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"])
Lines changed: 20 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,16 @@
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"
211
import type { PluginInput } from "@opencode-ai/plugin"
222
import type { createOpencodeClient } from "@opencode-ai/sdk"
3+
import { findFirstEmptyMessage, injectTextPart } from "./storage"
4+
import type { MessageData } from "./types"
235

246
type Client = ReturnType<typeof createOpencodeClient>
257

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
3114

3215
interface MessageInfo {
3316
id?: string
@@ -58,11 +41,6 @@ interface MessagePart {
5841
input?: Record<string, unknown>
5942
}
6043

61-
interface MessageData {
62-
info?: MessageInfo
63-
parts?: MessagePart[]
64-
}
65-
6644
function getErrorMessage(error: unknown): string {
6745
if (!error) return ""
6846
if (typeof error === "string") return error.toLowerCase()
@@ -120,7 +98,7 @@ async function recoverToolResultMissing(
12098
try {
12199
await client.session.prompt({
122100
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
124102
body: { parts: toolResultParts },
125103
})
126104

@@ -150,26 +128,17 @@ async function recoverThinkingBlockOrder(
150128
path: { id: messageID },
151129
body: { parts: patchedParts },
152130
})
153-
154131
return true
155-
} catch {
156-
// message.update not available
157-
}
132+
} catch {}
158133

159134
try {
160135
// @ts-expect-error - Experimental API
161136
await client.session.patch?.({
162137
path: { id: sessionID },
163-
body: {
164-
messageID,
165-
parts: patchedParts,
166-
},
138+
body: { messageID, parts: patchedParts },
167139
})
168-
169140
return true
170-
} catch {
171-
// session.patch not available
172-
}
141+
} catch {}
173142

174143
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
175144
}
@@ -197,205 +166,31 @@ async function recoverThinkingDisabledViolation(
197166
path: { id: messageID },
198167
body: { parts: strippedParts },
199168
})
200-
201169
return true
202-
} catch {
203-
// message.update not available
204-
}
170+
} catch {}
205171

206172
try {
207173
// @ts-expect-error - Experimental API
208174
await client.session.patch?.({
209175
path: { id: sessionID },
210-
body: {
211-
messageID,
212-
parts: strippedParts,
213-
},
176+
body: { messageID, parts: strippedParts },
214177
})
215-
216178
return true
217-
} catch {
218-
// session.patch not available
219-
}
179+
} catch {}
220180

221181
return false
222182
}
223183

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-
389184
async function recoverEmptyContentMessage(
390185
_client: Client,
391186
sessionID: string,
392187
failedAssistantMsg: MessageData,
393188
_directory: string
394189
): Promise<boolean> {
395-
const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id
190+
const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id
396191
if (!emptyMessageID) return false
397192

398-
return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)")
193+
return injectTextPart(sessionID, emptyMessageID, "(interrupted)")
399194
}
400195

401196
async function fallbackRevertStrategy(
@@ -508,16 +303,14 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
508303
tool_result_missing: "Injecting cancelled tool results...",
509304
thinking_block_order: "Fixing message structure...",
510305
thinking_disabled_violation: "Stripping thinking blocks...",
511-
empty_content_message: "Deleting empty message...",
306+
empty_content_message: "Fixing empty message...",
512307
}
513-
const toastTitle = toastTitles[errorType]
514-
const toastMessage = toastMessages[errorType]
515308

516309
await ctx.client.tui
517310
.showToast({
518311
body: {
519-
title: toastTitle,
520-
message: toastMessage,
312+
title: toastTitles[errorType],
313+
message: toastMessages[errorType],
521314
variant: "warning",
522315
duration: 3000,
523316
},

0 commit comments

Comments
 (0)