fix(plugin): normalize event session ids

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

Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks.
This commit is contained in:
YeonGyu-Kim
2026-05-12 11:48:18 +09:00
parent 67f90a819e
commit 4da48555ee
51 changed files with 740 additions and 254 deletions
@@ -5023,6 +5023,54 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown() 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 () => { test("completes task on session.status idle after todo-continuation finishes", async () => {
//#given //#given
const sessionID = "ses-status-idle-after-todo-continuation" 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) 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", () => { test("should update lastUpdate on thinking-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate //#given - a running task with stale lastUpdate
const client = { const client = {
+15 -14
View File
@@ -18,6 +18,7 @@ import {
resolveInheritedPromptTools, resolveInheritedPromptTools,
createInternalAgentTextPart, createInternalAgentTextPart,
} from "../../shared" } from "../../shared"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { setSessionTools } from "../../shared/session-tools-store" import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -118,7 +119,7 @@ interface MessagePartInfo {
interface EventProperties { interface EventProperties {
sessionID?: string sessionID?: string
info?: { id?: string } info?: { id?: string; sessionID?: string }
[key: string]: unknown [key: string]: unknown
} }
@@ -1260,8 +1261,9 @@ The fallback retry session is now created and can be inspected directly.
this.observedIncompleteTodosBySession.delete(sessionID) this.observedIncompleteTodosBySession.delete(sessionID)
} }
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean { private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
if (!partInfo?.sessionID) return false if (!partInfo) return false
if (!partInfo.sessionID && !sessionID) return false
if (partInfo.tool) return true if (partInfo.tool) return true
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
if (partInfo.type === "text" || partInfo.type === "reasoning") 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 const info = props?.info
if (!info || typeof info !== "object") return if (!info || typeof info !== "object") return
const sessionID = (info as Record<string, unknown>)["sessionID"] const sessionID = resolveMessageEventSessionID(props)
const role = (info as Record<string, unknown>)["role"] const role = (info as Record<string, unknown>)["role"]
if (typeof sessionID !== "string") return if (!sessionID) return
if (role === "tool") { if (role === "tool") {
this.markSessionOutputObserved(sessionID) 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") { if (event.type === "message.part.updated" || event.type === "message.part.delta") {
const partInfo = resolveMessagePartInfo(props) const partInfo = resolveMessagePartInfo(props)
const sessionID = partInfo?.sessionID const sessionID = resolveMessageEventSessionID(props)
if (!sessionID) return if (!sessionID) return
const resolved = this.resolveTaskAttemptBySession(sessionID) const resolved = this.resolveTaskAttemptBySession(sessionID)
@@ -1320,7 +1322,7 @@ The fallback retry session is now created and can be inspected directly.
const { task } = resolved const { task } = resolved
if (this.hasOutputSignalFromPart(partInfo)) { if (this.hasOutputSignalFromPart(partInfo, sessionID)) {
this.markSessionOutputObserved(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") { 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 const todos = Array.isArray(props?.todos) ? props.todos : undefined
if (!sessionID || !todos) return 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 (event.type === "session.idle") {
if (!props || typeof props !== "object") return if (!props || typeof props !== "object") return
const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined const sessionID = resolveSessionEventID(props)
if (sessionID) { if (sessionID) {
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to flush pending parent wake:", { sessionID, 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") { if (event.type === "session.error") {
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const resolved = this.resolveTaskAttemptBySession(sessionID) 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") { if (event.type === "session.deleted") {
const info = props?.info const sessionID = resolveSessionEventID(props)
if (!info || typeof info.id !== "string") return if (!sessionID) return
const sessionID = info.id
this.clearSessionOutputObserved(sessionID) this.clearSessionOutputObserved(sessionID)
this.clearSessionTodoObservation(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") { 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 const status = props?.status as { type?: string; message?: string } | undefined
if (!sessionID || !status?.type) return if (!sessionID || !status?.type) return
@@ -1,12 +1,8 @@
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { MIN_IDLE_TIME_MS } from "./constants" import { MIN_IDLE_TIME_MS } from "./constants"
import type { BackgroundTask } from "./types" import type { BackgroundTask } from "./types"
function getString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key]
return typeof value === "string" ? value : undefined
}
export function handleSessionIdleBackgroundEvent(args: { export function handleSessionIdleBackgroundEvent(args: {
properties: Record<string, unknown> properties: Record<string, unknown>
findBySession: (sessionID: string) => BackgroundTask | undefined findBySession: (sessionID: string) => BackgroundTask | undefined
@@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: {
emitIdleEvent, emitIdleEvent,
} = args } = args
const sessionID = getString(properties, "sessionID") const sessionID = resolveSessionEventID(properties)
if (!sessionID) return if (!sessionID) return
const task = findBySession(sessionID) const task = findBySession(sessionID)
+3 -2
View File
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema" import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig, WindowState } from "./types" import type { TrackedSession, CapacityConfig, WindowState } from "./types"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { import {
isInsideTmux as defaultIsInsideTmux, isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId, getCurrentPaneId as defaultGetCurrentPaneId,
@@ -1098,9 +1099,9 @@ export class TmuxSessionManager {
if (event.type !== "session.created") return if (event.type !== "session.created") return
const info = event.properties?.info 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" const title = info.title ?? "Subagent"
if (!this.sourcePaneId) { if (!this.sourcePaneId) {
@@ -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<string, TrackedSession>()
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)
})
})
@@ -7,6 +7,7 @@ import {
import type { TrackedSession } from "./types" import type { TrackedSession } from "./types"
import { log } from "../../shared" import { log } from "../../shared"
import { normalizeSDKResponse } from "../../shared" import { normalizeSDKResponse } from "../../shared"
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
const MIN_STABILITY_TIME_MS = 10 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000
const STABLE_POLLS_REQUIRED = 3 const STABLE_POLLS_REQUIRED = 3
@@ -170,10 +171,7 @@ export class TmuxPollingManager {
if (!properties) return undefined if (!properties) return undefined
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = properties.info return resolveMessageEventSessionID(properties)
if (!info || typeof info !== "object") return undefined
const sessionId = (info as { sessionID?: unknown }).sessionID
return typeof sessionId === "string" ? sessionId : undefined
} }
if ( if (
@@ -182,8 +180,7 @@ export class TmuxPollingManager {
|| event.type === "message.part.removed" || event.type === "message.part.removed"
|| event.type === "message.removed" || event.type === "message.removed"
) { ) {
const sessionId = properties.sessionID return resolveMessageEventSessionID(properties)
return typeof sessionId === "string" ? sessionId : undefined
} }
return undefined return undefined
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema" import type { TmuxConfig } from "../../config/schema"
import type { CapacityConfig, TrackedSession } from "./types" import type { CapacityConfig, TrackedSession } from "./types"
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { queryWindowState } from "./pane-state-querier" import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, type SessionMapping } from "./decision-engine" import { decideSpawnActions, type SessionMapping } from "./decision-engine"
import { executeActions } from "./action-executor" import { executeActions } from "./action-executor"
@@ -44,9 +45,9 @@ export async function handleSessionCreated(
if (event.type !== "session.created") return if (event.type !== "session.created") return
const info = event.properties?.info 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" const title = info.title ?? "Subagent"
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) { if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
+5 -5
View File
@@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
import type { AgentUsageState } from "./types"; import type { AgentUsageState } from "./types";
import { getSessionAgent } from "../../features/claude-code-session-state"; import { getSessionAgent } from "../../features/claude-code-session-state";
import { getAgentConfigKey } from "../../shared/agent-display-names"; import { getAgentConfigKey } from "../../shared/agent-display-names";
import { resolveSessionEventID } from "../../shared/event-session-id";
interface ToolExecuteInput { interface ToolExecuteInput {
tool: string; tool: string;
@@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
resetState(sessionInfo.id); resetState(sessionID);
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
resetState(sessionID); resetState(sessionID);
} }
@@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery" import { attemptDeduplicationRecovery } from "./deduplication-recovery"
import { clearSessionState } from "./state" import { clearSessionState } from "./state"
import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map" import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions { export interface AnthropicContextWindowLimitRecoveryOptions {
@@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id) clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID)
clearSessionState(autoCompactState, sessionInfo.id) clearSessionState(autoCompactState, sessionID)
} }
return return
} }
if (event.type === "session.error") { 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 }) dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error })
if (!sessionID) return if (!sessionID) return
@@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined 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) { if (sessionID && info?.role === "assistant" && info.error) {
dependencies.log("[auto-compact] message.updated with error", { sessionID, error: 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") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return if (!autoCompactState.pendingCompact.has(sessionID)) return
+12 -11
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { HOOK_NAME } from "./hook-name" import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error" import { isAbortError } from "./is-abort-error"
import { handleAtlasSessionIdle } from "./idle-event" import { handleAtlasSessionIdle } from "./idle-event"
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const state = getState(sessionID) const state = getState(sessionID)
@@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: {
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
await handleAtlasSessionIdle({ ctx, options, getState, sessionID }) await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
return return
@@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as 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 const role = info?.role as string | undefined
if (!sessionID) return if (!sessionID) return
@@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const info = props?.info as 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 const role = info?.role as string | undefined
if (sessionID && role === "assistant") { if (sessionID && role === "assistant") {
@@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: {
} }
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(props)
if (sessionID) { if (sessionID) {
const state = sessions.get(sessionID) const state = sessions.get(sessionID)
if (state) { if (state) {
@@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: {
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
const deletedState = sessions.get(sessionInfo.id) const deletedState = sessions.get(sessionID)
if (deletedState?.pendingRetryTimer) { if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer) clearTimeout(deletedState.pendingRetryTimer)
} }
sessions.delete(sessionInfo.id) sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
} }
return return
} }
if (event.type === "session.compacted") { 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) { if (sessionID) {
const compactedState = sessions.get(sessionID) const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) { if (compactedState?.pendingRetryTimer) {
+32
View File
@@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999
expect(callArgs.body.parts[0].text).toContain("2 remaining") 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 () => { test("should settle idle before injecting boulder continuation", async () => {
// given // given
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")
+3 -11
View File
@@ -5,6 +5,7 @@ import {
} from "./detector" } from "./detector"
import { executeSlashCommand, type ExecutorOptions } from "./executor" import { executeSlashCommand, type ExecutorOptions } from "./executor"
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { import {
AUTO_SLASH_COMMAND_TAG_CLOSE, AUTO_SLASH_COMMAND_TAG_CLOSE,
AUTO_SLASH_COMMAND_TAG_OPEN, AUTO_SLASH_COMMAND_TAG_OPEN,
@@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
function getDeletedSessionID(properties: unknown): string | null { function getDeletedSessionID(properties: unknown): string | null {
if (!isRecord(properties)) { return resolveSessionEventID(properties) ?? null
return null
}
const info = properties.info
if (!isRecord(info)) {
return null
}
return typeof info.id === "string" ? info.id : null
} }
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null { function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
@@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string |
"commandId", "commandId",
] ]
const recordInput = input as unknown const recordInput: unknown = input
if (!isRecord(recordInput)) { if (!isRecord(recordInput)) {
return null return null
} }
+5 -5
View File
@@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared" import { log } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names" import { getAgentConfigKey } from "../../shared/agent-display-names"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { buildReminderMessage } from "./formatter" import { buildReminderMessage } from "./formatter"
/** /**
@@ -120,15 +121,14 @@ export function createCategorySkillReminderHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
sessionStates.delete(sessionInfo.id) sessionStates.delete(sessionID)
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props)
(props?.info as { id?: string } | undefined)?.id) as string | undefined
if (sessionID) { if (sessionID) {
sessionStates.delete(sessionID) sessionStates.delete(sessionID)
} }
@@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript"
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache" import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
import type { PluginConfig } from "../types" import type { PluginConfig } from "../types"
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared" import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
import { resolveSessionEventID } from "../../../shared/event-session-id"
import { import {
clearAllSessionHookState, clearAllSessionHookState,
clearSessionHookState, clearSessionHookState,
@@ -26,7 +27,7 @@ export function createSessionEventHandler(
if (event.type === "session.error") { if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (sessionID) { if (sessionID) {
sessionErrorState.set(sessionID, { sessionErrorState.set(sessionID, {
hasError: true, hasError: true,
@@ -38,13 +39,13 @@ export function createSessionEventHandler(
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
parentSessionIdCache.delete(sessionInfo.id) parentSessionIdCache.delete(sessionID)
clearTranscriptCache(sessionInfo.id) clearTranscriptCache(sessionID)
clearToolInputCache(sessionInfo.id) clearToolInputCache(sessionID)
contextCollector?.clear(sessionInfo.id) contextCollector?.clear(sessionID)
clearSessionHookState(sessionInfo.id) clearSessionHookState(sessionID)
} }
return return
} }
@@ -54,7 +55,7 @@ export function createSessionEventHandler(
} }
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig() const claudeConfig = await loadClaudeHooksConfig()
@@ -3,6 +3,7 @@ import {
clearCompactionAgentConfigCheckpoint, clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint" } from "../../shared/compaction-agent-config-checkpoint"
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt" import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver" import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
@@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: {
sessionID?: string sessionID?: string
} | undefined } | undefined
if (!info?.sessionID || info.role !== "assistant" || !info.id) { const sessionID = resolveMessageEventSessionID(props)
if (!sessionID || info?.role !== "assistant" || !info.id) {
return return
} }
const tailState = getTailState(info.sessionID) const tailState = getTailState(sessionID)
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) { if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
finalizeTrackedAssistantMessage(tailState) finalizeTrackedAssistantMessage(tailState)
await maybeWarnAboutNoTextTail(info.sessionID) await maybeWarnAboutNoTextTail(sessionID)
} }
if (tailState.currentMessageID !== info.id) { if (tailState.currentMessageID !== info.id) {
@@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: {
} }
if (event.type === "message.part.delta") { 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 messageID = props?.messageID as string | undefined
const field = props?.field as string | undefined const field = props?.field as string | undefined
const delta = props?.delta 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 { export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction" return agent?.trim().toLowerCase() === "compaction"
} }
export function resolveSessionID(props?: Record<string, unknown>): string | undefined { export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ?? return resolveSessionEventID(props)
(props?.info as { id?: string } | undefined)?.id) as string | undefined
} }
+2 -2
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
interface TodoSnapshot { interface TodoSnapshot {
@@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise<TodoWriter | null> {
} }
function resolveSessionID(props?: Record<string, unknown>): string | undefined { function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ?? return resolveSessionEventID(props)
(props?.info as { id?: string } | undefined)?.id) as string | undefined
} }
export interface CompactionTodoPreserver { export interface CompactionTodoPreserver {
+8 -6
View File
@@ -4,6 +4,7 @@ import {
type ContextLimitModelCacheState, type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver" } from "../shared/context-limit-resolver"
import { isCompactionAgent } from "../shared/compaction-marker" import { isCompactionAgent } from "../shared/compaction-marker"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const CONTEXT_WARNING_THRESHOLD = 0.70 const CONTEXT_WARNING_THRESHOLD = 0.70
@@ -86,10 +87,10 @@ export function createContextWindowMonitorHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
remindedSessions.delete(sessionInfo.id) remindedSessions.delete(sessionID)
tokenCache.delete(sessionInfo.id) tokenCache.delete(sessionID)
} }
} }
@@ -106,9 +107,10 @@ export function createContextWindowMonitorHook(
if (!info || info.role !== "assistant" || !info.finish) return if (!info || info.role !== "assistant" || !info.finish) return
if (isCompactionAgent(info.agent)) 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, providerID: info.providerID,
modelID: info.modelID ?? "", modelID: info.modelID ?? "",
tokens: info.tokens, tokens: info.tokens,
+6 -6
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { processFilePathForAgentsInjection } from "./injector"; import { processFilePathForAgentsInjection } from "./injector";
import { clearInjectedPaths } from "./storage"; import { clearInjectedPaths } from "./storage";
@@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook(
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
sessionCaches.delete(sessionInfo.id); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionInfo.id); clearInjectedPaths(sessionID);
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
sessionCaches.delete(sessionID); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID); clearInjectedPaths(sessionID);
+6 -6
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { processFilePathForReadmeInjection } from "./injector"; import { processFilePathForReadmeInjection } from "./injector";
import { clearInjectedPaths } from "./storage"; import { clearInjectedPaths } from "./storage";
@@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook(
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
sessionCaches.delete(sessionInfo.id); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionInfo.id); clearInjectedPaths(sessionID);
} }
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
sessionCaches.delete(sessionID); sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID); clearInjectedPaths(sessionID);
+2 -2
View File
@@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types";
import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser";
import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager";
import { subagentSessions } from "../../features/claude-code-session-state"; import { subagentSessions } from "../../features/claude-code-session-state";
import { resolveSessionEventID } from "../../shared/event-session-id";
interface ToolExecuteInput { interface ToolExecuteInput {
tool: string; tool: string;
@@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) {
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
const sessionID = sessionInfo?.id;
if (sessionID) { if (sessionID) {
const state = getOrCreateStateLocal(sessionID); const state = getOrCreateStateLocal(sessionID);
+8 -7
View File
@@ -1,5 +1,6 @@
import type { OhMyOpenCodeConfig } from "../config" import type { OhMyOpenCodeConfig } from "../config"
import { isCompactionAgent } from "../shared/compaction-marker" import { isCompactionAgent } from "../shared/compaction-marker"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver" import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
@@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionID = (props?.info as { id?: string } | undefined)?.id const sessionID = resolveSessionEventID(props)
if (sessionID) { if (sessionID) {
compactionInProgress.delete(sessionID) compactionInProgress.delete(sessionID)
compactedSessions.delete(sessionID) compactedSessions.delete(sessionID)
@@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook(
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID as string | undefined) const sessionID = resolveSessionEventID(props)
?? (props?.info as { id?: string } | undefined)?.id
if (sessionID) { if (sessionID) {
postCompactionMonitor.onSessionCompacted(sessionID) postCompactionMonitor.onSessionCompacted(sessionID)
} }
@@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook(
parts?: unknown parts?: unknown
} | undefined } | 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 (isCompactionAgent(info.agent)) return
if (info.providerID && info.tokens) { if (info.providerID && info.tokens) {
tokenCache.set(info.sessionID, { tokenCache.set(sessionID, {
providerID: info.providerID, providerID: info.providerID,
modelID: info.modelID ?? "", modelID: info.modelID ?? "",
tokens: info.tokens, tokens: info.tokens,
}) })
} }
compactedSessions.delete(info.sessionID) compactedSessions.delete(sessionID)
await postCompactionMonitor.onAssistantMessageUpdated({ await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID, sessionID,
id: info.id, id: info.id,
parts: info.parts, parts: info.parts,
}) })
+19
View File
@@ -304,6 +304,25 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2) 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 () => { test("should settle idle before injecting continuation", async () => {
// given - active loop state with a configured idle settle delay // given - active loop state with a configured idle settle delay
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 }) const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 })
@@ -213,6 +213,77 @@ describe("ralph-loop non-abort error continuation", () => {
expect(hook.getState()?.iteration).toBe(3) 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 () => { test("skips immediate runtime retry while background tasks are running", async () => {
// given - an active loop owns running background work // given - an active loop owns running background work
const hook = createRalphLoopHook({ const hook = createRalphLoopHook({
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import type { RalphLoopOptions, RalphLoopState } from "./types" import type { RalphLoopOptions, RalphLoopState } from "./types"
import { HOOK_NAME } from "./constants" import { HOOK_NAME } from "./constants"
import { handleDetectedCompletion } from "./completion-handler" import { handleDetectedCompletion } from "./completion-handler"
@@ -36,12 +37,6 @@ function hasRunningBackgroundTasks(
: false : 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( function getRuntimeRetryActivitySessionID(
eventType: string, eventType: string,
props: Record<string, unknown> | undefined, props: Record<string, unknown> | undefined,
@@ -49,20 +44,19 @@ function getRuntimeRetryActivitySessionID(
if (eventType === "message.updated") { if (eventType === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const role = info?.role const role = info?.role
return role === "assistant" ? getInfoSessionID(props) : undefined return role === "assistant" ? resolveMessageEventSessionID(props) : undefined
} }
if (eventType === "message.part.updated") { if (eventType === "message.part.updated") {
if (typeof props?.sessionID === "string") return props.sessionID return resolveMessageEventSessionID(props)
return getInfoSessionID(props)
} }
if (eventType === "message.part.delta") { 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") { if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
return typeof props?.sessionID === "string" ? props.sessionID : undefined return resolveMessageEventSessionID(props)
} }
return undefined return undefined
@@ -198,7 +192,7 @@ export function createRalphLoopEventHandler(
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
if (inFlightSessions.has(sessionID)) { if (inFlightSessions.has(sessionID)) {
@@ -389,7 +383,7 @@ export function createRalphLoopEventHandler(
} }
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
const error = props?.error const error = props?.error
if (!sessionID || isAbortError(error)) { if (!sessionID || isAbortError(error)) {
handleErroredLoopSession(props, options.loopState) handleErroredLoopSession(props, options.loopState)
@@ -1,4 +1,5 @@
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { HOOK_NAME } from "./constants" import { HOOK_NAME } from "./constants"
import type { RalphLoopState } from "./types" import type { RalphLoopState } from "./types"
@@ -11,13 +12,13 @@ export function handleDeletedLoopSession(
props: Record<string, unknown> | undefined, props: Record<string, unknown> | undefined,
loopState: LoopStateController, loopState: LoopStateController,
): boolean { ): boolean {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (!sessionInfo?.id) return false if (!sessionID) return false
const state = loopState.getState() const state = loopState.getState()
if (state?.session_id === sessionInfo.id) { if (state?.session_id === sessionID) {
loopState.clear() loopState.clear()
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID })
} }
return true return true
} }
@@ -26,7 +27,7 @@ export function handleErroredLoopSession(
props: Record<string, unknown> | undefined, props: Record<string, unknown> | undefined,
loopState: LoopStateController, loopState: LoopStateController,
): boolean { ): boolean {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
const error = props?.error as { name?: string } | undefined const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") { if (error?.name === "MessageAbortedError") {
+5 -5
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import { createDynamicTruncator } from "../../shared/dynamic-truncator"; import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { resolveSessionEventID } from "../../shared/event-session-id";
import { getRuleInjectionFilePath } from "./output-path"; import { getRuleInjectionFilePath } from "./output-path";
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache"; import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
import { createRuleInjectionProcessor } from "./injector"; import { createRuleInjectionProcessor } from "./injector";
@@ -80,16 +81,15 @@ export function createRulesInjectorHook(
const props = event.properties as Record<string, unknown> | undefined; const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id) { if (sessionID) {
clearSessionState(sessionInfo.id); clearSessionState(sessionID);
} }
clearProjectRootCache(); clearProjectRootCache();
} }
if (event.type === "session.compacted") { if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? const sessionID = resolveSessionEventID(props);
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) { if (sessionID) {
clearSessionState(sessionID); clearSessionState(sessionID);
} }
+7 -7
View File
@@ -10,6 +10,7 @@ import { isAbortError } from "../../shared/is-abort-error"
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
import { createSessionStatusHandler } from "./session-status-handler" import { createSessionStatusHandler } from "./session-status-handler"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps 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 handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined const sessionInfo = props?.info as { id?: string; model?: string } | undefined
const sessionID = sessionInfo?.id const sessionID = resolveSessionEventID(props)
const model = sessionInfo?.model const model = sessionInfo?.model
if (sessionID && model) { if (sessionID && model) {
@@ -41,8 +42,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
} }
const handleSessionDeleted = (props: Record<string, unknown> | undefined) => { const handleSessionDeleted = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
const sessionID = sessionInfo?.id
if (sessionID) { if (sessionID) {
log(`[${HOOK_NAME}] Cleaning up session state`, { 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 handleSessionStop = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) { 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 handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
const info = props?.info as 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 const role = info?.role as string | undefined
if (!sessionID || role !== "user") return if (!sessionID || role !== "user") return
@@ -81,7 +81,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
} }
const handleSessionIdle = (props: Record<string, unknown> | undefined) => { const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
if (cancelledSessions.has(sessionID)) { if (cancelledSessions.has(sessionID)) {
@@ -111,7 +111,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
} }
const handleSessionError = async (props: Record<string, unknown> | undefined) => { const handleSessionError = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
const error = props?.error const error = props?.error
const agent = props?.agent as string | undefined const agent = props?.agent as string | undefined
@@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models"
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
import { hasVisibleAssistantResponse } from "./visible-assistant-response" import { hasVisibleAssistantResponse } from "./visible-assistant-response"
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
export { hasVisibleAssistantResponse } from "./visible-assistant-response" export { hasVisibleAssistantResponse } from "./visible-assistant-response"
@@ -17,7 +18,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
return async (props: Record<string, unknown> | undefined) => { return async (props: Record<string, unknown> | undefined) => {
const info = props?.info as 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 timeoutEnabled = config.timeout_seconds > 0
const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined
const infoParts = info?.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 { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
import { resolveSessionEventID } from "../../shared/event-session-id"
export function createSessionStatusHandler( export function createSessionStatusHandler(
deps: HookDeps, deps: HookDeps,
@@ -22,7 +23,7 @@ export function createSessionStatusHandler(
} = deps } = deps
return async (props: Record<string, unknown> | undefined) => { 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 status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
const agent = props?.agent as string | undefined const agent = props?.agent as string | undefined
const model = props?.model 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 const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId 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 return undefined
} }
+41
View File
@@ -375,6 +375,47 @@ describe("session-notification", () => {
expect(notificationCalls).toHaveLength(0) 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 () => { test("should mark session activity on tool.execute.before event", async () => {
// given - main session is set // given - main session is set
const mainSessionID = "main-tool" const mainSessionID = "main-tool"
+9 -5
View File
@@ -7,6 +7,7 @@ import { getEventToolName, getQuestionText, getSessionID } from "./session-notif
import { hasIncompleteTodos } from "./session-todo-status" import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler" import { createIdleNotificationScheduler } from "./session-notification-scheduler"
import { createSessionNotificationInit } from "./session-notification-init" import { createSessionNotificationInit } from "./session-notification-init"
import { resolveSessionEventID } from "../shared/event-session-id"
interface SessionNotificationConfig { interface SessionNotificationConfig {
title?: string title?: string
@@ -98,8 +99,7 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") { if (event.type === "session.created") {
const info = props?.info as Record<string, unknown> | undefined const sessionID = resolveSessionEventID(props)
const sessionID = info?.id as string | undefined
if (sessionID) scheduler.markSessionActivity(sessionID) if (sessionID) scheduler.markSessionActivity(sessionID)
return return
} }
@@ -116,7 +116,11 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
return 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 info = props?.info as Record<string, unknown> | undefined
const sessionID = getSessionID({ ...props, info }) const sessionID = getSessionID({ ...props, info })
if (sessionID) scheduler.markSessionActivity(sessionID) if (sessionID) scheduler.markSessionActivity(sessionID)
@@ -165,8 +169,8 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id) if (sessionID) scheduler.deleteSession(sessionID)
} }
} }
} }
+6 -5
View File
@@ -5,6 +5,7 @@ import {
clearContinuationMarker, clearContinuationMarker,
setContinuationMarkerSource, setContinuationMarkerSource,
} from "../../features/run-continuation-state" } from "../../features/run-continuation-state"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
const HOOK_NAME = "stop-continuation-guard" const HOOK_NAME = "stop-continuation-guard"
@@ -86,11 +87,11 @@ export function createStopContinuationGuardHook(
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
clear(sessionInfo.id) clear(sessionID)
clearContinuationMarker(ctx.directory, sessionInfo.id) clearContinuationMarker(ctx.directory, sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
} }
} }
} }
+3 -2
View File
@@ -1,5 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { resolveSessionEventID } from "../../shared/event-session-id"
const TASK_TOOLS = new Set([ const TASK_TOOLS = new Set([
"task", "task",
"task_create", "task_create",
@@ -50,8 +52,7 @@ export function createTaskReminderHook(_ctx: PluginInput) {
"tool.execute.after": toolExecuteAfter, "tool.execute.after": toolExecuteAfter,
event: async ({ event }: { event: { type: string; properties?: unknown } }) => { event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.deleted") return if (event.type !== "session.deleted") return
const props = event.properties as { info?: { id?: string } } | undefined const sessionId = resolveSessionEventID(event.properties)
const sessionId = props?.info?.id
if (!sessionId) return if (!sessionId) return
sessionCounters.delete(sessionId) sessionCounters.delete(sessionId)
}, },
@@ -7,6 +7,7 @@ import {
applyMemberSessionRouting, applyMemberSessionRouting,
buildMemberPromptBody, buildMemberPromptBody,
} from "../../features/team-mode/member-session-routing" } from "../../features/team-mode/member-session-routing"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { settleAfterSessionIdle } from "../shared/session-idle-settle" import { settleAfterSessionIdle } from "../shared/session-idle-settle"
@@ -35,8 +36,7 @@ export type HookImpl = (input: HookInput) => Promise<void>
type TeamIdleWakeHintOptions = { idleSettleMs?: number } type TeamIdleWakeHintOptions = { idleSettleMs?: number }
function getIdleSessionID(properties: unknown): string | undefined { function getIdleSessionID(properties: unknown): string | undefined {
const record = properties as { sessionID?: string } | undefined return resolveSessionEventID(properties)
return record?.sessionID
} }
function buildWakeHint(unreadCount: number): string { 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 { lookupTeamSession } from "../../features/team-mode/team-session-registry"
import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import type { TmuxSessionManager } from "../../features/tmux-subagent/manager" import type { TmuxSessionManager } from "../../features/tmux-subagent/manager"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
type HookInput = { event: { type: string; properties?: unknown } } type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void> export type HookImpl = (input: HookInput) => Promise<void>
function getDeletedSessionID(properties: unknown): string | undefined { function getDeletedSessionID(properties: unknown): string | undefined {
const record = properties as { info?: { id?: string } } | undefined return resolveSessionEventID(properties)
return record?.info?.id
} }
async function findLeadTeamRunId( async function findLeadTeamRunId(
@@ -1,14 +1,14 @@
import type { TeamModeConfig } from "../../config/schema/team-mode" import type { TeamModeConfig } from "../../config/schema/team-mode"
import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution" import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
type HookInput = { event: { type: string; properties?: unknown } } type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void> export type HookImpl = (input: HookInput) => Promise<void>
function getErroredSessionID(properties: unknown): string | undefined { function getErroredSessionID(properties: unknown): string | undefined {
const record = properties as { sessionID?: string } | undefined return resolveSessionEventID(properties)
return record?.sessionID
} }
export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl { 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 { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution"
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import type { RuntimeStateMember } from "../../features/team-mode/types" import type { RuntimeStateMember } from "../../features/team-mode/types"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
type HookInput = { event: { type: string; properties?: unknown } } 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"]) const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet<MemberStatus> = new Set(["running", "idle", "pending"])
function getSessionIDFromIdleEvent(properties: unknown): string | undefined { function getSessionIDFromIdleEvent(properties: unknown): string | undefined {
const record = properties as { sessionID?: string } | undefined return resolveSessionEventID(properties)
return record?.sessionID
} }
function getSessionIDFromDeletedEvent(properties: unknown): string | undefined { function getSessionIDFromDeletedEvent(properties: unknown): string | undefined {
const record = properties as { info?: { id?: string } } | undefined return resolveSessionEventID(properties)
return record?.info?.id
} }
async function transitionMemberStatus( async function transitionMemberStatus(
+4 -3
View File
@@ -2,6 +2,7 @@ import { detectThinkKeyword, extractPromptText } from "./detector"
import { isAlreadyHighVariant } from "./switcher" import { isAlreadyHighVariant } from "./switcher"
import type { ThinkModeState } from "./types" import type { ThinkModeState } from "./types"
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
const thinkModeState = new Map<string, ThinkModeState>() const thinkModeState = new Map<string, ThinkModeState>()
@@ -66,9 +67,9 @@ export function createThinkModeHook() {
event: async ({ event }: { event: { type: string; properties?: unknown } }) => { event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const props = event.properties as { info?: { id?: string } } | undefined const sessionID = resolveSessionEventID(event.properties)
if (props?.info?.id) { if (sessionID) {
thinkModeState.delete(props.info.id) thinkModeState.delete(sessionID)
} }
} }
}, },
@@ -5,6 +5,7 @@ import {
clearContinuationMarker, clearContinuationMarker,
} from "../../features/run-continuation-state" } from "../../features/run-continuation-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
import { armCompactionGuard } from "./compaction-guard" import { armCompactionGuard } from "./compaction-guard"
@@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const error = extractSessionErrorInfo(props?.error) const error = extractSessionErrorInfo(props?.error)
@@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: {
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
sessionStateStore.startPruneInterval() sessionStateStore.startPruneInterval()
@@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: {
} }
if (event.type === "session.compacted") { 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) { if (sessionID) {
const state = sessionStateStore.getState(sessionID) const state = sessionStateStore.getState(sessionID)
const compactionEpoch = armCompactionGuard(state, Date.now()) const compactionEpoch = armCompactionGuard(state, Date.now())
@@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: {
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (sessionInfo?.id) { if (sessionID) {
clearContinuationMarker(ctx.directory, sessionInfo.id) clearContinuationMarker(ctx.directory, sessionID)
} }
} }
@@ -1,4 +1,5 @@
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants" import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
import type { SessionStateStore } from "./session-state" import type { SessionStateStore } from "./session-state"
@@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: {
if (eventType === "message.updated") { if (eventType === "message.updated") {
const info = properties?.info as Record<string, unknown> | undefined 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 const role = info?.role as string | undefined
if (!sessionID) return if (!sessionID) return
@@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: {
} }
if (eventType === "message.part.updated") { if (eventType === "message.part.updated") {
const sessionID = typeof properties?.sessionID === "string" const targetSessionID = resolveMessageEventSessionID(properties)
? properties.sessionID
: undefined
const legacyInfo = properties?.info as Record<string, unknown> | undefined
const legacySessionID = legacyInfo?.sessionID as string | undefined
const targetSessionID = sessionID ?? legacySessionID
if (targetSessionID) { if (targetSessionID) {
const state = sessionStateStore.getExistingState(targetSessionID) const state = sessionStateStore.getExistingState(targetSessionID)
@@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: {
} }
if (eventType === "message.part.delta") { if (eventType === "message.part.delta") {
const sessionID = properties?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(properties)
if (sessionID) { if (sessionID) {
const state = sessionStateStore.getExistingState(sessionID) const state = sessionStateStore.getExistingState(sessionID)
if (state) { if (state) {
@@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: {
} }
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
const sessionID = properties?.sessionID as string | undefined const sessionID = resolveMessageEventSessionID(properties)
if (sessionID) { if (sessionID) {
const state = sessionStateStore.getExistingState(sessionID) const state = sessionStateStore.getExistingState(sessionID)
if (state) { if (state) {
@@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: {
} }
if (eventType === "session.deleted") { if (eventType === "session.deleted") {
const sessionInfo = properties?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(properties)
if (sessionInfo?.id) { if (sessionID) {
sessionStateStore.cleanup(sessionInfo.id) sessionStateStore.cleanup(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
} }
return return
} }
@@ -12,6 +12,7 @@ import {
} from "./constants" } from "./constants"
type TimerCallback = (...args: any[]) => void type TimerCallback = (...args: any[]) => void
type FakeTimerID = number & ReturnType<typeof setTimeout> & ReturnType<typeof setInterval>
interface FakeTimers { interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void> advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
@@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers {
callback, callback,
args, args,
}) })
return id return id as FakeTimerID
} }
const clear = (id: number | undefined) => { const clear = (id: number | undefined) => {
@@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers {
if (normalized >= REAL_MAX_DELAY_MS) { if (normalized >= REAL_MAX_DELAY_MS) {
return original.setTimeout(callback, delay, ...args) 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 }) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => { globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
@@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers {
if (interval >= REAL_MAX_DELAY_MS) { if (interval >= REAL_MAX_DELAY_MS) {
return original.setInterval(callback, delay, ...args) 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 }) as typeof setInterval
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => { globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
@@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => {
} }
} }
type MockPluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
let mockMessages: MockMessage[] = [] let mockMessages: MockMessage[] = []
function createMockPluginInput() { function createMockPluginInput() {
@@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
}, },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
} }
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager { function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
@@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => {
getTasksByParentSession: () => runningTasks getTasksByParentSession: () => runningTasks
? [{ status: "running" }] ? [{ status: "running" }]
: [], : [],
} as any } as BackgroundManager
} }
beforeEach(() => { beforeEach(() => {
@@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls[0].text).toContain("TODO CONTINUATION") expect(promptCalls[0].text).toContain("TODO CONTINUATION")
}, { timeout: 15000 }) }, { 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 () => { test("should not inject when all todos are complete", async () => {
// given - session with all todos complete // given - session with all todos complete
const sessionID = "main-456" const sessionID = "main-456"
@@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0) 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 () => { test("should cancel countdown on assistant activity with message.part.delta payload", async () => {
// given - session starting countdown // given - session starting countdown
const sessionID = "main-assistant-delta" const sessionID = "main-assistant-delta"
@@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) }, tui: { showToast: async () => ({}) },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
@@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) }, tui: { showToast: async () => ({}) },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
@@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) }, tui: { showToast: async () => ({}) },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {}) const hook = createTodoContinuationEnforcer(mockInput, {})
@@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) }, tui: { showToast: async () => ({}) },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
@@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) }, tui: { showToast: async () => ({}) },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {}) const hook = createTodoContinuationEnforcer(mockInput, {})
@@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) }, tui: { showToast: async () => ({}) },
}, },
directory: "/tmp/test", directory: "/tmp/test",
} as any } as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
skipAgents: [], skipAgents: [],
@@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => {
const mockInput = createMockPluginInput() const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async () => { mockInput.client.session.promptAsync = async () => {
const error = new Error("prompt is too long: 150000 tokens > 100000 maximum") const error = new Error("prompt is too long: 150000 tokens > 100000 maximum")
;(error as any).name = "ContextLengthError" error.name = "ContextLengthError"
throw error throw error
} }
@@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent"
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { isAbortError } from "../../shared/is-abort-error" import { isAbortError } from "../../shared/is-abort-error"
import { import {
buildReminder, buildReminder,
@@ -128,7 +129,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") { if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID || !isAbortError(props?.error)) return if (!sessionID || !isAbortError(props?.error)) return
cancelledSessions.add(sessionID) cancelledSessions.add(sessionID)
@@ -138,7 +139,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
} }
if (event.type === "session.stop") { if (event.type === "session.stop") {
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
cancelledSessions.add(sessionID) cancelledSessions.add(sessionID)
@@ -149,7 +150,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as 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 const role = info?.role as string | undefined
if (!sessionID || (role !== "user" && role !== "assistant")) return 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") { 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 if (!sessionID) return
cancelledSessions.delete(sessionID) cancelledSessions.delete(sessionID)
@@ -166,16 +167,16 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
} }
if (event.type === "session.deleted") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionID = resolveSessionEventID(props)
if (!sessionInfo?.id) return if (!sessionID) return
cancelledSessions.delete(sessionInfo.id) cancelledSessions.delete(sessionID)
return return
} }
if (event.type !== "session.idle") return if (event.type !== "session.idle") return
const sessionID = props?.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const mainSessionID = getMainSessionID() const mainSessionID = getMainSessionID()
+2 -2
View File
@@ -4,6 +4,7 @@ import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path" import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler" import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
import { resolveSessionEventID } from "../../shared/event-session-id"
export type GuardArgs = { export type GuardArgs = {
filePath?: string filePath?: string
@@ -108,8 +109,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: Wri
return return
} }
const props = event.properties as { info?: { id?: string } } | undefined const sessionID = resolveSessionEventID(event.properties)
const sessionID = props?.info?.id
if (!sessionID) { if (!sessionID) {
return return
} }
+50
View File
@@ -802,6 +802,56 @@ describe("createEventHandler - event forwarding", () => {
expect(forwardedEvents[0]?.event.type).toBe("message.part.delta") 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 () => { it("does not forward tmux activity events when tmux integration is disabled", async () => {
const forwardedEvents: EventInput[] = [] const forwardedEvents: EventInput[] = []
const eventHandler = createEventHandler({ const eventHandler = createEventHandler({
+44 -42
View File
@@ -47,6 +47,7 @@ import type { CreatedHooks } from "../create-hooks";
import type { Managers } from "../create-managers"; import type { Managers } from "../create-managers";
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"; import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"; import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id";
type FirstMessageVariantGate = { type FirstMessageVariantGate = {
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void; 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 getEventSessionID = (input: EventInput): string | undefined => {
const properties = input.event.properties; const properties = input.event.properties;
if ( if (input.event.type.startsWith("session.")) {
!properties || return resolveSessionEventID(properties);
typeof properties !== "object" ||
!("sessionID" in properties) ||
typeof properties.sessionID !== "string"
) {
return undefined;
} }
return properties.sessionID; if (input.event.type.startsWith("message.") || input.event.type.startsWith("tool.")) {
return resolveMessageEventSessionID(properties);
}
const record: Record<string, unknown> | undefined = isRecord(properties) ? properties : undefined;
const sessionID = record?.sessionID;
return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : undefined;
}; };
const runEventHookSafely = async ( const runEventHookSafely = async (
@@ -467,10 +468,11 @@ export function createEventHandler(args: {
if (event.type === "session.created") { if (event.type === "session.created") {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined; 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) { if (!isSubagentSession) {
setMainSession(sessionInfo?.id); setMainSession(sessionID);
} }
firstMessageVariantGate.markSessionCreated(sessionInfo); firstMessageVariantGate.markSessionCreated(sessionInfo);
@@ -489,62 +491,62 @@ export function createEventHandler(args: {
// Skip subagent sessions — they are dispatched by specialized callbacks // Skip subagent sessions — they are dispatched by specialized callbacks
// in create-managers.ts (async) and tool-registry.ts (sync) // in create-managers.ts (async) and tool-registry.ts (sync)
if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) { if (pluginConfig.openclaw && sessionID && !isSubagentSession) {
await dispatchOpenClawEvent({ await dispatchOpenClawEvent({
config: pluginConfig.openclaw, config: pluginConfig.openclaw,
rawEvent: event.type, rawEvent: event.type,
context: { context: {
sessionId: sessionInfo.id, sessionId: sessionID,
projectPath: pluginContext.directory, 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") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined; const sessionID = resolveSessionEventID(props);
if (sessionInfo?.id === getMainSessionID()) { if (sessionID === getMainSessionID()) {
setMainSession(undefined); setMainSession(undefined);
} }
if (sessionInfo?.id) { if (sessionID) {
const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id); const wasSyncSubagentSession = syncSubagentSessions.has(sessionID);
clearSessionAgent(sessionInfo.id); clearSessionAgent(sessionID);
lastHandledModelErrorMessageID.delete(sessionInfo.id); lastHandledModelErrorMessageID.delete(sessionID);
lastHandledRetryStatusKey.delete(sessionInfo.id); lastHandledRetryStatusKey.delete(sessionID);
lastKnownModelBySession.delete(sessionInfo.id); lastKnownModelBySession.delete(sessionID);
if (modelFallback) { if (modelFallback) {
clearPendingModelFallback(modelFallback, sessionInfo.id); clearPendingModelFallback(modelFallback, sessionID);
clearSessionFallbackChain(modelFallback, sessionInfo.id); clearSessionFallbackChain(modelFallback, sessionID);
} }
resetMessageCursor(sessionInfo.id); resetMessageCursor(sessionID);
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id); clearBackgroundOutputConsumptionsForParentSession(sessionID);
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id); clearBackgroundOutputConsumptionsForTaskSession(sessionID);
firstMessageVariantGate.clear(sessionInfo.id); firstMessageVariantGate.clear(sessionID);
clearSessionModel(sessionInfo.id); clearSessionModel(sessionID);
clearSessionPromptParams(sessionInfo.id); clearSessionPromptParams(sessionID);
syncSubagentSessions.delete(sessionInfo.id); syncSubagentSessions.delete(sessionID);
if (pluginConfig.openclaw) { if (pluginConfig.openclaw) {
await dispatchOpenClawEvent({ await dispatchOpenClawEvent({
config: pluginConfig.openclaw, config: pluginConfig.openclaw,
rawEvent: event.type, rawEvent: event.type,
context: { context: {
sessionId: sessionInfo.id, sessionId: sessionID,
projectPath: pluginContext.directory, projectPath: pluginContext.directory,
tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE, tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE,
}, },
}); });
} }
if (wasSyncSubagentSession) { if (wasSyncSubagentSession) {
subagentSessions.delete(sessionInfo.id); subagentSessions.delete(sessionID);
} }
deleteSessionTools(sessionInfo.id); deleteSessionTools(sessionID);
await managers.skillMcpManager.disconnectSession(sessionInfo.id); await managers.skillMcpManager.disconnectSession(sessionID);
await lspManager.cleanupTempDirectoryClients(); await lspManager.cleanupTempDirectoryClients();
if (tmuxIntegrationEnabled) { if (tmuxIntegrationEnabled) {
await managers.tmuxSessionManager.onSessionDeleted({ await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id, sessionID,
}); });
} }
} }
@@ -555,12 +557,12 @@ export function createEventHandler(args: {
if (event.type === "message.removed") { if (event.type === "message.removed") {
const messageID = props?.messageID as string | undefined; const messageID = props?.messageID as string | undefined;
const sessionID = props?.sessionID as string | undefined; const sessionID = resolveMessageEventSessionID(props);
restoreBackgroundOutputConsumption(sessionID, messageID); restoreBackgroundOutputConsumption(sessionID, messageID);
} }
if (event.type === "session.idle" && pluginConfig.openclaw) { if (event.type === "session.idle" && pluginConfig.openclaw) {
const sessionID = props?.sessionID as string | undefined; const sessionID = resolveSessionEventID(props);
if (sessionID) { if (sessionID) {
await dispatchOpenClawEvent({ await dispatchOpenClawEvent({
config: pluginConfig.openclaw, config: pluginConfig.openclaw,
@@ -582,7 +584,7 @@ export function createEventHandler(args: {
if (event.type === "message.updated") { if (event.type === "message.updated") {
const info = props?.info as 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 agent = info?.agent as string | undefined; const agent = info?.agent as string | undefined;
const role = info?.role as string | undefined; const role = info?.role as string | undefined;
if (sessionID && info?.finish === true) { if (sessionID && info?.finish === true) {
@@ -665,7 +667,7 @@ export function createEventHandler(args: {
} }
if (event.type === "session.status") { 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; 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 // 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") { if (event.type === "session.error") {
try { try {
const sessionID = props?.sessionID as string | undefined; const sessionID = resolveSessionEventID(props);
const error = props?.error; const error = props?.error;
const errorName = extractErrorName(error); const errorName = extractErrorName(error);
@@ -818,7 +820,7 @@ export function createEventHandler(args: {
} }
} }
} catch (err) { } catch (err) {
const sessionID = props?.sessionID as string | undefined; const sessionID = resolveSessionEventID(props);
log("[event] model-fallback error in session.error:", { sessionID, error: err }); log("[event] model-fallback error in session.error:", { sessionID, error: err });
} }
+3 -1
View File
@@ -1,3 +1,5 @@
import { resolveSessionEventID } from "../shared/event-session-id"
type EventInput = { event: { type: string; properties?: Record<string, unknown> } } type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
type SessionStatus = { type: string } type SessionStatus = { type: string }
@@ -10,7 +12,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu
const status = props.status as SessionStatus | undefined const status = props.status as SessionStatus | undefined
if (!status || status.type !== "idle") return null if (!status || status.type !== "idle") return null
const sessionID = props.sessionID as string | undefined const sessionID = resolveSessionEventID(props)
if (!sessionID) return null if (!sessionID) return null
return { return {
+40
View File
@@ -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")
})
})
+23
View File
@@ -0,0 +1,23 @@
import { isRecord } from "./record-type-guard"
function getStringField(record: Record<string, unknown> | 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")
}
+1
View File
@@ -54,6 +54,7 @@ export * from "./fallback-model-availability"
export * from "./connected-providers-cache" export * from "./connected-providers-cache"
export * from "./context-limit-resolver" export * from "./context-limit-resolver"
export * from "./session-utils" export * from "./session-utils"
export * from "./event-session-id"
export * from "./tmux" export * from "./tmux"
export * from "./model-suggestion-retry" export * from "./model-suggestion-retry"
export * from "./opencode-server-auth" export * from "./opencode-server-auth"