Skip to content

Commit 16806da

Browse files
committed
refactor(session-recovery): process entire message history for empty/thinking block recovery
- Scan all non-final assistant messages for empty content, orphan thinking blocks, and disabled thinking - Add storage utility functions: findMessagesWithThinkingBlocks, findMessagesWithOrphanThinking, stripThinkingParts, prependThinkingPart - Fix: Previously only processed single failed message, now handles multiple broken messages in history - Improve: Use filesystem-based recovery instead of unreliable SDK APIs
1 parent c5f651c commit 16806da

File tree

2 files changed

+145
-59
lines changed

2 files changed

+145
-59
lines changed

src/hooks/session-recovery/index.ts

Lines changed: 47 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type { PluginInput } from "@opencode-ai/plugin"
22
import type { createOpencodeClient } from "@opencode-ai/sdk"
3-
import { findFirstEmptyMessage, injectTextPart } from "./storage"
3+
import {
4+
findEmptyMessages,
5+
findMessagesWithOrphanThinking,
6+
findMessagesWithThinkingBlocks,
7+
injectTextPart,
8+
prependThinkingPart,
9+
stripThinkingParts,
10+
} from "./storage"
411
import type { MessageData } from "./types"
512

613
type Client = ReturnType<typeof createOpencodeClient>
@@ -109,76 +116,46 @@ async function recoverToolResultMissing(
109116
}
110117

111118
async function recoverThinkingBlockOrder(
112-
client: Client,
119+
_client: Client,
113120
sessionID: string,
114-
failedAssistantMsg: MessageData,
115-
directory: string
121+
_failedAssistantMsg: MessageData,
122+
_directory: string
116123
): Promise<boolean> {
117-
const messageID = failedAssistantMsg.info?.id
118-
if (!messageID) {
124+
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
125+
126+
if (orphanMessages.length === 0) {
119127
return false
120128
}
121129

122-
const existingParts = failedAssistantMsg.parts || []
123-
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
124-
125-
try {
126-
// @ts-expect-error - Experimental API
127-
await client.message?.update?.({
128-
path: { id: messageID },
129-
body: { parts: patchedParts },
130-
})
131-
return true
132-
} catch {}
133-
134-
try {
135-
// @ts-expect-error - Experimental API
136-
await client.session.patch?.({
137-
path: { id: sessionID },
138-
body: { messageID, parts: patchedParts },
139-
})
140-
return true
141-
} catch {}
130+
let anySuccess = false
131+
for (const messageID of orphanMessages) {
132+
if (prependThinkingPart(sessionID, messageID)) {
133+
anySuccess = true
134+
}
135+
}
142136

143-
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
137+
return anySuccess
144138
}
145139

146140
async function recoverThinkingDisabledViolation(
147-
client: Client,
141+
_client: Client,
148142
sessionID: string,
149-
failedAssistantMsg: MessageData
143+
_failedAssistantMsg: MessageData
150144
): Promise<boolean> {
151-
const messageID = failedAssistantMsg.info?.id
152-
if (!messageID) {
153-
return false
154-
}
145+
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
155146

156-
const existingParts = failedAssistantMsg.parts || []
157-
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
158-
159-
if (strippedParts.length === 0) {
147+
if (messagesWithThinking.length === 0) {
160148
return false
161149
}
162150

163-
try {
164-
// @ts-expect-error - Experimental API
165-
await client.message?.update?.({
166-
path: { id: messageID },
167-
body: { parts: strippedParts },
168-
})
169-
return true
170-
} catch {}
171-
172-
try {
173-
// @ts-expect-error - Experimental API
174-
await client.session.patch?.({
175-
path: { id: sessionID },
176-
body: { messageID, parts: strippedParts },
177-
})
178-
return true
179-
} catch {}
151+
let anySuccess = false
152+
for (const messageID of messagesWithThinking) {
153+
if (stripThinkingParts(messageID)) {
154+
anySuccess = true
155+
}
156+
}
180157

181-
return false
158+
return anySuccess
182159
}
183160

184161
async function recoverEmptyContentMessage(
@@ -187,10 +164,22 @@ async function recoverEmptyContentMessage(
187164
failedAssistantMsg: MessageData,
188165
_directory: string
189166
): Promise<boolean> {
190-
const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id
191-
if (!emptyMessageID) return false
167+
const emptyMessageIDs = findEmptyMessages(sessionID)
168+
169+
if (emptyMessageIDs.length === 0) {
170+
const fallbackID = failedAssistantMsg.info?.id
171+
if (!fallbackID) return false
172+
return injectTextPart(sessionID, fallbackID, "(interrupted)")
173+
}
174+
175+
let anySuccess = false
176+
for (const messageID of emptyMessageIDs) {
177+
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
178+
anySuccess = true
179+
}
180+
}
192181

193-
return injectTextPart(sessionID, emptyMessageID, "(interrupted)")
182+
return anySuccess
194183
}
195184

196185
async function fallbackRevertStrategy(

src/hooks/session-recovery/storage.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
1+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
22
import { join } from "node:path"
33
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
44
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
@@ -136,3 +136,100 @@ export function findFirstEmptyMessage(sessionID: string): string | null {
136136
const emptyIds = findEmptyMessages(sessionID)
137137
return emptyIds.length > 0 ? emptyIds[0] : null
138138
}
139+
140+
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
141+
const messages = readMessages(sessionID)
142+
const result: string[] = []
143+
144+
for (let i = 0; i < messages.length; i++) {
145+
const msg = messages[i]
146+
if (msg.role !== "assistant") continue
147+
148+
const isLastMessage = i === messages.length - 1
149+
if (isLastMessage) continue
150+
151+
const parts = readParts(msg.id)
152+
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
153+
if (hasThinking) {
154+
result.push(msg.id)
155+
}
156+
}
157+
158+
return result
159+
}
160+
161+
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
162+
const messages = readMessages(sessionID)
163+
const result: string[] = []
164+
165+
for (let i = 0; i < messages.length; i++) {
166+
const msg = messages[i]
167+
if (msg.role !== "assistant") continue
168+
169+
const isLastMessage = i === messages.length - 1
170+
if (isLastMessage) continue
171+
172+
const parts = readParts(msg.id)
173+
if (parts.length === 0) continue
174+
175+
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
176+
const firstPart = sortedParts[0]
177+
178+
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
179+
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
180+
181+
if (hasThinking && !firstIsThinking) {
182+
result.push(msg.id)
183+
}
184+
}
185+
186+
return result
187+
}
188+
189+
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
190+
const partDir = join(PART_STORAGE, messageID)
191+
192+
if (!existsSync(partDir)) {
193+
mkdirSync(partDir, { recursive: true })
194+
}
195+
196+
const partId = `prt_0000000000_thinking`
197+
const part = {
198+
id: partId,
199+
sessionID,
200+
messageID,
201+
type: "thinking",
202+
thinking: "",
203+
synthetic: true,
204+
}
205+
206+
try {
207+
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
208+
return true
209+
} catch {
210+
return false
211+
}
212+
}
213+
214+
export function stripThinkingParts(messageID: string): boolean {
215+
const partDir = join(PART_STORAGE, messageID)
216+
if (!existsSync(partDir)) return false
217+
218+
let anyRemoved = false
219+
for (const file of readdirSync(partDir)) {
220+
if (!file.endsWith(".json")) continue
221+
try {
222+
const filePath = join(partDir, file)
223+
const content = readFileSync(filePath, "utf-8")
224+
const part = JSON.parse(content) as StoredPart
225+
if (THINKING_TYPES.has(part.type)) {
226+
unlinkSync(filePath)
227+
anyRemoved = true
228+
}
229+
} catch {
230+
continue
231+
}
232+
}
233+
234+
return anyRemoved
235+
}

0 commit comments

Comments
 (0)