diff --git a/src/plugin/event.model-fallback-pin-agent.test.ts b/src/plugin/event.model-fallback-pin-agent.test.ts new file mode 100644 index 000000000..db29bcb0e --- /dev/null +++ b/src/plugin/event.model-fallback-pin-agent.test.ts @@ -0,0 +1,271 @@ +declare const require: (name: string) => any +const { afterEach, describe, expect, spyOn, test } = require("bun:test") + +import { createEventHandler } from "./event" +import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" +import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook" +import * as connectedProvidersCache from "../shared/connected-providers-cache" + +let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined +let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined + +function setupConnectedProviderCacheMocks(): void { + readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null) +} + +type PromptBody = { + path: { id: string } + body: { + parts: Array<{ type: "text"; text: string }> + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + } + query: { directory: string } +} + +describe("createEventHandler - model-fallback auto-continuation pins agent/model/variant", () => { + const createHandler = (args?: { + hooks?: any + pluginConfig?: any + withPromptAsync?: boolean + }) => { + setupConnectedProviderCacheMocks() + const promptAsyncBodies: PromptBody[] = [] + const promptBodies: PromptBody[] = [] + + const sessionClient: Record = { + abort: async () => ({}), + prompt: async (input: PromptBody) => { + promptBodies.push(input) + return {} + }, + } + if (args?.withPromptAsync ?? true) { + sessionClient.promptAsync = async (input: PromptBody) => { + promptAsyncBodies.push(input) + return {} + } + } + + const handler = createEventHandler({ + ctx: { + directory: "/tmp", + client: { session: sessionClient }, + } as any, + pluginConfig: (args?.pluginConfig ?? {}) as any, + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: { + tmuxSessionManager: { + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + skillMcpManager: { + disconnectSession: async () => {}, + }, + } as any, + hooks: args?.hooks ?? ({} as any), + }) + + return { handler, promptAsyncBodies, promptBodies } + } + + afterEach(() => { + readConnectedProvidersCacheSpy?.mockRestore() + readProviderModelsCacheSpy?.mockRestore() + readConnectedProvidersCacheSpy = undefined + readProviderModelsCacheSpy = undefined + _resetForTesting() + }) + + test("pins agent/model on promptAsync body when continuing after message.updated fallback", async () => { + // given + const sessionID = "ses_pin_message_updated" + setMainSession(sessionID) + const modelFallback = createModelFallbackHook() + clearPendingModelFallback(modelFallback, sessionID) + const { handler, promptAsyncBodies } = createHandler({ hooks: { modelFallback } }) + + // when + await handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_err_pin_1", + sessionID, + role: "assistant", + time: { created: 1, completed: 2 }, + error: { + name: "APIError", + data: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + isRetryable: true, + }, + }, + parentID: "msg_user_pin_1", + modelID: "claude-opus-4-7-thinking", + providerID: "anthropic", + agent: "Sisyphus - Ultraworker", + }, + }, + }, + }) + + // then + expect(promptAsyncBodies.length).toBe(1) + const body = promptAsyncBodies[0]!.body + expect(body.agent).toBeDefined() + expect(body.agent).toContain("Sisyphus") + expect(body.model).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-7", + }) + }) + + test("pins agent/model on promptAsync body when continuing after session.error fallback", async () => { + // given + const sessionID = "ses_pin_session_error" + setMainSession(sessionID) + const modelFallback = createModelFallbackHook() + clearPendingModelFallback(modelFallback, sessionID) + const { handler, promptAsyncBodies } = createHandler({ hooks: { modelFallback } }) + + // when + await handler({ + event: { + type: "session.error", + properties: { + sessionID, + providerID: "anthropic", + modelID: "claude-opus-4-7-thinking", + error: { + name: "UnknownError", + data: { + error: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + }, + }, + }, + }, + }, + }) + + // then + expect(promptAsyncBodies.length).toBe(1) + const body = promptAsyncBodies[0]!.body + expect(body.agent).toBeDefined() + expect(body.agent?.toLowerCase()).toContain("sisyphus") + expect(body.model).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-7", + }) + }) + + test("pins agent/model on fallback prompt() body when promptAsync is not available (session.status)", async () => { + // given + const sessionID = "ses_pin_session_status_noasync" + setMainSession(sessionID) + const modelFallback = createModelFallbackHook() + clearPendingModelFallback(modelFallback, sessionID) + const { handler, promptBodies, promptAsyncBodies } = createHandler({ + hooks: { modelFallback }, + withPromptAsync: false, + }) + + await handler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_user_status_noasync", + sessionID, + role: "user", + modelID: "claude-opus-4-7-thinking", + providerID: "anthropic", + agent: "Sisyphus - Ultraworker", + }, + }, + }, + }) + + // when + await handler({ + event: { + type: "session.status", + properties: { + sessionID, + status: { + type: "retry", + attempt: 1, + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + next: 1234, + }, + }, + }, + }) + + // then + expect(promptAsyncBodies.length).toBe(0) + expect(promptBodies.length).toBe(1) + const body = promptBodies[0]!.body + expect(body.agent).toBeDefined() + expect(body.agent).toContain("Sisyphus") + expect(body.model).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-7", + }) + }) + + test("pins variant from agent config when present", async () => { + // given + const sessionID = "ses_pin_variant" + setMainSession(sessionID) + const modelFallback = createModelFallbackHook() + clearPendingModelFallback(modelFallback, sessionID) + const pluginConfig = { + agents: { + sisyphus: { + variant: "thinking", + }, + }, + } + const { handler, promptAsyncBodies } = createHandler({ + hooks: { modelFallback }, + pluginConfig, + }) + + // when + await handler({ + event: { + type: "session.error", + properties: { + sessionID, + providerID: "anthropic", + modelID: "claude-opus-4-7-thinking", + error: { + name: "UnknownError", + data: { + error: { + message: + "Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}", + }, + }, + }, + }, + }, + }) + + // then + expect(promptAsyncBodies.length).toBe(1) + const body = promptAsyncBodies[0]!.body + expect(body.variant).toBe("thinking") + }) +}) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index c347e2dac..7ff985e8e 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -1,9 +1,11 @@ +/// import { describe, it, expect, afterEach, mock, spyOn } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" import { createEventHandler, extractErrorMessage } from "./event" import { createChatMessageHandler } from "./chat-message" import * as openclawRuntimeDispatch from "../openclaw/runtime-dispatch" -import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" +import { _resetForTesting, setMainSession, subagentSessions } from "../features/claude-code-session-state" import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook" import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state" @@ -36,11 +38,16 @@ function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConf return cast(config) } +function asPluginInput(input: unknown): PluginInput { + return input as PluginInput +} + function createEventHandlerManagers( overrides: Record = {}, ): EventHandlerArgs["managers"] { return cast({ tmuxSessionManager: { + onEvent: () => {}, onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, @@ -89,6 +96,43 @@ function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType }) } +function createIdleDedupSpyEventHandler(args: { + onEvent: (event: EventInput["event"]) => void + sessionNotification: (input: EventInput) => Promise +}): ReturnType { + return createEventHandler({ + ctx: asEventHandlerContext({ + directory: "/tmp", + client: { + session: {}, + }, + }), + pluginConfig: asPluginConfig({ + tmux: { enabled: true }, + }), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + tmuxSessionManager: { + onEvent: args.onEvent, + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({ + sessionNotification: args.sessionNotification, + }), + }) +} + +async function flushMicrotasks(turns: number = 5): Promise { + for (let index = 0; index < turns; index += 1) { + await Promise.resolve() + } +} + afterEach(() => { mock.restore() _resetForTesting() @@ -107,7 +151,192 @@ describe("event error extraction", () => { }) describe("createEventHandler - idle deduplication", () => { - it("dispatches both idle events when the real idle arrives within 500ms", async () => { + it("#given tmux integration enabled #when session.idle arrives #then it forwards the event to tmuxSessionManager.onEvent", async () => { + //#given + const onEvent = mock<(event: EventInput["event"]) => void>(() => {}) + const idleEvent = { + event: { + type: "session.idle", + properties: { + sessionID: "ses_tmux_idle", + }, + }, + } + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({ + directory: "/tmp", + client: { + session: {}, + }, + }), + pluginConfig: asPluginConfig({ + tmux: { enabled: true }, + }), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + tmuxSessionManager: { + onEvent, + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + + //#when + await eventHandler(asEventHandlerInput(idleEvent)) + + //#then + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent.mock.calls[0]?.[0]).toEqual(idleEvent.event) + }) + + it("#given a readiness retry is pending #when session.idle arrives through the plugin handler #then tmux retry spawns the pane", async () => { + //#given + const sessionStatusData: Record = {} + const sessionStatusResult = { + data: sessionStatusData, + } + const spawnTmuxPane = mock(async (_sessionId: string) => ({ + success: true, + paneId: "%mock", + })) + let waitForSessionReadyCallCount = 0 + + mock.module("../features/tmux-subagent/pane-state-querier", () => ({ + queryWindowState: async () => ({ + windowWidth: 220, + windowHeight: 44, + mainPane: { + paneId: "%0", + width: 110, + height: 44, + left: 0, + top: 0, + title: "main", + isActive: true, + }, + agentPanes: [], + }), + })) + mock.module("../features/tmux-subagent/action-executor", () => ({ + executeActions: async (actions: Array<{ type: string; sessionId: string }>) => { + for (const action of actions) { + if (action.type === "spawn") { + await spawnTmuxPane(action.sessionId) + } + } + + return { + success: true, + spawnedPaneId: "%mock", + results: [], + } + }, + executeAction: async () => ({ success: true }), + })) + mock.module("../features/tmux-subagent/session-ready-waiter", () => ({ + waitForSessionReady: async () => { + waitForSessionReadyCallCount += 1 + if (waitForSessionReadyCallCount === 1) { + throw new Error("session readiness timed out") + } + + return true + }, + })) + mock.module("../shared/tmux", () => ({ + isInsideTmux: () => true, + getCurrentPaneId: () => "%0", + POLL_INTERVAL_BACKGROUND_MS: 100, + spawnTmuxWindow: async () => ({ success: true, paneId: "%isolated-window" }), + spawnTmuxSession: async () => ({ success: true, paneId: "%isolated-session" }), + killTmuxSessionIfExists: async () => true, + getIsolatedSessionName: (pid: number = 12345) => `omo-agents-${pid}`, + sweepStaleOmoAgentSessions: async () => 0, + })) + + const { TmuxSessionManager } = await import("../features/tmux-subagent/manager") + const managerContext = asPluginInput({ + serverUrl: new URL("http://localhost:4096"), + directory: "/tmp", + project: "/tmp", + worktree: "/tmp", + $: {}, + client: { + session: { + status: async () => sessionStatusResult, + messages: async () => ({ data: [] }), + }, + }, + }) + const manager = new TmuxSessionManager(managerContext, { + enabled: true, + isolation: "inline", + layout: "main-vertical", + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + }) + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({ + directory: "/tmp", + client: { + session: {}, + }, + }), + pluginConfig: asPluginConfig({ + tmux: { enabled: true }, + }), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + tmuxSessionManager: manager, + skillMcpManager: { + disconnectSession: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + + //#when + await manager.onSessionCreated({ + type: "session.created", + properties: { + info: { + id: "ses_retry_via_plugin", + parentID: "ses_parent", + title: "Retry Via Plugin Event", + }, + }, + }) + + //#then + expect(spawnTmuxPane).toHaveBeenCalledTimes(0) + + //#when + sessionStatusData.ses_retry_via_plugin = { type: "idle" } + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_retry_via_plugin", + }, + }, + })) + await flushMicrotasks(20) + + //#then + expect(spawnTmuxPane).toHaveBeenCalledTimes(1) + }) + + it("dedups real-idle-after-synthetic-idle within 500ms", async () => { + //#given const dispatchCalls: EventInput[] = [] const eventHandler = createIdleTrackingEventHandler(dispatchCalls) const sessionId = "ses_test123" @@ -128,14 +357,70 @@ describe("createEventHandler - idle deduplication", () => { }, }, })) - expect(dispatchCalls).toHaveLength(2) + + //#then + expect(dispatchCalls).toHaveLength(1) expect(dispatchCalls[0]?.event.type).toBe("session.idle") - expect(dispatchCalls[1]?.event.type).toBe("session.idle") expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) - expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) - it("drops the synthetic idle when a real idle already arrived within 500ms", async () => { + it("dedups back-to-back real session.idle events for the same sessionID within 500ms", async () => { + //#given + const originalDateNow = Date.now + let currentNow = 10_000 + Date.now = () => currentNow + const onEvent = mock<(event: EventInput["event"]) => void>(() => {}) + const sessionNotification = mock(async (_input: EventInput) => {}) + const eventHandler = createIdleDedupSpyEventHandler({ + onEvent, + sessionNotification, + }) + const sessionId = "ses_same_idle" + + try { + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: sessionId, + }, + }, + })) + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: sessionId, + }, + }, + })) + + //#then + expect(onEvent).toHaveBeenCalledTimes(1) + expect(sessionNotification).toHaveBeenCalledTimes(1) + + //#when + currentNow += 501 + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: sessionId, + }, + }, + })) + + //#then + expect(onEvent).toHaveBeenCalledTimes(2) + expect(sessionNotification).toHaveBeenCalledTimes(2) + } finally { + Date.now = originalDateNow + } + }) + + it("still dedups synthetic-idle-after-real-idle as before", async () => { + //#given const dispatchCalls: EventInput[] = [] const eventHandler = createIdleTrackingEventHandler(dispatchCalls) const sessionId = "ses_test456" @@ -161,7 +446,47 @@ describe("createEventHandler - idle deduplication", () => { expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) - it("prunes both maps on every event", async () => { + it("does NOT dedup session.idle events for DIFFERENT sessionIDs", async () => { + //#given + const originalDateNow = Date.now + let currentNow = 20_000 + Date.now = () => currentNow + const onEvent = mock<(event: EventInput["event"]) => void>(() => {}) + const sessionNotification = mock(async (_input: EventInput) => {}) + const eventHandler = createIdleDedupSpyEventHandler({ + onEvent, + sessionNotification, + }) + + try { + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_first_idle", + }, + }, + })) + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_second_idle", + }, + }, + })) + + //#then + expect(onEvent).toHaveBeenCalledTimes(2) + expect(sessionNotification).toHaveBeenCalledTimes(2) + } finally { + Date.now = originalDateNow + } + }) + + it("both maps pruned on every event", async () => { + //#given const eventHandler = createEventHandler({ ctx: {} as any, pluginConfig: {} as any, @@ -493,8 +818,186 @@ describe("createEventHandler - event forwarding", () => { expect(createdSessions).toHaveLength(0) }) + it("skips tmux dispatch for subagent sessions marked only via subagentSessions (no parentID)", async () => { + //#given + type SessionCreatedEvent = { + type?: string + properties?: { + info?: { + id?: string + parentID?: string + title?: string + } + } + } + const onSessionCreated = mock(async (event: SessionCreatedEvent) => event) + subagentSessions.add("ses_marked_subagent") + 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: { + onSessionCreated, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.created", + properties: { info: { id: "ses_marked_subagent", title: "Child" } }, + }, + })) + + //#then + expect(onSessionCreated).not.toHaveBeenCalled() + }) + + it("still dispatches for a primary session not in subagentSessions", async () => { + //#given + type SessionCreatedEvent = { + type?: string + properties?: { + info?: { + id?: string + parentID?: string + title?: string + } + } + } + const onSessionCreated = mock(async (event: SessionCreatedEvent) => event) + 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: { + onSessionCreated, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.created", + properties: { info: { id: "ses_primary", title: "Primary" } }, + }, + })) + + //#then + expect(onSessionCreated).toHaveBeenCalledTimes(1) + expect(onSessionCreated).toHaveBeenCalledWith({ + type: "session.created", + properties: { info: { id: "ses_primary", title: "Primary" } }, + }) + }) + + it("Path A skips dispatch even when subagentSessions Set is populated only AFTER the event arrives (parentID covers it)", async () => { + //#given + type SessionCreatedEvent = { + type?: string + properties?: { + info?: { + id?: string + parentID?: string + title?: string + } + } + } + const onSessionCreated = mock(async (event: SessionCreatedEvent) => event) + 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: { + onSessionCreated, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.created", + properties: { info: { id: "ses_parent_marked", parentID: "ses_parent", title: "Child" } }, + }, + })) + + //#then + expect(onSessionCreated).not.toHaveBeenCalled() + + //#when + subagentSessions.add("ses_parent_marked") + await eventHandler(asEventHandlerInput({ + event: { + type: "session.created", + properties: { info: { id: "ses_parent_marked", title: "Child" } }, + }, + })) + + //#then + expect(onSessionCreated).not.toHaveBeenCalled() + }) + it("dispatches OpenClaw after session.created for main sessions (no parentID)", async () => { - const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null) + //#given + const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent") + openClawSpy.mockResolvedValue(null) const eventHandler = createEventHandler({ ctx: asEventHandlerContext({ directory: "/tmp/project-created" }), pluginConfig: asPluginConfig({ @@ -528,19 +1031,26 @@ describe("createEventHandler - event forwarding", () => { properties: { info: { id: "ses_openclaw_created" } }, }, })) - const [call] = openClawSpy.mock.calls[0] ?? [] - expect(call).toMatchObject({ - rawEvent: "session.created", - context: { - sessionId: "ses_openclaw_created", - projectPath: "/tmp/project-created", - tmuxPaneId: "%9", - }, + + //#then - OpenClaw dispatch called for main session + const call = openClawSpy.mock.calls[0]?.[0] as + | { + rawEvent?: string + context?: { sessionId?: string; projectPath?: string; tmuxPaneId?: string } + } + | undefined + expect(call?.rawEvent).toBe("session.created") + expect(call?.context).toEqual({ + sessionId: "ses_openclaw_created", + projectPath: "/tmp/project-created", + tmuxPaneId: "%9", }) }) - it("does not dispatch OpenClaw for subagent sessions with a parentID", async () => { - const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null) + it("does NOT dispatch OpenClaw for subagent sessions (with parentID)", async () => { + //#given + const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent") + openClawSpy.mockResolvedValue(null) const eventHandler = createEventHandler({ ctx: asEventHandlerContext({ directory: "/tmp/project-created" }), pluginConfig: asPluginConfig({ @@ -632,7 +1142,8 @@ describe("createEventHandler - event forwarding", () => { }) it("dispatches OpenClaw for synthetic session.idle events", async () => { - const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null) + const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent") + openClawSpy.mockResolvedValue(null) const eventHandler = createEventHandler({ ctx: asEventHandlerContext({ directory: "/tmp/project-idle" }), pluginConfig: asPluginConfig({ openclaw: { enabled: true, gateways: {}, hooks: {} } }), @@ -658,14 +1169,17 @@ describe("createEventHandler - event forwarding", () => { }, })) - const [call] = openClawSpy.mock.calls[0] ?? [] - expect(call).toMatchObject({ - rawEvent: "session.idle", - context: { - sessionId: "ses_openclaw_idle", - projectPath: "/tmp/project-idle", - tmuxPaneId: "%3", - }, + const call = openClawSpy.mock.calls[0]?.[0] as + | { + rawEvent?: string + context?: { sessionId?: string; projectPath?: string; tmuxPaneId?: string } + } + | undefined + expect(call?.rawEvent).toBe("session.idle") + expect(call?.context).toEqual({ + sessionId: "ses_openclaw_idle", + projectPath: "/tmp/project-idle", + tmuxPaneId: "%3", }) }) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 686f55fae..ae9a43d1f 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -6,6 +6,7 @@ import { clearSessionAgent, getMainSessionID, getSessionAgent, + resolveRegisteredAgentName, setMainSession, subagentSessions, syncSubagentSessions, @@ -37,6 +38,10 @@ import { clearSessionPromptParams } from "../shared/session-prompt-params-state" import { deleteSessionTools } from "../shared/session-tools-store"; import { lspManager } from "../tools"; import { dispatchOpenClawEvent } from "../openclaw/runtime-dispatch"; +import { createTeamIdleWakeHint } from "../hooks/team-session-events/team-idle-wake-hint"; +import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler"; +import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler"; +import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler"; import type { CreatedHooks } from "../create-hooks"; import type { Managers } from "../create-managers"; @@ -155,12 +160,22 @@ export function createEventHandler(args: { abort: (input: { path: { id: string } }) => Promise; promptAsync?: (input: { path: { id: string }; - body: { parts: Array<{ type: "text"; text: string }> }; + body: { + parts: Array<{ type: "text"; text: string }>; + agent?: string; + model?: { providerID: string; modelID: string }; + variant?: string; + }; query: { directory: string }; }) => Promise; prompt: (input: { path: { id: string }; - body: { parts: Array<{ type: "text"; text: string }> }; + body: { + parts: Array<{ type: "text"; text: string }>; + agent?: string; + model?: { providerID: string; modelID: string }; + variant?: string; + }; query: { directory: string }; }) => Promise; summarize: (...args: unknown[]) => Promise; @@ -273,7 +288,28 @@ export function createEventHandler(args: { const recentSyntheticIdles = new Map(); const recentRealIdles = new Map(); + const recentAnyIdles = new Map(); const DEDUP_WINDOW_MS = 500; + const teamModeConfig = pluginConfig.team_mode?.enabled ? pluginConfig.team_mode : undefined; + const teamLeadOrphanHandler = teamModeConfig + ? createTeamLeadOrphanHandler(teamModeConfig, managers.tmuxSessionManager, managers.backgroundManager) + : undefined; + const teamMemberErrorHandler = teamModeConfig + ? createTeamMemberErrorHandler(teamModeConfig) + : undefined; + const teamMemberStatusHandler = teamModeConfig + ? createTeamMemberStatusHandler(teamModeConfig) + : undefined; + const teamIdleWakeHint = teamModeConfig && pluginContext.client.session?.promptAsync + ? createTeamIdleWakeHint({ + directory: pluginContext.directory, + client: { + session: { + promptAsync: pluginContext.client.session.promptAsync, + }, + }, + }, teamModeConfig) + : undefined; const TMUX_ACTIVITY_EVENT_TYPES = new Set([ "message.updated", "message.part.updated", @@ -291,14 +327,52 @@ export function createEventHandler(args: { return !subagentSessions.has(sessionID); }; - const autoContinueAfterFallback = async (sessionID: string, source: string): Promise => { + const shouldDispatchIdleEvent = (sessionID: string, now: number): boolean => { + const lastDispatchedAt = recentAnyIdles.get(sessionID); + if (lastDispatchedAt !== undefined && now - lastDispatchedAt < DEDUP_WINDOW_MS) { + return false; + } + + recentAnyIdles.set(sessionID, now); + return true; + }; + + const autoContinueAfterFallback = async ( + sessionID: string, + source: string, + fallbackContext?: { + agentName?: string; + providerID?: string; + modelID?: string; + }, + ): Promise => { await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { log("[event] model-fallback abort failed", { sessionID, source, error }); }); + const launchAgent = fallbackContext?.agentName + ? resolveRegisteredAgentName(fallbackContext.agentName) + : undefined; + const launchModel = fallbackContext?.providerID && fallbackContext?.modelID + ? { providerID: fallbackContext.providerID, modelID: fallbackContext.modelID } + : undefined; + + const agentConfigKey = fallbackContext?.agentName + ? getAgentConfigKey(fallbackContext.agentName) + : undefined; + const agentSettings = agentConfigKey + ? pluginConfig.agents?.[agentConfigKey as keyof NonNullable] + : undefined; + const launchVariant = (agentSettings as { variant?: string } | undefined)?.variant; + const promptBody = { path: { id: sessionID }, - body: { parts: [{ type: "text" as const, text: "continue" }] }, + body: { + ...(launchAgent ? { agent: launchAgent } : {}), + ...(launchModel ? { model: launchModel } : {}), + ...(launchVariant ? { variant: launchVariant } : {}), + parts: [{ type: "text" as const, text: "continue" }], + }, query: { directory: pluginContext.directory }, }; @@ -318,20 +392,23 @@ export function createEventHandler(args: { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles, now: Date.now(), dedupWindowMs: DEDUP_WINDOW_MS, }); if (input.event.type === "session.idle") { - const sessionID = (input.event.properties as Record | undefined)?.sessionID as - | string - | undefined; + const sessionID = getEventSessionID(input); if (sessionID) { + const now = Date.now(); const emittedAt = recentSyntheticIdles.get(sessionID); - if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) { + if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) { recentSyntheticIdles.delete(sessionID); } - recentRealIdles.set(sessionID, Date.now()); + recentRealIdles.set(sessionID, now); + if (!shouldDispatchIdleEvent(sessionID, now)) { + return; + } } } @@ -340,12 +417,16 @@ export function createEventHandler(args: { const syntheticIdle = normalizeSessionStatusToIdle(input); if (syntheticIdle) { const sessionID = (syntheticIdle.event.properties as Record)?.sessionID as string; + const now = Date.now(); const emittedAt = recentRealIdles.get(sessionID); - if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) { + if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) { recentRealIdles.delete(sessionID); return; } - recentSyntheticIdles.set(sessionID, Date.now()); + recentSyntheticIdles.set(sessionID, now); + if (!shouldDispatchIdleEvent(sessionID, now)) { + return; + } await dispatchToHooks(syntheticIdle as EventInput); if (pluginConfig.openclaw) { await dispatchOpenClawEvent({ @@ -369,14 +450,16 @@ export function createEventHandler(args: { if (event.type === "session.created") { const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined; + const isSubagentSession = !!sessionInfo?.parentID || !!sessionInfo?.id && subagentSessions.has(sessionInfo.id); - if (!sessionInfo?.parentID) { + if (!isSubagentSession) { setMainSession(sessionInfo?.id); } firstMessageVariantGate.markSessionCreated(sessionInfo); - if (tmuxIntegrationEnabled) { + // Subagent sessions are registered by the specialized background/delegate callbacks. + if (tmuxIntegrationEnabled && !isSubagentSession) { await managers.tmuxSessionManager.onSessionCreated( event as { type: string; @@ -389,7 +472,6 @@ export function createEventHandler(args: { // Skip subagent sessions — they are dispatched by specialized callbacks // in create-managers.ts (async) and tool-registry.ts (sync) - const isSubagentSession = !!sessionInfo?.parentID; if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, @@ -449,6 +531,9 @@ export function createEventHandler(args: { }); } } + + await runEventHookSafely("teamLeadOrphanHandler", teamLeadOrphanHandler, input); + await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input); } if (event.type === "message.removed") { @@ -472,6 +557,12 @@ export function createEventHandler(args: { } } + if (event.type === "session.idle") { + managers.tmuxSessionManager?.onEvent?.(event); + await runEventHookSafely("teamIdleWakeHint", teamIdleWakeHint, input); + await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input); + } + if (event.type === "message.updated") { const info = props?.info as Record | undefined; const sessionID = info?.sessionID as string | undefined; @@ -541,7 +632,11 @@ export function createEventHandler(args: { !hooks.stopContinuationGuard?.isStopped(sessionID) ) { lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); - await autoContinueAfterFallback(sessionID, "message.updated"); + await autoContinueAfterFallback(sessionID, "message.updated", { + agentName, + providerID: currentProvider, + modelID: currentModel, + }); } } } @@ -605,7 +700,11 @@ export function createEventHandler(args: { shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID) ) { - await autoContinueAfterFallback(sessionID, "session.status"); + await autoContinueAfterFallback(sessionID, "session.status", { + agentName, + providerID: currentProvider, + modelID: currentModel, + }); } } } @@ -693,7 +792,11 @@ export function createEventHandler(args: { shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID) ) { - await autoContinueAfterFallback(sessionID, "session.error"); + await autoContinueAfterFallback(sessionID, "session.error", { + agentName, + providerID: currentProvider, + modelID: currentModel, + }); } } } @@ -701,6 +804,8 @@ export function createEventHandler(args: { const sessionID = props?.sessionID as string | undefined; log("[event] model-fallback error in session.error:", { sessionID, error: err }); } + + await runEventHookSafely("teamMemberErrorHandler", teamMemberErrorHandler, input); } }; } diff --git a/src/plugin/recent-synthetic-idles.test.ts b/src/plugin/recent-synthetic-idles.test.ts index 0c944cccb..e3edaa851 100644 --- a/src/plugin/recent-synthetic-idles.test.ts +++ b/src/plugin/recent-synthetic-idles.test.ts @@ -15,6 +15,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 500, }) @@ -36,6 +37,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 100, }) @@ -55,6 +57,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 500, }) @@ -77,6 +80,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 500, }) @@ -102,6 +106,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 500, }) @@ -127,6 +132,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 500, }) @@ -158,6 +164,7 @@ describe("pruneRecentSyntheticIdles", () => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, + recentAnyIdles: new Map(), now: 2000, dedupWindowMs: 500, }) diff --git a/src/plugin/recent-synthetic-idles.ts b/src/plugin/recent-synthetic-idles.ts index 200030444..e2aa82fbd 100644 --- a/src/plugin/recent-synthetic-idles.ts +++ b/src/plugin/recent-synthetic-idles.ts @@ -1,10 +1,11 @@ export function pruneRecentSyntheticIdles(args: { recentSyntheticIdles: Map recentRealIdles: Map + recentAnyIdles: Map now: number dedupWindowMs: number }): void { - const { recentSyntheticIdles, recentRealIdles, now, dedupWindowMs } = args + const { recentSyntheticIdles, recentRealIdles, recentAnyIdles, now, dedupWindowMs } = args for (const [sessionID, emittedAt] of recentSyntheticIdles) { if (now - emittedAt >= dedupWindowMs) { @@ -17,4 +18,10 @@ export function pruneRecentSyntheticIdles(args: { recentRealIdles.delete(sessionID) } } + + for (const [sessionID, emittedAt] of recentAnyIdles) { + if (now - emittedAt >= dedupWindowMs) { + recentAnyIdles.delete(sessionID) + } + } }