feat(config): add experimental config for gating unstable features (#110)
* feat(anthropic-auto-compact): add aggressive truncation and empty message recovery Add truncateUntilTargetTokens method, empty content recovery mechanism, and emptyContentAttemptBySession tracking for robust message handling. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(session-recovery): add auto-resume and recovery callbacks Implement ResumeConfig, resumeSession() method, and callback support for enhanced session recovery and resume functionality. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config): add experimental config schema for gating unstable features This adds a new 'experimental' config field to the OhMyOpenCode schema that enables fine-grained control over unstable/experimental features: - aggressive_truncation: Enables aggressive token truncation in anthropic-auto-compact hook for more aggressive token limit handling - empty_message_recovery: Enables empty message recovery mechanism in anthropic-auto-compact hook for fixing truncation-induced empty message errors - auto_resume: Enables automatic session resume after recovery in session-recovery hook for seamless recovery experience The experimental config is optional and all experimental features are disabled by default, ensuring backward compatibility while allowing early adopters to opt-in to cutting-edge features. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
replaceEmptyTextParts,
|
||||
stripThinkingParts,
|
||||
} from "./storage"
|
||||
import type { MessageData } from "./types"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
|
||||
export interface SessionRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -22,7 +27,6 @@ type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "empty_content_message"
|
||||
| null
|
||||
|
||||
interface MessageInfo {
|
||||
@@ -49,6 +53,41 @@ interface MessagePart {
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
||||
|
||||
function findLastUserMessage(messages: MessageData[]): MessageData | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info?.role === "user") {
|
||||
return messages[i]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {
|
||||
return {
|
||||
sessionID,
|
||||
agent: userMessage?.info?.agent,
|
||||
model: userMessage?.info?.model,
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: config.sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
|
||||
agent: config.agent,
|
||||
model: config.model,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error.toLowerCase()
|
||||
@@ -104,15 +143,6 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("non-empty content") ||
|
||||
message.includes("must have non-empty content") ||
|
||||
(message.includes("content") && message.includes("is empty")) ||
|
||||
(message.includes("content field") && message.includes("empty"))
|
||||
) {
|
||||
return "empty_content_message"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -286,8 +316,9 @@ export interface SessionRecoveryHook {
|
||||
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
|
||||
}
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
|
||||
export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook {
|
||||
const processingErrors = new Set<string>()
|
||||
const experimental = options?.experimental
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
|
||||
|
||||
@@ -338,13 +369,11 @@ export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
empty_content_message: "Empty Message Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
empty_content_message: "Fixing empty message...",
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
@@ -364,13 +393,21 @@ export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
||||
if (success && experimental?.auto_resume) {
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "empty_content_message") {
|
||||
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
||||
if (success && experimental?.auto_resume) {
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return success
|
||||
return success
|
||||
} catch (err) {
|
||||
console.error("[session-recovery] Recovery failed:", err)
|
||||
return false
|
||||
|
||||
@@ -69,6 +69,13 @@ export interface MessageData {
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
system?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
parts?: Array<{
|
||||
type: string
|
||||
@@ -80,3 +87,12 @@ export interface MessageData {
|
||||
callID?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ResumeConfig {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user