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
This commit is contained in:
@@ -136,6 +136,11 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
|||||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||||
|
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||||
|
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||||
|
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||||
|
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||||
|
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
|||||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||||
- **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.
|
- **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.
|
||||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||||
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
|
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||||
|
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||||
|
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||||
|
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||||
|
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -9,6 +9,7 @@
|
|||||||
"@ast-grep/napi": "^0.40.0",
|
"@ast-grep/napi": "^0.40.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||||
"@opencode-ai/plugin": "^1.0.7",
|
"@opencode-ai/plugin": "^1.0.7",
|
||||||
|
"xdg-basedir": "^5.1.0",
|
||||||
"zod": "^4.1.8",
|
"zod": "^4.1.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -102,6 +103,8 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||||
|
|
||||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@ast-grep/napi": "^0.40.0",
|
"@ast-grep/napi": "^0.40.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||||
"@opencode-ai/plugin": "^1.0.7",
|
"@opencode-ai/plugin": "^1.0.7",
|
||||||
|
"xdg-basedir": "^5.1.0",
|
||||||
"zod": "^4.1.8"
|
"zod": "^4.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,14 +12,21 @@
|
|||||||
* - Recovery: strip thinking/redacted_thinking blocks
|
* - Recovery: strip thinking/redacted_thinking blocks
|
||||||
*
|
*
|
||||||
* 4. Empty content message (non-empty content required)
|
* 4. Empty content message (non-empty content required)
|
||||||
* - Recovery: delete the empty message via revert
|
* - Recovery: inject text part directly via filesystem
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { xdgData } from "xdg-basedir"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
type Client = ReturnType<typeof createOpencodeClient>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
|
const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||||
|
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||||
|
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||||
|
|
||||||
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
|
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
|
||||||
|
|
||||||
interface MessageInfo {
|
interface MessageInfo {
|
||||||
@@ -215,6 +222,140 @@ async function recoverThinkingDisabledViolation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||||
|
const META_TYPES = new Set(["step-start", "step-finish"])
|
||||||
|
|
||||||
|
interface StoredMessageMeta {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
role: string
|
||||||
|
parentID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredPart {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
type: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePartId(): string {
|
||||||
|
const timestamp = Date.now().toString(16)
|
||||||
|
const random = Math.random().toString(36).substring(2, 10)
|
||||||
|
return `prt_${timestamp}${random}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageDir(sessionID: string): string {
|
||||||
|
const projectHash = readdirSync(MESSAGE_STORAGE).find((dir) => {
|
||||||
|
const sessionDir = join(MESSAGE_STORAGE, dir)
|
||||||
|
try {
|
||||||
|
return readdirSync(sessionDir).some((f) => f.includes(sessionID.replace("ses_", "")))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (projectHash) {
|
||||||
|
return join(MESSAGE_STORAGE, projectHash, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||||
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(sessionPath)) {
|
||||||
|
return sessionPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessagesFromStorage(sessionID: string): StoredMessageMeta[] {
|
||||||
|
const messageDir = getMessageDir(sessionID)
|
||||||
|
if (!messageDir || !existsSync(messageDir)) return []
|
||||||
|
|
||||||
|
const messages: StoredMessageMeta[] = []
|
||||||
|
for (const file of readdirSync(messageDir)) {
|
||||||
|
if (!file.endsWith(".json")) continue
|
||||||
|
try {
|
||||||
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||||
|
messages.push(JSON.parse(content))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPartsFromStorage(messageID: string): StoredPart[] {
|
||||||
|
const partDir = join(PART_STORAGE, messageID)
|
||||||
|
if (!existsSync(partDir)) return []
|
||||||
|
|
||||||
|
const parts: StoredPart[] = []
|
||||||
|
for (const file of readdirSync(partDir)) {
|
||||||
|
if (!file.endsWith(".json")) continue
|
||||||
|
try {
|
||||||
|
const content = readFileSync(join(partDir, file), "utf-8")
|
||||||
|
parts.push(JSON.parse(content))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectTextPartToStorage(sessionID: string, messageID: string, text: string): boolean {
|
||||||
|
const partDir = join(PART_STORAGE, messageID)
|
||||||
|
|
||||||
|
if (!existsSync(partDir)) {
|
||||||
|
mkdirSync(partDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const partId = generatePartId()
|
||||||
|
const part: StoredPart = {
|
||||||
|
id: partId,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEmptyContentMessageFromStorage(sessionID: string): string | null {
|
||||||
|
const messages = readMessagesFromStorage(sessionID)
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i]
|
||||||
|
if (msg.role !== "assistant") continue
|
||||||
|
|
||||||
|
const isLastMessage = i === messages.length - 1
|
||||||
|
if (isLastMessage) continue
|
||||||
|
|
||||||
|
const parts = readPartsFromStorage(msg.id)
|
||||||
|
const hasContent = parts.some((p) => {
|
||||||
|
if (THINKING_TYPES.has(p.type)) return false
|
||||||
|
if (META_TYPES.has(p.type)) return false
|
||||||
|
if (p.type === "text" && p.text?.trim()) return true
|
||||||
|
if (p.type === "tool_use") return true
|
||||||
|
if (p.type === "tool_result") return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasContent && parts.length > 0) {
|
||||||
|
return msg.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function hasNonEmptyOutput(msg: MessageData): boolean {
|
function hasNonEmptyOutput(msg: MessageData): boolean {
|
||||||
const parts = msg.parts
|
const parts = msg.parts
|
||||||
@@ -246,65 +387,15 @@ function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function recoverEmptyContentMessage(
|
async function recoverEmptyContentMessage(
|
||||||
client: Client,
|
_client: Client,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
failedAssistantMsg: MessageData,
|
failedAssistantMsg: MessageData,
|
||||||
directory: string
|
_directory: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id
|
||||||
const messagesResp = await client.session.messages({
|
if (!emptyMessageID) return false
|
||||||
path: { id: sessionID },
|
|
||||||
query: { directory },
|
|
||||||
})
|
|
||||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
|
||||||
|
|
||||||
if (!msgs || msgs.length === 0) return false
|
return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)")
|
||||||
|
|
||||||
const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg
|
|
||||||
const messageID = emptyMsg.info?.id
|
|
||||||
if (!messageID) return false
|
|
||||||
|
|
||||||
const existingParts = emptyMsg.parts || []
|
|
||||||
const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every(
|
|
||||||
(p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (hasOnlyThinkingOrMeta) {
|
|
||||||
const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }]
|
|
||||||
|
|
||||||
try {
|
|
||||||
// @ts-expect-error - Experimental API
|
|
||||||
await client.message?.update?.({
|
|
||||||
path: { id: messageID },
|
|
||||||
body: { parts: strippedParts },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
// message.update not available
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// @ts-expect-error - Experimental API
|
|
||||||
await client.session.patch?.({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: { messageID, parts: strippedParts },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
// session.patch not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const revertTargetID = emptyMsg.info?.parentID || messageID
|
|
||||||
await client.session.revert({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: { messageID: revertTargetID },
|
|
||||||
query: { directory },
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fallbackRevertStrategy(
|
async function fallbackRevertStrategy(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
Diagnostic,
|
Diagnostic,
|
||||||
PrepareRenameResult,
|
PrepareRenameResult,
|
||||||
PrepareRenameDefaultBehavior,
|
PrepareRenameDefaultBehavior,
|
||||||
|
Range,
|
||||||
WorkspaceEdit,
|
WorkspaceEdit,
|
||||||
TextEdit,
|
TextEdit,
|
||||||
CodeAction,
|
CodeAction,
|
||||||
@@ -165,23 +166,37 @@ export function filterDiagnosticsBySeverity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatPrepareRenameResult(
|
export function formatPrepareRenameResult(
|
||||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | null
|
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
|
||||||
): string {
|
): string {
|
||||||
if (!result) return "Cannot rename at this position"
|
if (!result) return "Cannot rename at this position"
|
||||||
|
|
||||||
|
// Case 1: { defaultBehavior: boolean }
|
||||||
if ("defaultBehavior" in result) {
|
if ("defaultBehavior" in result) {
|
||||||
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
|
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Case 2: { range: Range, placeholder?: string }
|
||||||
|
if ("range" in result && result.range) {
|
||||||
const startLine = result.range.start.line + 1
|
const startLine = result.range.start.line + 1
|
||||||
const startChar = result.range.start.character
|
const startChar = result.range.start.character
|
||||||
const endLine = result.range.end.line + 1
|
const endLine = result.range.end.line + 1
|
||||||
const endChar = result.range.end.character
|
const endChar = result.range.end.character
|
||||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||||
|
|
||||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Case 3: Range directly (has start/end but no range property)
|
||||||
|
if ("start" in result && "end" in result) {
|
||||||
|
const startLine = result.start.line + 1
|
||||||
|
const startChar = result.start.character
|
||||||
|
const endLine = result.end.line + 1
|
||||||
|
const endChar = result.end.character
|
||||||
|
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Cannot rename at this position"
|
||||||
|
}
|
||||||
|
|
||||||
export function formatTextEdit(edit: TextEdit): string {
|
export function formatTextEdit(edit: TextEdit): string {
|
||||||
const startLine = edit.range.start.line + 1
|
const startLine = edit.range.start.line + 1
|
||||||
const startChar = edit.range.start.character
|
const startChar = edit.range.start.character
|
||||||
|
|||||||
Reference in New Issue
Block a user