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:
@@ -802,6 +802,56 @@ describe("createEventHandler - event forwarding", () => {
|
||||
expect(forwardedEvents[0]?.event.type).toBe("message.part.delta")
|
||||
})
|
||||
|
||||
it("forwards legacy message.part.updated activity with part-only session id to tmux session manager", async () => {
|
||||
const forwardedEvents: EventInput[] = []
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: asEventHandlerContext({}),
|
||||
pluginConfig: asPluginConfig({
|
||||
tmux: {
|
||||
enabled: true,
|
||||
layout: "main-vertical",
|
||||
main_pane_size: 60,
|
||||
main_pane_min_width: 120,
|
||||
agent_pane_min_width: 40,
|
||||
isolation: "inline",
|
||||
},
|
||||
}),
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: createEventHandlerManagers({
|
||||
skillMcpManager: {
|
||||
disconnectSession: async () => {},
|
||||
},
|
||||
tmuxSessionManager: {
|
||||
onEvent: (event: EventInput["event"]) => {
|
||||
forwardedEvents.push({ event })
|
||||
},
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
}),
|
||||
hooks: createEventHandlerHooks({}),
|
||||
})
|
||||
await eventHandler(asEventHandlerInput({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "ses_tmux_part_only",
|
||||
type: "text",
|
||||
text: "x",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
expect(forwardedEvents.length).toBe(1)
|
||||
expect(forwardedEvents[0]?.event.type).toBe("message.part.updated")
|
||||
})
|
||||
|
||||
it("does not forward tmux activity events when tmux integration is disabled", async () => {
|
||||
const forwardedEvents: EventInput[] = []
|
||||
const eventHandler = createEventHandler({
|
||||
|
||||
+44
-42
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveSessionEventID } from "../shared/event-session-id"
|
||||
|
||||
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
||||
type SessionStatus = { type: string }
|
||||
|
||||
@@ -10,7 +12,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu
|
||||
const status = props.status as SessionStatus | undefined
|
||||
if (!status || status.type !== "idle") return null
|
||||
|
||||
const sessionID = props.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return null
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user