Skip to content

Commit a287e59

Browse files
committed
feat(session-recovery): add filesystem-based empty content recovery
- Replace API-based recovery with direct JSON file editing for empty content messages - Add cross-platform storage path support via xdg-basedir (Linux/macOS/Windows) - Inject '(interrupted)' text part to fix messages with only thinking/meta blocks - Update README docs with detailed session recovery scenarios
1 parent 80fe3ae commit a287e59

File tree

6 files changed

+183
-64
lines changed

6 files changed

+183
-64
lines changed

README.ko.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
136136
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
137137
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
138138
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
139+
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
140+
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
141+
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
142+
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
143+
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
139144
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
140145

141146
### Agents

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
132132
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
133133
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
134134
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
135-
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
135+
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
136+
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
137+
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
138+
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
139+
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
136140
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
137141

138142
### Agents

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"@ast-grep/napi": "^0.40.0",
1010
"@code-yeongyu/comment-checker": "^0.4.1",
1111
"@opencode-ai/plugin": "^1.0.7",
12+
"xdg-basedir": "^5.1.0",
1213
"zod": "^4.1.8",
1314
},
1415
"devDependencies": {
@@ -102,6 +103,8 @@
102103

103104
"undici-types": ["[email protected]", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
104105

106+
"xdg-basedir": ["[email protected]", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
107+
105108
"zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
106109
}
107110
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@ast-grep/napi": "^0.40.0",
4747
"@code-yeongyu/comment-checker": "^0.4.1",
4848
"@opencode-ai/plugin": "^1.0.7",
49+
"xdg-basedir": "^5.1.0",
4950
"zod": "^4.1.8"
5051
},
5152
"devDependencies": {

src/hooks/session-recovery.ts

Lines changed: 147 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@
1212
* - Recovery: strip thinking/redacted_thinking blocks
1313
*
1414
* 4. Empty content message (non-empty content required)
15-
* - Recovery: delete the empty message via revert
15+
* - Recovery: inject text part directly via filesystem
1616
*/
1717

18+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
19+
import { join } from "node:path"
20+
import { xdgData } from "xdg-basedir"
1821
import type { PluginInput } from "@opencode-ai/plugin"
1922
import type { createOpencodeClient } from "@opencode-ai/sdk"
2023

2124
type Client = ReturnType<typeof createOpencodeClient>
2225

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+
2330
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
2431

2532
interface MessageInfo {
@@ -215,6 +222,140 @@ async function recoverThinkingDisabledViolation(
215222
}
216223

217224
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+
}
218359

219360
function hasNonEmptyOutput(msg: MessageData): boolean {
220361
const parts = msg.parts
@@ -246,65 +387,15 @@ function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
246387
}
247388

248389
async function recoverEmptyContentMessage(
249-
client: Client,
390+
_client: Client,
250391
sessionID: string,
251392
failedAssistantMsg: MessageData,
252-
directory: string
393+
_directory: string
253394
): 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
285397

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)")
308399
}
309400

310401
async function fallbackRevertStrategy(

src/tools/lsp/utils.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
Diagnostic,
1313
PrepareRenameResult,
1414
PrepareRenameDefaultBehavior,
15+
Range,
1516
WorkspaceEdit,
1617
TextEdit,
1718
CodeAction,
@@ -165,21 +166,35 @@ export function filterDiagnosticsBySeverity(
165166
}
166167

167168
export function formatPrepareRenameResult(
168-
result: PrepareRenameResult | PrepareRenameDefaultBehavior | null
169+
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
169170
): string {
170171
if (!result) return "Cannot rename at this position"
171172

173+
// Case 1: { defaultBehavior: boolean }
172174
if ("defaultBehavior" in result) {
173175
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
174176
}
175177

176-
const startLine = result.range.start.line + 1
177-
const startChar = result.range.start.character
178-
const endLine = result.range.end.line + 1
179-
const endChar = result.range.end.character
180-
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
178+
// Case 2: { range: Range, placeholder?: string }
179+
if ("range" in result && result.range) {
180+
const startLine = result.range.start.line + 1
181+
const startChar = result.range.start.character
182+
const endLine = result.range.end.line + 1
183+
const endChar = result.range.end.character
184+
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
185+
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
186+
}
187+
188+
// Case 3: Range directly (has start/end but no range property)
189+
if ("start" in result && "end" in result) {
190+
const startLine = result.start.line + 1
191+
const startChar = result.start.character
192+
const endLine = result.end.line + 1
193+
const endChar = result.end.character
194+
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
195+
}
181196

182-
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
197+
return "Cannot rename at this position"
183198
}
184199

185200
export function formatTextEdit(edit: TextEdit): string {

0 commit comments

Comments
 (0)