fix(plugin): normalize event session ids

Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events.

Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks.
This commit is contained in:
YeonGyu-Kim
2026-05-12 11:48:18 +09:00
parent 67f90a819e
commit 4da48555ee
51 changed files with 740 additions and 254 deletions
+44 -42
View File
@@ -47,6 +47,7 @@ import type { CreatedHooks } from "../create-hooks";
import type { Managers } from "../create-managers";
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id";
type FirstMessageVariantGate = {
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
@@ -235,15 +236,15 @@ export function createEventHandler(args: {
const getEventSessionID = (input: EventInput): string | undefined => {
const properties = input.event.properties;
if (
!properties ||
typeof properties !== "object" ||
!("sessionID" in properties) ||
typeof properties.sessionID !== "string"
) {
return undefined;
if (input.event.type.startsWith("session.")) {
return resolveSessionEventID(properties);
}
return properties.sessionID;
if (input.event.type.startsWith("message.") || input.event.type.startsWith("tool.")) {
return resolveMessageEventSessionID(properties);
}
const record: Record<string, unknown> | undefined = isRecord(properties) ? properties : undefined;
const sessionID = record?.sessionID;
return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : undefined;
};
const runEventHookSafely = async (
@@ -467,10 +468,11 @@ export function createEventHandler(args: {
if (event.type === "session.created") {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
const isSubagentSession = !!sessionInfo?.parentID || !!sessionInfo?.id && subagentSessions.has(sessionInfo.id);
const sessionID = resolveSessionEventID(props);
const isSubagentSession = !!sessionInfo?.parentID || !!sessionID && subagentSessions.has(sessionID);
if (!isSubagentSession) {
setMainSession(sessionInfo?.id);
setMainSession(sessionID);
}
firstMessageVariantGate.markSessionCreated(sessionInfo);
@@ -489,62 +491,62 @@ export function createEventHandler(args: {
// Skip subagent sessions — they are dispatched by specialized callbacks
// in create-managers.ts (async) and tool-registry.ts (sync)
if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) {
if (pluginConfig.openclaw && sessionID && !isSubagentSession) {
await dispatchOpenClawEvent({
config: pluginConfig.openclaw,
rawEvent: event.type,
context: {
sessionId: sessionInfo.id,
sessionId: sessionID,
projectPath: pluginContext.directory,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
},
});
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id === getMainSessionID()) {
const sessionID = resolveSessionEventID(props);
if (sessionID === getMainSessionID()) {
setMainSession(undefined);
}
if (sessionInfo?.id) {
const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id);
clearSessionAgent(sessionInfo.id);
lastHandledModelErrorMessageID.delete(sessionInfo.id);
lastHandledRetryStatusKey.delete(sessionInfo.id);
lastKnownModelBySession.delete(sessionInfo.id);
if (sessionID) {
const wasSyncSubagentSession = syncSubagentSessions.has(sessionID);
clearSessionAgent(sessionID);
lastHandledModelErrorMessageID.delete(sessionID);
lastHandledRetryStatusKey.delete(sessionID);
lastKnownModelBySession.delete(sessionID);
if (modelFallback) {
clearPendingModelFallback(modelFallback, sessionInfo.id);
clearSessionFallbackChain(modelFallback, sessionInfo.id);
clearPendingModelFallback(modelFallback, sessionID);
clearSessionFallbackChain(modelFallback, sessionID);
}
resetMessageCursor(sessionInfo.id);
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
clearSessionModel(sessionInfo.id);
clearSessionPromptParams(sessionInfo.id);
syncSubagentSessions.delete(sessionInfo.id);
resetMessageCursor(sessionID);
clearBackgroundOutputConsumptionsForParentSession(sessionID);
clearBackgroundOutputConsumptionsForTaskSession(sessionID);
firstMessageVariantGate.clear(sessionID);
clearSessionModel(sessionID);
clearSessionPromptParams(sessionID);
syncSubagentSessions.delete(sessionID);
if (pluginConfig.openclaw) {
await dispatchOpenClawEvent({
config: pluginConfig.openclaw,
rawEvent: event.type,
context: {
sessionId: sessionInfo.id,
sessionId: sessionID,
projectPath: pluginContext.directory,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
},
});
}
if (wasSyncSubagentSession) {
subagentSessions.delete(sessionInfo.id);
subagentSessions.delete(sessionID);
}
deleteSessionTools(sessionInfo.id);
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
deleteSessionTools(sessionID);
await managers.skillMcpManager.disconnectSession(sessionID);
await lspManager.cleanupTempDirectoryClients();
if (tmuxIntegrationEnabled) {
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
sessionID,
});
}
}
@@ -555,12 +557,12 @@ export function createEventHandler(args: {
if (event.type === "message.removed") {
const messageID = props?.messageID as string | undefined;
const sessionID = props?.sessionID as string | undefined;
const sessionID = resolveMessageEventSessionID(props);
restoreBackgroundOutputConsumption(sessionID, messageID);
}
if (event.type === "session.idle" && pluginConfig.openclaw) {
const sessionID = props?.sessionID as string | undefined;
const sessionID = resolveSessionEventID(props);
if (sessionID) {
await dispatchOpenClawEvent({
config: pluginConfig.openclaw,
@@ -582,7 +584,7 @@ export function createEventHandler(args: {
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined;
const sessionID = info?.sessionID as string | undefined;
const sessionID = resolveMessageEventSessionID(props);
const agent = info?.agent as string | undefined;
const role = info?.role as string | undefined;
if (sessionID && info?.finish === true) {
@@ -665,7 +667,7 @@ export function createEventHandler(args: {
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined;
const sessionID = resolveSessionEventID(props);
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
// Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery
@@ -733,7 +735,7 @@ export function createEventHandler(args: {
if (event.type === "session.error") {
try {
const sessionID = props?.sessionID as string | undefined;
const sessionID = resolveSessionEventID(props);
const error = props?.error;
const errorName = extractErrorName(error);
@@ -818,7 +820,7 @@ export function createEventHandler(args: {
}
}
} catch (err) {
const sessionID = props?.sessionID as string | undefined;
const sessionID = resolveSessionEventID(props);
log("[event] model-fallback error in session.error:", { sessionID, error: err });
}