fix(session-recovery): Add placeholder message for thinking-only messages

- Add findMessagesWithThinkingOnly() to detect orphan thinking messages
- Inject [user interrupted] placeholder for thinking-only messages
- Expand index offset handling from 2 to 3 attempts for better error recovery
- Use constant PLACEHOLDER_TEXT for consistency across recovery functions
This commit is contained in:
YeonGyu-Kim
2025-12-14 22:26:58 +09:00
parent 7cb8210e65
commit 1aaa6e6ba2
2 changed files with 39 additions and 14 deletions

View File

@@ -6,6 +6,7 @@ import {
findMessageByIndexNeedingThinking, findMessageByIndexNeedingThinking,
findMessagesWithOrphanThinking, findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks, findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly,
injectTextPart, injectTextPart,
prependThinkingPart, prependThinkingPart,
stripThinkingParts, stripThinkingParts,
@@ -177,6 +178,8 @@ async function recoverThinkingDisabledViolation(
return anySuccess return anySuccess
} }
const PLACEHOLDER_TEXT = "[user interrupted]"
async function recoverEmptyContentMessage( async function recoverEmptyContentMessage(
_client: Client, _client: Client,
sessionID: string, sessionID: string,
@@ -187,23 +190,28 @@ async function recoverEmptyContentMessage(
const targetIndex = extractMessageIndex(error) const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id const failedID = failedAssistantMsg.info?.id
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)
}
if (targetIndex !== null) { if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) { if (targetMessageID) {
return injectTextPart(sessionID, targetMessageID, "(interrupted)") return injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)
} }
} }
if (failedID) { if (failedID) {
if (injectTextPart(sessionID, failedID, "(interrupted)")) { if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true return true
} }
} }
const emptyMessageIDs = findEmptyMessages(sessionID) const emptyMessageIDs = findEmptyMessages(sessionID)
let anySuccess = false let anySuccess = thinkingOnlyIDs.length > 0
for (const messageID of emptyMessageIDs) { for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) { if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true anySuccess = true
} }
} }

View File

@@ -133,20 +133,15 @@ export function findEmptyMessages(sessionID: string): string[] {
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID) const messages = readMessages(sessionID)
// Try multiple indices to handle system message offset // API index may differ from storage index due to system messages
// API includes system message at index 0, storage may not const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2]
const indicesToTry = [targetIndex, targetIndex - 1]
for (const idx of indicesToTry) { for (const idx of indicesToTry) {
if (idx < 0 || idx >= messages.length) continue if (idx < 0 || idx >= messages.length) continue
const targetMsg = messages[idx] const targetMsg = messages[idx]
// NOTE: Do NOT skip last assistant message here
// If API returned an error, this message is NOT the final assistant message
// (the API only allows empty content for the ACTUAL final assistant message)
if (!messageHasContent(targetMsg.id)) { if (!messageHasContent(targetMsg.id)) {
return targetMsg.id return targetMsg.id
} }
@@ -177,6 +172,28 @@ export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
return result return result
} }
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
const hasTextContent = parts.some(hasContent)
// Has thinking but no text content = orphan thinking
if (hasThinking && !hasTextContent) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithOrphanThinking(sessionID: string): string[] { export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID) const messages = readMessages(sessionID)
const result: string[] = [] const result: string[] = []