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:
@@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state";
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
||||
import { clearSessionState } from "./state"
|
||||
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
@@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID)
|
||||
|
||||
clearSessionState(autoCompactState, sessionInfo.id)
|
||||
clearSessionState(autoCompactState, sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error })
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
|
||||
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)
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
dependencies.log("[auto-compact] message.updated with error", { sessionID, error: info.error })
|
||||
@@ -137,7 +138,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { isAbortError } from "./is-abort-error"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
@@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
|
||||
return
|
||||
@@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: {
|
||||
|
||||
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 role = info?.role as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: {
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
@@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: {
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
@@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: {
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
const deletedState = sessions.get(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
const deletedState = sessions.get(sessionID)
|
||||
if (deletedState?.pendingRetryTimer) {
|
||||
clearTimeout(deletedState.pendingRetryTimer)
|
||||
}
|
||||
sessions.delete(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
sessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
const compactedState = sessions.get(sessionID)
|
||||
if (compactedState?.pendingRetryTimer) {
|
||||
|
||||
@@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should inject continuation when idle event carries session id in info", async () => {
|
||||
// given - boulder state with incomplete plan and nested session event shape
|
||||
const planPath = join(TEST_DIR, "test-plan-info-idle.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan-info-idle",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createTestAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { info: { id: MAIN_SESSION_ID } },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should call prompt with continuation
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should settle idle before injecting boulder continuation", async () => {
|
||||
// given
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "./detector"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
@@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | null {
|
||||
if (!isRecord(properties)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = properties.info
|
||||
if (!isRecord(info)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return typeof info.id === "string" ? info.id : null
|
||||
return resolveSessionEventID(properties) ?? null
|
||||
}
|
||||
|
||||
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
|
||||
@@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string |
|
||||
"commandId",
|
||||
]
|
||||
|
||||
const recordInput = input as unknown
|
||||
const recordInput: unknown = input
|
||||
if (!isRecord(recordInput)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { buildReminderMessage } from "./formatter"
|
||||
|
||||
/**
|
||||
@@ -120,15 +121,14 @@ export function createCategorySkillReminderHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStates.delete(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
sessionStates.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
sessionStates.delete(sessionID)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript"
|
||||
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
|
||||
import type { PluginConfig } from "../types"
|
||||
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
||||
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
||||
import {
|
||||
clearAllSessionHookState,
|
||||
clearSessionHookState,
|
||||
@@ -26,7 +27,7 @@ export function createSessionEventHandler(
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
sessionErrorState.set(sessionID, {
|
||||
hasError: true,
|
||||
@@ -38,13 +39,13 @@ export function createSessionEventHandler(
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
parentSessionIdCache.delete(sessionInfo.id)
|
||||
clearTranscriptCache(sessionInfo.id)
|
||||
clearToolInputCache(sessionInfo.id)
|
||||
contextCollector?.clear(sessionInfo.id)
|
||||
clearSessionHookState(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
parentSessionIdCache.delete(sessionID)
|
||||
clearTranscriptCache(sessionID)
|
||||
clearToolInputCache(sessionID)
|
||||
contextCollector?.clear(sessionID)
|
||||
clearSessionHookState(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -54,7 +55,7 @@ export function createSessionEventHandler(
|
||||
}
|
||||
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
||||
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
|
||||
@@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: {
|
||||
sessionID?: string
|
||||
} | undefined
|
||||
|
||||
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID || info?.role !== "assistant" || !info.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const tailState = getTailState(info.sessionID)
|
||||
const tailState = getTailState(sessionID)
|
||||
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
|
||||
finalizeTrackedAssistantMessage(tailState)
|
||||
await maybeWarnAboutNoTextTail(info.sessionID)
|
||||
await maybeWarnAboutNoTextTail(sessionID)
|
||||
}
|
||||
|
||||
if (tailState.currentMessageID !== info.id) {
|
||||
@@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: {
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const messageID = props?.messageID as string | undefined
|
||||
const field = props?.field as string | undefined
|
||||
const delta = props?.delta as string | undefined
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
return resolveSessionEventID(props)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface TodoSnapshot {
|
||||
@@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise<TodoWriter | null> {
|
||||
}
|
||||
|
||||
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
return resolveSessionEventID(props)
|
||||
}
|
||||
|
||||
export interface CompactionTodoPreserver {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ContextLimitModelCacheState,
|
||||
} from "../shared/context-limit-resolver"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
@@ -86,10 +87,10 @@ export function createContextWindowMonitorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
remindedSessions.delete(sessionInfo.id)
|
||||
tokenCache.delete(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
remindedSessions.delete(sessionID)
|
||||
tokenCache.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +107,10 @@ export function createContextWindowMonitorHook(
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
if (!info.sessionID || !info.providerID || !info.tokens) return
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID || !info.providerID || !info.tokens) return
|
||||
|
||||
tokenCache.set(info.sessionID, {
|
||||
tokenCache.set(sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
import { processFilePathForAgentsInjection } from "./injector";
|
||||
import { clearInjectedPaths } from "./storage";
|
||||
|
||||
@@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
import { processFilePathForReadmeInjection } from "./injector";
|
||||
import { clearInjectedPaths } from "./storage";
|
||||
|
||||
@@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types";
|
||||
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
|
||||
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
|
||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
const sessionID = sessionInfo?.id;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
|
||||
if (sessionID) {
|
||||
const state = getOrCreateStateLocal(sessionID);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
|
||||
|
||||
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
|
||||
@@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = (props?.info as { id?: string } | undefined)?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
compactionInProgress.delete(sessionID)
|
||||
compactedSessions.delete(sessionID)
|
||||
@@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook(
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID as string | undefined)
|
||||
?? (props?.info as { id?: string } | undefined)?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
postCompactionMonitor.onSessionCompacted(sessionID)
|
||||
}
|
||||
@@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook(
|
||||
parts?: unknown
|
||||
} | undefined
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!info || info.role !== "assistant" || !info.finish || !sessionID) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
|
||||
if (info.providerID && info.tokens) {
|
||||
tokenCache.set(info.sessionID, {
|
||||
tokenCache.set(sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
}
|
||||
compactedSessions.delete(info.sessionID)
|
||||
compactedSessions.delete(sessionID)
|
||||
|
||||
await postCompactionMonitor.onAssistantMessageUpdated({
|
||||
sessionID: info.sessionID,
|
||||
sessionID,
|
||||
id: info.id,
|
||||
parts: info.parts,
|
||||
})
|
||||
|
||||
@@ -304,6 +304,25 @@ describe("ralph-loop", () => {
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should inject continuation when idle event carries session id in info", async () => {
|
||||
// given - active loop state and nested session event shape
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-info-idle", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// when - session goes idle with id under info
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { info: { id: "session-info-idle" } },
|
||||
},
|
||||
})
|
||||
|
||||
// then - continuation should be injected for that session
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-info-idle")
|
||||
expect(promptCalls[0].text).toContain("RALPH LOOP")
|
||||
})
|
||||
|
||||
test("should settle idle before injecting continuation", async () => {
|
||||
// given - active loop state with a configured idle settle delay
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
|
||||
|
||||
@@ -213,6 +213,77 @@ describe("ralph-loop non-abort error continuation", () => {
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
})
|
||||
|
||||
test("continues after retry run activity from legacy message.part.updated part session id", async () => {
|
||||
// given - an active loop retries a recoverable runtime error
|
||||
const hook = createRalphLoopHook({
|
||||
directory: testDirectory,
|
||||
project: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: "http://localhost:4096",
|
||||
$: async () => ({}),
|
||||
client: {
|
||||
session: {
|
||||
messages: async (options: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: options.path.id })
|
||||
return { data: [] }
|
||||
},
|
||||
promptAsync: async (options: {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: string; text: string }> }
|
||||
}) => {
|
||||
promptCalls.push({
|
||||
sessionID: options.path.id,
|
||||
text: options.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async () => ({}),
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
|
||||
hook.startLoop("session-123", "Keep working", {
|
||||
messageCountAtStart: 0,
|
||||
maxIterations: 5,
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "RuntimeError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when - the retried run emits legacy assistant activity before any stale idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: "session-123",
|
||||
type: "text",
|
||||
text: "working",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// then - the real idle is allowed to continue the loop
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
})
|
||||
|
||||
test("skips immediate runtime retry while background tasks are running", async () => {
|
||||
// given - an active loop owns running background work
|
||||
const hook = createRalphLoopHook({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { handleDetectedCompletion } from "./completion-handler"
|
||||
@@ -36,12 +37,6 @@ function hasRunningBackgroundTasks(
|
||||
: false
|
||||
}
|
||||
|
||||
function getInfoSessionID(props: Record<string, unknown> | undefined): string | undefined {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID
|
||||
return typeof sessionID === "string" ? sessionID : undefined
|
||||
}
|
||||
|
||||
function getRuntimeRetryActivitySessionID(
|
||||
eventType: string,
|
||||
props: Record<string, unknown> | undefined,
|
||||
@@ -49,20 +44,19 @@ function getRuntimeRetryActivitySessionID(
|
||||
if (eventType === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const role = info?.role
|
||||
return role === "assistant" ? getInfoSessionID(props) : undefined
|
||||
return role === "assistant" ? resolveMessageEventSessionID(props) : undefined
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
if (typeof props?.sessionID === "string") return props.sessionID
|
||||
return getInfoSessionID(props)
|
||||
return resolveMessageEventSessionID(props)
|
||||
}
|
||||
|
||||
if (eventType === "message.part.delta") {
|
||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
return resolveMessageEventSessionID(props)
|
||||
}
|
||||
|
||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||
return typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
return resolveMessageEventSessionID(props)
|
||||
}
|
||||
|
||||
return undefined
|
||||
@@ -198,7 +192,7 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (inFlightSessions.has(sessionID)) {
|
||||
@@ -389,7 +383,7 @@ export function createRalphLoopEventHandler(
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const error = props?.error
|
||||
if (!sessionID || isAbortError(error)) {
|
||||
handleErroredLoopSession(props, options.loopState)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import type { RalphLoopState } from "./types"
|
||||
|
||||
@@ -11,13 +12,13 @@ export function handleDeletedLoopSession(
|
||||
props: Record<string, unknown> | undefined,
|
||||
loopState: LoopStateController,
|
||||
): boolean {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (!sessionInfo?.id) return false
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return false
|
||||
|
||||
const state = loopState.getState()
|
||||
if (state?.session_id === sessionInfo.id) {
|
||||
if (state?.session_id === sessionID) {
|
||||
loopState.clear()
|
||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
|
||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID })
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -26,7 +27,7 @@ export function handleErroredLoopSession(
|
||||
props: Record<string, unknown> | undefined,
|
||||
loopState: LoopStateController,
|
||||
): boolean {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
|
||||
if (error?.name === "MessageAbortedError") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||
import { getRuleInjectionFilePath } from "./output-path";
|
||||
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
||||
import { createRuleInjectionProcessor } from "./injector";
|
||||
@@ -80,16 +81,15 @@ export function createRulesInjectorHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionState(sessionInfo.id);
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
clearSessionState(sessionID);
|
||||
}
|
||||
clearProjectRootCache();
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
const sessionID = resolveSessionEventID(props);
|
||||
if (sessionID) {
|
||||
clearSessionState(sessionID);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isAbortError } from "../../shared/is-abort-error"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { createSessionStatusHandler } from "./session-status-handler"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
|
||||
@@ -30,7 +31,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
||||
const sessionID = sessionInfo?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const model = sessionInfo?.model
|
||||
|
||||
if (sessionID && model) {
|
||||
@@ -41,8 +42,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionDeleted = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
const sessionID = sessionInfo?.id
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
|
||||
if (sessionID) {
|
||||
log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID })
|
||||
@@ -58,7 +58,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionStop = async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||
@@ -73,7 +73,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
const handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID || role !== "user") return
|
||||
|
||||
@@ -81,7 +81,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
if (cancelledSessions.has(sessionID)) {
|
||||
@@ -111,7 +111,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
}
|
||||
|
||||
const handleSessionError = async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const error = props?.error
|
||||
const agent = props?.agent as string | undefined
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||
|
||||
export { hasVisibleAssistantResponse } from "./visible-assistant-response"
|
||||
|
||||
@@ -17,7 +18,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
||||
|
||||
return async (props: Record<string, unknown> | undefined) => {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
const timeoutEnabled = config.timeout_seconds > 0
|
||||
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||
const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export function createSessionStatusHandler(
|
||||
deps: HookDeps,
|
||||
@@ -22,7 +23,7 @@ export function createSessionStatusHandler(
|
||||
} = deps
|
||||
|
||||
return async (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
|
||||
const agent = props?.agent as string | undefined
|
||||
const model = props?.model as string | undefined
|
||||
|
||||
@@ -23,6 +23,15 @@ export function getSessionID(properties: EventProperties): string | undefined {
|
||||
const infoSessionId = info?.sessionId
|
||||
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
||||
|
||||
const part = properties?.part
|
||||
if (isRecord(part)) {
|
||||
const partSessionID = part.sessionID
|
||||
if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID
|
||||
|
||||
const partSessionId = part.sessionId
|
||||
if (typeof partSessionId === "string" && partSessionId.length > 0) return partSessionId
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -375,6 +375,47 @@ describe("session-notification", () => {
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should mark session activity on message.part.updated event with part session id", async () => {
|
||||
// given - main session is set
|
||||
const mainSessionID = "main-part-activity"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 50,
|
||||
skipIfIncompleteTodos: false,
|
||||
activityGracePeriodMs: 0,
|
||||
})
|
||||
|
||||
// when - session goes idle, then streamed assistant activity fires
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID: mainSessionID,
|
||||
type: "text",
|
||||
text: "still working",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for idle delay to pass
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// then - notification should NOT be sent (streaming activity cancelled it)
|
||||
expect(notificationCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should mark session activity on tool.execute.before event", async () => {
|
||||
// given - main session is set
|
||||
const mainSessionID = "main-tool"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEventToolName, getQuestionText, getSessionID } from "./session-notif
|
||||
import { hasIncompleteTodos } from "./session-todo-status"
|
||||
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||
import { createSessionNotificationInit } from "./session-notification-init"
|
||||
import { resolveSessionEventID } from "../shared/event-session-id"
|
||||
|
||||
interface SessionNotificationConfig {
|
||||
title?: string
|
||||
@@ -98,8 +99,7 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.id as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -116,7 +116,11 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
if (
|
||||
event.type === "message.updated" ||
|
||||
event.type === "message.part.updated" ||
|
||||
event.type === "message.part.delta"
|
||||
) {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = getSessionID({ ...props, info })
|
||||
if (sessionID) scheduler.markSessionActivity(sessionID)
|
||||
@@ -165,8 +169,8 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) scheduler.deleteSession(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearContinuationMarker,
|
||||
setContinuationMarkerSource,
|
||||
} from "../../features/run-continuation-state"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const HOOK_NAME = "stop-continuation-guard"
|
||||
@@ -86,11 +87,11 @@ export function createStopContinuationGuardHook(
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clear(sessionInfo.id)
|
||||
clearContinuationMarker(ctx.directory, sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
clear(sessionID)
|
||||
clearContinuationMarker(ctx.directory, sessionID)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
const TASK_TOOLS = new Set([
|
||||
"task",
|
||||
"task_create",
|
||||
@@ -50,8 +52,7 @@ export function createTaskReminderHook(_ctx: PluginInput) {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type !== "session.deleted") return
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
const sessionId = props?.info?.id
|
||||
const sessionId = resolveSessionEventID(event.properties)
|
||||
if (!sessionId) return
|
||||
sessionCounters.delete(sessionId)
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
applyMemberSessionRouting,
|
||||
buildMemberPromptBody,
|
||||
} from "../../features/team-mode/member-session-routing"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
|
||||
@@ -35,8 +36,7 @@ export type HookImpl = (input: HookInput) => Promise<void>
|
||||
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
||||
|
||||
function getIdleSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
function buildWakeHint(unreadCount: number): string {
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { BackgroundManager } from "../../features/background-agent/manager"
|
||||
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
||||
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
function getDeletedSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { info?: { id?: string } } | undefined
|
||||
return record?.info?.id
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
async function findLeadTeamRunId(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
export type HookImpl = (input: HookInput) => Promise<void>
|
||||
|
||||
function getErroredSessionID(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import type { RuntimeStateMember } from "../../features/team-mode/types"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type HookInput = { event: { type: string; properties?: unknown } }
|
||||
@@ -13,13 +14,11 @@ const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["run
|
||||
const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
|
||||
|
||||
function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
|
||||
const record = properties as { sessionID?: string } | undefined
|
||||
return record?.sessionID
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
|
||||
const record = properties as { info?: { id?: string } } | undefined
|
||||
return record?.info?.id
|
||||
return resolveSessionEventID(properties)
|
||||
}
|
||||
|
||||
async function transitionMemberStatus(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||
import { isAlreadyHighVariant } from "./switcher"
|
||||
import type { ThinkModeState } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
const thinkModeState = new Map<string, ThinkModeState>()
|
||||
|
||||
@@ -66,9 +67,9 @@ export function createThinkModeHook() {
|
||||
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
if (props?.info?.id) {
|
||||
thinkModeState.delete(props.info.id)
|
||||
const sessionID = resolveSessionEventID(event.properties)
|
||||
if (sessionID) {
|
||||
thinkModeState.delete(sessionID)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearContinuationMarker,
|
||||
} from "../../features/run-continuation-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
|
||||
import { armCompactionGuard } from "./compaction-guard"
|
||||
@@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const error = extractSessionErrorInfo(props?.error)
|
||||
@@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
sessionStateStore.startPruneInterval()
|
||||
@@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
const compactionEpoch = armCompactionGuard(state, Date.now())
|
||||
@@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clearContinuationMarker(ctx.directory, sessionInfo.id)
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
clearContinuationMarker(ctx.directory, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
@@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: {
|
||||
|
||||
if (eventType === "message.updated") {
|
||||
const info = properties?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(properties)
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
@@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
const sessionID = typeof properties?.sessionID === "string"
|
||||
? properties.sessionID
|
||||
: undefined
|
||||
const legacyInfo = properties?.info as Record<string, unknown> | undefined
|
||||
const legacySessionID = legacyInfo?.sessionID as string | undefined
|
||||
const targetSessionID = sessionID ?? legacySessionID
|
||||
const targetSessionID = resolveMessageEventSessionID(properties)
|
||||
|
||||
if (targetSessionID) {
|
||||
const state = sessionStateStore.getExistingState(targetSessionID)
|
||||
@@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "message.part.delta") {
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(properties)
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) {
|
||||
@@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(properties)
|
||||
if (sessionID) {
|
||||
const state = sessionStateStore.getExistingState(sessionID)
|
||||
if (state) {
|
||||
@@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "session.deleted") {
|
||||
const sessionInfo = properties?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStateStore.cleanup(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
const sessionID = resolveSessionEventID(properties)
|
||||
if (sessionID) {
|
||||
sessionStateStore.cleanup(sessionID)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./constants"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
type FakeTimerID = number & ReturnType<typeof setTimeout> & ReturnType<typeof setInterval>
|
||||
|
||||
interface FakeTimers {
|
||||
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
|
||||
@@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers {
|
||||
callback,
|
||||
args,
|
||||
})
|
||||
return id
|
||||
return id as FakeTimerID
|
||||
}
|
||||
|
||||
const clear = (id: number | undefined) => {
|
||||
@@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers {
|
||||
if (normalized >= REAL_MAX_DELAY_MS) {
|
||||
return original.setTimeout(callback, delay, ...args)
|
||||
}
|
||||
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
|
||||
return schedule(callback, normalized, null, args)
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
@@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers {
|
||||
if (interval >= REAL_MAX_DELAY_MS) {
|
||||
return original.setInterval(callback, delay, ...args)
|
||||
}
|
||||
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
|
||||
return schedule(callback, interval, interval, args)
|
||||
}) as typeof setInterval
|
||||
|
||||
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
|
||||
@@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
}
|
||||
}
|
||||
|
||||
type MockPluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||
|
||||
let mockMessages: MockMessage[] = []
|
||||
|
||||
function createMockPluginInput() {
|
||||
@@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
}
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
@@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
} as BackgroundManager
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should inject continuation when idle event carries session id in info", async () => {
|
||||
fakeTimers.restore()
|
||||
// given - OpenCode session events can nest the session id under info
|
||||
const sessionID = "main-info-idle"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// when - session goes idle with the nested event shape
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { info: { id: sessionID } } },
|
||||
})
|
||||
|
||||
// then - continuation is still injected for that session
|
||||
await wait(2500)
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0].sessionID).toBe(sessionID)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should not inject when all todos are complete", async () => {
|
||||
// given - session with all todos complete
|
||||
const sessionID = "main-456"
|
||||
@@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity when message.part.updated only has part session id", async () => {
|
||||
// given - session starting countdown
|
||||
const sessionID = "main-assistant-part-only"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// when - legacy part-only sync payload reports assistant output
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-1",
|
||||
messageID: "msg-1",
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "working",
|
||||
},
|
||||
time: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity with message.part.delta payload", async () => {
|
||||
// given - session starting countdown
|
||||
const sessionID = "main-assistant-delta"
|
||||
@@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
@@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
@@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
@@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
@@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
@@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
tui: { showToast: async () => ({}) },
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
} as MockPluginInput
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
skipAgents: [],
|
||||
@@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async () => {
|
||||
const error = new Error("prompt is too long: 150000 tokens > 100000 maximum")
|
||||
;(error as any).name = "ContextLengthError"
|
||||
error.name = "ContextLengthError"
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { isAbortError } from "../../shared/is-abort-error"
|
||||
import {
|
||||
buildReminder,
|
||||
@@ -128,7 +129,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID || !isAbortError(props?.error)) return
|
||||
|
||||
cancelledSessions.add(sessionID)
|
||||
@@ -138,7 +139,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
}
|
||||
|
||||
if (event.type === "session.stop") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.add(sessionID)
|
||||
@@ -149,7 +150,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
|
||||
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 role = info?.role as string | undefined
|
||||
if (!sessionID || (role !== "user" && role !== "assistant")) return
|
||||
|
||||
@@ -158,7 +159,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.delete(sessionID)
|
||||
@@ -166,16 +167,16 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (!sessionInfo?.id) return
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.delete(sessionInfo.id)
|
||||
cancelledSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (!sessionID) return
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
|
||||
export type GuardArgs = {
|
||||
filePath?: string
|
||||
@@ -108,8 +109,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: Wri
|
||||
return
|
||||
}
|
||||
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
const sessionID = props?.info?.id
|
||||
const sessionID = resolveSessionEventID(event.properties)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user