diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 9249af41a..c28abfcad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5023,6 +5023,54 @@ describe("BackgroundManager.handleEvent - session.error", () => { manager.shutdown() }) + test("completes task when session.idle carries session id in info", async () => { + //#given + const sessionID = "ses-info-idle-completes-task" + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + messages: async () => ({ + data: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "done" }], + }, + ], + }), + todo: async () => ({ data: [] }), + }, + } + + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + stubNotifyParentSession(manager) + + const task = createMockTask({ + id: "task-info-idle-completes", + sessionId: sessionID, + parentSessionId: "parent-session", + parentMessageId: "msg-info-idle", + description: "task completed by nested idle event", + agent: "explore", + status: "running", + startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)), + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.idle", + properties: { info: { id: sessionID } }, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + //#then + expect(task.status).toBe("completed") + + manager.shutdown() + }) + test("completes task on session.status idle after todo-continuation finishes", async () => { //#given const sessionID = "ses-status-idle-after-todo-continuation" @@ -5747,6 +5795,54 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => { expect(task.progress!.toolCalls).toBe(2) }) + test("should update lastUpdate when legacy message.part.updated only has part session id", () => { + //#given - a running task with stale lastUpdate + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + + const oldUpdate = new Date(Date.now() - 300_000) + const task: BackgroundTask = { + id: "task-part-only-1", + sessionId: "session-part-only-1", + parentSessionId: "parent-1", + parentMessageId: "msg-1", + description: "Legacy part-only task", + prompt: "Keep working", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 600_000), + progress: { + toolCalls: 0, + lastUpdate: oldUpdate, + }, + } + getTaskMap(manager).set(task.id, task) + + //#when - a legacy message.part.updated event arrives without top-level sessionID + manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "session-part-only-1", + type: "text", + text: "still working", + }, + }, + }) + + //#then - lastUpdate should be refreshed, toolCalls should remain 0 + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime()) + expect(task.progress!.toolCalls).toBe(0) + }) + test("should update lastUpdate on thinking-type message.part.updated event", () => { //#given - a running task with stale lastUpdate const client = { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 3ec161b0e..fdfa5849d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -18,6 +18,7 @@ import { resolveInheritedPromptTools, createInternalAgentTextPart, } from "../../shared" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" @@ -118,7 +119,7 @@ interface MessagePartInfo { interface EventProperties { sessionID?: string - info?: { id?: string } + info?: { id?: string; sessionID?: string } [key: string]: unknown } @@ -1260,8 +1261,9 @@ The fallback retry session is now created and can be inspected directly. this.observedIncompleteTodosBySession.delete(sessionID) } - private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean { - if (!partInfo?.sessionID) return false + private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean { + if (!partInfo) return false + if (!partInfo.sessionID && !sessionID) return false if (partInfo.tool) return true if (partInfo.type === "tool" || partInfo.type === "tool_result") return true if (partInfo.type === "text" || partInfo.type === "reasoning") return true @@ -1279,9 +1281,9 @@ The fallback retry session is now created and can be inspected directly. const info = props?.info if (!info || typeof info !== "object") return - const sessionID = (info as Record)["sessionID"] + const sessionID = resolveMessageEventSessionID(props) const role = (info as Record)["role"] - if (typeof sessionID !== "string") return + if (!sessionID) return if (role === "tool") { this.markSessionOutputObserved(sessionID) @@ -1312,7 +1314,7 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "message.part.updated" || event.type === "message.part.delta") { const partInfo = resolveMessagePartInfo(props) - const sessionID = partInfo?.sessionID + const sessionID = resolveMessageEventSessionID(props) if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) @@ -1320,7 +1322,7 @@ The fallback retry session is now created and can be inspected directly. const { task } = resolved - if (this.hasOutputSignalFromPart(partInfo)) { + if (this.hasOutputSignalFromPart(partInfo, sessionID)) { this.markSessionOutputObserved(sessionID) } @@ -1404,7 +1406,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "todo.updated") { - const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) const todos = Array.isArray(props?.todos) ? props.todos : undefined if (!sessionID || !todos) return @@ -1419,7 +1421,7 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "session.idle") { if (!props || typeof props !== "object") return - const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { log("[background-agent] Failed to flush pending parent wake:", { sessionID, error }) @@ -1440,7 +1442,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.error") { - const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) @@ -1469,9 +1471,8 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.deleted") { - const info = props?.info - if (!info || typeof info.id !== "string") return - const sessionID = info.id + const sessionID = resolveSessionEventID(props) + if (!sessionID) return this.clearSessionOutputObserved(sessionID) this.clearSessionTodoObservation(sessionID) @@ -1529,7 +1530,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.status") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const status = props?.status as { type?: string; message?: string } | undefined if (!sessionID || !status?.type) return diff --git a/src/features/background-agent/session-idle-event-handler.ts b/src/features/background-agent/session-idle-event-handler.ts index c3396f75d..e004b128d 100644 --- a/src/features/background-agent/session-idle-event-handler.ts +++ b/src/features/background-agent/session-idle-event-handler.ts @@ -1,12 +1,8 @@ import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { MIN_IDLE_TIME_MS } from "./constants" import type { BackgroundTask } from "./types" -function getString(obj: Record, key: string): string | undefined { - const value = obj[key] - return typeof value === "string" ? value : undefined -} - export function handleSessionIdleBackgroundEvent(args: { properties: Record findBySession: (sessionID: string) => BackgroundTask | undefined @@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: { emitIdleEvent, } = args - const sessionID = getString(properties, "sessionID") + const sessionID = resolveSessionEventID(properties) if (!sessionID) return const task = findBySession(sessionID) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 212985cbd..dda4c0468 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig, WindowState } from "./types" import * as sharedModule from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, @@ -1098,9 +1099,9 @@ export class TmuxSessionManager { if (event.type !== "session.created") return const info = event.properties?.info - if (!info?.id || !info?.parentID) return + const sessionId = resolveSessionEventID(event.properties) + if (!sessionId || !info?.parentID) return - const sessionId = info.id const title = info.title ?? "Subagent" if (!this.sourcePaneId) { diff --git a/src/features/tmux-subagent/polling-manager-event-session-id.test.ts b/src/features/tmux-subagent/polling-manager-event-session-id.test.ts new file mode 100644 index 000000000..486862f8f --- /dev/null +++ b/src/features/tmux-subagent/polling-manager-event-session-id.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" + +import { TmuxPollingManager } from "./polling-manager" +import type { TrackedSession } from "./types" + +describe("TmuxPollingManager event session ids", () => { + test("#given legacy message.part.updated properties #when handling activity #then part session id increments activity version", () => { + const sessions = new Map() + sessions.set("ses-part-only", { + sessionId: "ses-part-only", + paneId: "%1", + description: "test", + createdAt: new Date(), + lastSeenAt: new Date(), + closePending: false, + closeRetryCount: 0, + activityVersion: 0, + }) + + const client = { + session: { + status: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + const manager = new TmuxPollingManager(client as never, sessions, async () => {}) + + manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses-part-only", + type: "text", + text: "working", + }, + }, + }) + + expect(sessions.get("ses-part-only")?.activityVersion).toBe(1) + }) +}) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 74e017c5e..c34e126aa 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -7,6 +7,7 @@ import { import type { TrackedSession } from "./types" import { log } from "../../shared" import { normalizeSDKResponse } from "../../shared" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" const MIN_STABILITY_TIME_MS = 10 * 1000 const STABLE_POLLS_REQUIRED = 3 @@ -170,10 +171,7 @@ export class TmuxPollingManager { if (!properties) return undefined if (event.type === "message.updated") { - const info = properties.info - if (!info || typeof info !== "object") return undefined - const sessionId = (info as { sessionID?: unknown }).sessionID - return typeof sessionId === "string" ? sessionId : undefined + return resolveMessageEventSessionID(properties) } if ( @@ -182,8 +180,7 @@ export class TmuxPollingManager { || event.type === "message.part.removed" || event.type === "message.removed" ) { - const sessionId = properties.sessionID - return typeof sessionId === "string" ? sessionId : undefined + return resolveMessageEventSessionID(properties) } return undefined diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts index fa6fcc24e..a80cdd546 100644 --- a/src/features/tmux-subagent/session-created-handler.ts +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { CapacityConfig, TrackedSession } from "./types" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, type SessionMapping } from "./decision-engine" import { executeActions } from "./action-executor" @@ -44,9 +45,9 @@ export async function handleSessionCreated( if (event.type !== "session.created") return const info = event.properties?.info - if (!info?.id || !info?.parentID) return + const sessionId = resolveSessionEventID(event.properties) + if (!sessionId || !info?.parentID) return - const sessionId = info.id const title = info.title ?? "Subagent" if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) { diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index ef2a7b3d9..d5ea75ecd 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -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 | 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); } diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts index 0a80d63bc..862c3b8a3 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts @@ -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 | 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 | 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 diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index e9358b7ad..2ad001df4 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -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 | 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 | 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 | 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) { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 9e5692e44..f3f9f5602 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -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") diff --git a/src/hooks/auto-slash-command/hook.ts b/src/hooks/auto-slash-command/hook.ts index 73083f20d..1803394d1 100644 --- a/src/hooks/auto-slash-command/hook.ts +++ b/src/hooks/auto-slash-command/hook.ts @@ -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 { } 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 } diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index a89d182b0..f940e6288 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -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 | 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) } diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts index ca4556dda..534b39e34 100644 --- a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -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 | 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 | 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 | undefined - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const claudeConfig = await loadClaudeHooksConfig() diff --git a/src/hooks/compaction-context-injector/hook.ts b/src/hooks/compaction-context-injector/hook.ts index 29e253518..9251d92c0 100644 --- a/src/hooks/compaction-context-injector/hook.ts +++ b/src/hooks/compaction-context-injector/hook.ts @@ -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 diff --git a/src/hooks/compaction-context-injector/session-id.ts b/src/hooks/compaction-context-injector/session-id.ts index 29de9c5c4..e9ff8a90d 100644 --- a/src/hooks/compaction-context-injector/session-id.ts +++ b/src/hooks/compaction-context-injector/session-id.ts @@ -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 | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + return resolveSessionEventID(props) } diff --git a/src/hooks/compaction-todo-preserver/hook.ts b/src/hooks/compaction-todo-preserver/hook.ts index 2bfe20cac..63e744c9c 100644 --- a/src/hooks/compaction-todo-preserver/hook.ts +++ b/src/hooks/compaction-todo-preserver/hook.ts @@ -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 { } function resolveSessionID(props?: Record): string | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + return resolveSessionEventID(props) } export interface CompactionTodoPreserver { diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index acdeee1c4..30e241c3f 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -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 | 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, diff --git a/src/hooks/directory-agents-injector/hook.ts b/src/hooks/directory-agents-injector/hook.ts index c1f62208f..58279fd3d 100644 --- a/src/hooks/directory-agents-injector/hook.ts +++ b/src/hooks/directory-agents-injector/hook.ts @@ -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 | 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); diff --git a/src/hooks/directory-readme-injector/hook.ts b/src/hooks/directory-readme-injector/hook.ts index 0fdab1858..a843131b8 100644 --- a/src/hooks/directory-readme-injector/hook.ts +++ b/src/hooks/directory-readme-injector/hook.ts @@ -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 | 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); diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts index 4128f903c..86aa2f101 100644 --- a/src/hooks/interactive-bash-session/hook.ts +++ b/src/hooks/interactive-bash-session/hook.ts @@ -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 | 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); diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index b1e46b689..28da76192 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -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 | 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, }) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 4bda8eb0b..172363473 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -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 }) diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts index 5c5f63ca7..66c51f96f 100644 --- a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -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({ diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 87c0f9435..c64cf2ef0 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -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 | undefined): string | undefined { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID - return typeof sessionID === "string" ? sessionID : undefined -} - function getRuntimeRetryActivitySessionID( eventType: string, props: Record | undefined, @@ -49,20 +44,19 @@ function getRuntimeRetryActivitySessionID( if (eventType === "message.updated") { const info = props?.info as Record | 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) diff --git a/src/hooks/ralph-loop/session-event-handler.ts b/src/hooks/ralph-loop/session-event-handler.ts index 193a89de9..ee85e7f3a 100644 --- a/src/hooks/ralph-loop/session-event-handler.ts +++ b/src/hooks/ralph-loop/session-event-handler.ts @@ -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 | 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 | 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") { diff --git a/src/hooks/rules-injector/hook.ts b/src/hooks/rules-injector/hook.ts index b3a09ca12..3b62d5e01 100644 --- a/src/hooks/rules-injector/hook.ts +++ b/src/hooks/rules-injector/hook.ts @@ -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 | 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); } diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index 5776041ed..d874c5c15 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -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 | 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 | 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 | 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 | undefined) => { const info = props?.info as Record | 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 | 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 | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error const agent = props?.agent as string | undefined diff --git a/src/hooks/runtime-fallback/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index 0c07405a0..b054ca5d8 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -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 | undefined) => { const info = props?.info as Record | 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 diff --git a/src/hooks/runtime-fallback/session-status-handler.ts b/src/hooks/runtime-fallback/session-status-handler.ts index 1fff2a6ff..c356f2f6f 100644 --- a/src/hooks/runtime-fallback/session-status-handler.ts +++ b/src/hooks/runtime-fallback/session-status-handler.ts @@ -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 | 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 diff --git a/src/hooks/session-notification-event-properties.ts b/src/hooks/session-notification-event-properties.ts index b51edf81b..e3e4205dc 100644 --- a/src/hooks/session-notification-event-properties.ts +++ b/src/hooks/session-notification-event-properties.ts @@ -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 } diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index ceb2c981b..31a133f9a 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -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" diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index c9178f0df..e65f358cd 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -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 | undefined if (event.type === "session.created") { - const info = props?.info as Record | 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 | 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) } } } diff --git a/src/hooks/stop-continuation-guard/hook.ts b/src/hooks/stop-continuation-guard/hook.ts index ce3ba7c0b..94c78893e 100644 --- a/src/hooks/stop-continuation-guard/hook.ts +++ b/src/hooks/stop-continuation-guard/hook.ts @@ -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 | 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 }) } } } diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts index 4e795018d..9a09daeaa 100644 --- a/src/hooks/task-reminder/hook.ts +++ b/src/hooks/task-reminder/hook.ts @@ -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) }, diff --git a/src/hooks/team-session-events/team-idle-wake-hint.ts b/src/hooks/team-session-events/team-idle-wake-hint.ts index 0bb99ab23..8d2c8a5d9 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.ts @@ -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 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 { diff --git a/src/hooks/team-session-events/team-lead-orphan-handler.ts b/src/hooks/team-session-events/team-lead-orphan-handler.ts index e7b70b3d5..07349ab32 100644 --- a/src/hooks/team-session-events/team-lead-orphan-handler.ts +++ b/src/hooks/team-session-events/team-lead-orphan-handler.ts @@ -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 function getDeletedSessionID(properties: unknown): string | undefined { - const record = properties as { info?: { id?: string } } | undefined - return record?.info?.id + return resolveSessionEventID(properties) } async function findLeadTeamRunId( diff --git a/src/hooks/team-session-events/team-member-error-handler.ts b/src/hooks/team-session-events/team-member-error-handler.ts index 89dc67601..c7f669c8c 100644 --- a/src/hooks/team-session-events/team-member-error-handler.ts +++ b/src/hooks/team-session-events/team-member-error-handler.ts @@ -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 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 { diff --git a/src/hooks/team-session-events/team-member-status-handler.ts b/src/hooks/team-session-events/team-member-status-handler.ts index 3e31173c7..011b9769c 100644 --- a/src/hooks/team-session-events/team-member-status-handler.ts +++ b/src/hooks/team-session-events/team-member-status-handler.ts @@ -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 = new Set(["run const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet = 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( diff --git a/src/hooks/think-mode/hook.ts b/src/hooks/think-mode/hook.ts index 8f2442382..732096b28 100644 --- a/src/hooks/think-mode/hook.ts +++ b/src/hooks/think-mode/hook.ts @@ -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() @@ -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) } } }, diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index 27096056f..0cc85fba8 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -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 | 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) } } diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index a88da8773..dc21fa8f1 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -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 | 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 | 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 } diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 03b56ba4e..bb1a56d39 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -12,6 +12,7 @@ import { } from "./constants" type TimerCallback = (...args: any[]) => void +type FakeTimerID = number & ReturnType & ReturnType interface FakeTimers { advanceBy: (ms: number, advanceClock?: boolean) => Promise @@ -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 + 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 + return schedule(callback, interval, interval, args) }) as typeof setInterval globalThis.clearTimeout = ((id?: Parameters[0]) => { @@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => { } } + type MockPluginInput = Parameters[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 } diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index c5168759d..6f43a7840 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -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 | 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 | 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() diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts index 19b72eb73..96da6fa02 100644 --- a/src/hooks/write-existing-file-guard/hook.ts +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -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 } diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index a400118e6..a89f59ce9 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -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({ diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 2b94fcadf..ade05ea93 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -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 | 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 | 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 }); } diff --git a/src/plugin/session-status-normalizer.ts b/src/plugin/session-status-normalizer.ts index e02377d3c..6089bf5c8 100644 --- a/src/plugin/session-status-normalizer.ts +++ b/src/plugin/session-status-normalizer.ts @@ -1,3 +1,5 @@ +import { resolveSessionEventID } from "../shared/event-session-id" + type EventInput = { event: { type: string; properties?: Record } } 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 { diff --git a/src/shared/event-session-id.test.ts b/src/shared/event-session-id.test.ts new file mode 100644 index 000000000..a1fa9c220 --- /dev/null +++ b/src/shared/event-session-id.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" + +import { resolveMessageEventSessionID, resolveSessionEventID } from "./event-session-id" + +describe("event session id resolvers", () => { + test("#given legacy message.part.updated properties #when resolving message session id #then part.sessionID is used", () => { + const sessionID = resolveMessageEventSessionID({ + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses-part-only", + type: "text", + text: "working", + }, + }) + + expect(sessionID).toBe("ses-part-only") + }) + + test("#given message.updated info id #when resolving message session id #then message id is not mistaken for session id", () => { + const sessionID = resolveMessageEventSessionID({ + info: { + id: "msg-not-session", + role: "assistant", + }, + }) + + expect(sessionID).toBeUndefined() + }) + + test("#given legacy session lifecycle properties #when resolving session id #then info.id is used", () => { + const sessionID = resolveSessionEventID({ + info: { + id: "ses-legacy-info-id", + }, + }) + + expect(sessionID).toBe("ses-legacy-info-id") + }) +}) diff --git a/src/shared/event-session-id.ts b/src/shared/event-session-id.ts new file mode 100644 index 000000000..734ff5d6a --- /dev/null +++ b/src/shared/event-session-id.ts @@ -0,0 +1,23 @@ +import { isRecord } from "./record-type-guard" + +function getStringField(record: Record | undefined, key: string): string | undefined { + const value = record?.[key] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +export function resolveSessionEventID(properties: unknown): string | undefined { + const props = isRecord(properties) ? properties : undefined + const info = isRecord(props?.info) ? props.info : undefined + return getStringField(props, "sessionID") + ?? getStringField(info, "sessionID") + ?? getStringField(info, "id") +} + +export function resolveMessageEventSessionID(properties: unknown): string | undefined { + const props = isRecord(properties) ? properties : undefined + const info = isRecord(props?.info) ? props.info : undefined + const part = isRecord(props?.part) ? props.part : undefined + return getStringField(props, "sessionID") + ?? getStringField(info, "sessionID") + ?? getStringField(part, "sessionID") +} diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c33..0ea008554 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -54,6 +54,7 @@ export * from "./fallback-model-availability" export * from "./connected-providers-cache" export * from "./context-limit-resolver" export * from "./session-utils" +export * from "./event-session-id" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth"