feat(plugin): integrate team-mode into session events and synthetic idles
This commit is contained in:
@@ -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<string, any> = {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
+541
-27
@@ -1,9 +1,11 @@
|
|||||||
|
/// <reference path="../../bun-test.d.ts" />
|
||||||
import { describe, it, expect, afterEach, mock, spyOn } from "bun:test"
|
import { describe, it, expect, afterEach, mock, spyOn } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import { createEventHandler, extractErrorMessage } from "./event"
|
import { createEventHandler, extractErrorMessage } from "./event"
|
||||||
import { createChatMessageHandler } from "./chat-message"
|
import { createChatMessageHandler } from "./chat-message"
|
||||||
import * as openclawRuntimeDispatch from "../openclaw/runtime-dispatch"
|
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 { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
||||||
import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state"
|
import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state"
|
||||||
|
|
||||||
@@ -36,11 +38,16 @@ function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConf
|
|||||||
return cast<ChatMessageHandlerArgs["pluginConfig"]>(config)
|
return cast<ChatMessageHandlerArgs["pluginConfig"]>(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asPluginInput(input: unknown): PluginInput {
|
||||||
|
return input as PluginInput
|
||||||
|
}
|
||||||
|
|
||||||
function createEventHandlerManagers(
|
function createEventHandlerManagers(
|
||||||
overrides: Record<string, unknown> = {},
|
overrides: Record<string, unknown> = {},
|
||||||
): EventHandlerArgs["managers"] {
|
): EventHandlerArgs["managers"] {
|
||||||
return cast<EventHandlerArgs["managers"]>({
|
return cast<EventHandlerArgs["managers"]>({
|
||||||
tmuxSessionManager: {
|
tmuxSessionManager: {
|
||||||
|
onEvent: () => {},
|
||||||
onSessionCreated: async () => {},
|
onSessionCreated: async () => {},
|
||||||
onSessionDeleted: async () => {},
|
onSessionDeleted: async () => {},
|
||||||
},
|
},
|
||||||
@@ -89,6 +96,43 @@ function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createIdleDedupSpyEventHandler(args: {
|
||||||
|
onEvent: (event: EventInput["event"]) => void
|
||||||
|
sessionNotification: (input: EventInput) => Promise<void>
|
||||||
|
}): ReturnType<typeof createEventHandler> {
|
||||||
|
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<void> {
|
||||||
|
for (let index = 0; index < turns; index += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
@@ -107,7 +151,192 @@ describe("event error extraction", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("createEventHandler - idle deduplication", () => {
|
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<string, { type: string }> = {}
|
||||||
|
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 dispatchCalls: EventInput[] = []
|
||||||
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
||||||
const sessionId = "ses_test123"
|
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[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[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 dispatchCalls: EventInput[] = []
|
||||||
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
||||||
const sessionId = "ses_test456"
|
const sessionId = "ses_test456"
|
||||||
@@ -161,7 +446,47 @@ describe("createEventHandler - idle deduplication", () => {
|
|||||||
expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
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({
|
const eventHandler = createEventHandler({
|
||||||
ctx: {} as any,
|
ctx: {} as any,
|
||||||
pluginConfig: {} as any,
|
pluginConfig: {} as any,
|
||||||
@@ -493,8 +818,186 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
expect(createdSessions).toHaveLength(0)
|
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 () => {
|
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({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
|
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
|
||||||
pluginConfig: asPluginConfig({
|
pluginConfig: asPluginConfig({
|
||||||
@@ -528,19 +1031,26 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
properties: { info: { id: "ses_openclaw_created" } },
|
properties: { info: { id: "ses_openclaw_created" } },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
const [call] = openClawSpy.mock.calls[0] ?? []
|
|
||||||
expect(call).toMatchObject({
|
//#then - OpenClaw dispatch called for main session
|
||||||
rawEvent: "session.created",
|
const call = openClawSpy.mock.calls[0]?.[0] as
|
||||||
context: {
|
| {
|
||||||
sessionId: "ses_openclaw_created",
|
rawEvent?: string
|
||||||
projectPath: "/tmp/project-created",
|
context?: { sessionId?: string; projectPath?: string; tmuxPaneId?: string }
|
||||||
tmuxPaneId: "%9",
|
}
|
||||||
},
|
| 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 () => {
|
it("does NOT dispatch OpenClaw for subagent sessions (with parentID)", async () => {
|
||||||
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null)
|
//#given
|
||||||
|
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent")
|
||||||
|
openClawSpy.mockResolvedValue(null)
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
|
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
|
||||||
pluginConfig: asPluginConfig({
|
pluginConfig: asPluginConfig({
|
||||||
@@ -632,7 +1142,8 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("dispatches OpenClaw for synthetic session.idle events", async () => {
|
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({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({ directory: "/tmp/project-idle" }),
|
ctx: asEventHandlerContext({ directory: "/tmp/project-idle" }),
|
||||||
pluginConfig: asPluginConfig({ openclaw: { enabled: true, gateways: {}, hooks: {} } }),
|
pluginConfig: asPluginConfig({ openclaw: { enabled: true, gateways: {}, hooks: {} } }),
|
||||||
@@ -658,14 +1169,17 @@ describe("createEventHandler - event forwarding", () => {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const [call] = openClawSpy.mock.calls[0] ?? []
|
const call = openClawSpy.mock.calls[0]?.[0] as
|
||||||
expect(call).toMatchObject({
|
| {
|
||||||
rawEvent: "session.idle",
|
rawEvent?: string
|
||||||
context: {
|
context?: { sessionId?: string; projectPath?: string; tmuxPaneId?: string }
|
||||||
sessionId: "ses_openclaw_idle",
|
}
|
||||||
projectPath: "/tmp/project-idle",
|
| undefined
|
||||||
tmuxPaneId: "%3",
|
expect(call?.rawEvent).toBe("session.idle")
|
||||||
},
|
expect(call?.context).toEqual({
|
||||||
|
sessionId: "ses_openclaw_idle",
|
||||||
|
projectPath: "/tmp/project-idle",
|
||||||
|
tmuxPaneId: "%3",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+122
-17
@@ -6,6 +6,7 @@ import {
|
|||||||
clearSessionAgent,
|
clearSessionAgent,
|
||||||
getMainSessionID,
|
getMainSessionID,
|
||||||
getSessionAgent,
|
getSessionAgent,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
setMainSession,
|
setMainSession,
|
||||||
subagentSessions,
|
subagentSessions,
|
||||||
syncSubagentSessions,
|
syncSubagentSessions,
|
||||||
@@ -37,6 +38,10 @@ import { clearSessionPromptParams } from "../shared/session-prompt-params-state"
|
|||||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||||
import { lspManager } from "../tools";
|
import { lspManager } from "../tools";
|
||||||
import { dispatchOpenClawEvent } from "../openclaw/runtime-dispatch";
|
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 { CreatedHooks } from "../create-hooks";
|
||||||
import type { Managers } from "../create-managers";
|
import type { Managers } from "../create-managers";
|
||||||
@@ -155,12 +160,22 @@ export function createEventHandler(args: {
|
|||||||
abort: (input: { path: { id: string } }) => Promise<unknown>;
|
abort: (input: { path: { id: string } }) => Promise<unknown>;
|
||||||
promptAsync?: (input: {
|
promptAsync?: (input: {
|
||||||
path: { id: string };
|
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 };
|
query: { directory: string };
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
prompt: (input: {
|
prompt: (input: {
|
||||||
path: { id: string };
|
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 };
|
query: { directory: string };
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
summarize: (...args: unknown[]) => Promise<unknown>;
|
summarize: (...args: unknown[]) => Promise<unknown>;
|
||||||
@@ -273,7 +288,28 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
const recentSyntheticIdles = new Map<string, number>();
|
const recentSyntheticIdles = new Map<string, number>();
|
||||||
const recentRealIdles = new Map<string, number>();
|
const recentRealIdles = new Map<string, number>();
|
||||||
|
const recentAnyIdles = new Map<string, number>();
|
||||||
const DEDUP_WINDOW_MS = 500;
|
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([
|
const TMUX_ACTIVITY_EVENT_TYPES = new Set([
|
||||||
"message.updated",
|
"message.updated",
|
||||||
"message.part.updated",
|
"message.part.updated",
|
||||||
@@ -291,14 +327,52 @@ export function createEventHandler(args: {
|
|||||||
return !subagentSessions.has(sessionID);
|
return !subagentSessions.has(sessionID);
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoContinueAfterFallback = async (sessionID: string, source: string): Promise<void> => {
|
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<void> => {
|
||||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => {
|
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => {
|
||||||
log("[event] model-fallback abort failed", { sessionID, source, 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<typeof pluginConfig.agents>]
|
||||||
|
: undefined;
|
||||||
|
const launchVariant = (agentSettings as { variant?: string } | undefined)?.variant;
|
||||||
|
|
||||||
const promptBody = {
|
const promptBody = {
|
||||||
path: { id: sessionID },
|
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 },
|
query: { directory: pluginContext.directory },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -318,20 +392,23 @@ export function createEventHandler(args: {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles,
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
dedupWindowMs: DEDUP_WINDOW_MS,
|
dedupWindowMs: DEDUP_WINDOW_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (input.event.type === "session.idle") {
|
if (input.event.type === "session.idle") {
|
||||||
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as
|
const sessionID = getEventSessionID(input);
|
||||||
| string
|
|
||||||
| undefined;
|
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
|
const now = Date.now();
|
||||||
const emittedAt = recentSyntheticIdles.get(sessionID);
|
const emittedAt = recentSyntheticIdles.get(sessionID);
|
||||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) {
|
||||||
recentSyntheticIdles.delete(sessionID);
|
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);
|
const syntheticIdle = normalizeSessionStatusToIdle(input);
|
||||||
if (syntheticIdle) {
|
if (syntheticIdle) {
|
||||||
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
|
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
|
||||||
|
const now = Date.now();
|
||||||
const emittedAt = recentRealIdles.get(sessionID);
|
const emittedAt = recentRealIdles.get(sessionID);
|
||||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) {
|
||||||
recentRealIdles.delete(sessionID);
|
recentRealIdles.delete(sessionID);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recentSyntheticIdles.set(sessionID, Date.now());
|
recentSyntheticIdles.set(sessionID, now);
|
||||||
|
if (!shouldDispatchIdleEvent(sessionID, now)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await dispatchToHooks(syntheticIdle as EventInput);
|
await dispatchToHooks(syntheticIdle as EventInput);
|
||||||
if (pluginConfig.openclaw) {
|
if (pluginConfig.openclaw) {
|
||||||
await dispatchOpenClawEvent({
|
await dispatchOpenClawEvent({
|
||||||
@@ -369,14 +450,16 @@ 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);
|
||||||
|
|
||||||
if (!sessionInfo?.parentID) {
|
if (!isSubagentSession) {
|
||||||
setMainSession(sessionInfo?.id);
|
setMainSession(sessionInfo?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
||||||
|
|
||||||
if (tmuxIntegrationEnabled) {
|
// Subagent sessions are registered by the specialized background/delegate callbacks.
|
||||||
|
if (tmuxIntegrationEnabled && !isSubagentSession) {
|
||||||
await managers.tmuxSessionManager.onSessionCreated(
|
await managers.tmuxSessionManager.onSessionCreated(
|
||||||
event as {
|
event as {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -389,7 +472,6 @@ 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)
|
||||||
const isSubagentSession = !!sessionInfo?.parentID;
|
|
||||||
if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) {
|
if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) {
|
||||||
await dispatchOpenClawEvent({
|
await dispatchOpenClawEvent({
|
||||||
config: pluginConfig.openclaw,
|
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") {
|
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") {
|
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 = info?.sessionID as string | undefined;
|
||||||
@@ -541,7 +632,11 @@ export function createEventHandler(args: {
|
|||||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||||
) {
|
) {
|
||||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
|
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) &&
|
shouldAutoRetrySession(sessionID) &&
|
||||||
!hooks.stopContinuationGuard?.isStopped(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) &&
|
shouldAutoRetrySession(sessionID) &&
|
||||||
!hooks.stopContinuationGuard?.isStopped(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;
|
const sessionID = props?.sessionID as string | undefined;
|
||||||
log("[event] model-fallback error in session.error:", { sessionID, error: err });
|
log("[event] model-fallback error in session.error:", { sessionID, error: err });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await runEventHookSafely("teamMemberErrorHandler", teamMemberErrorHandler, input);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 500,
|
dedupWindowMs: 500,
|
||||||
})
|
})
|
||||||
@@ -36,6 +37,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 100,
|
dedupWindowMs: 100,
|
||||||
})
|
})
|
||||||
@@ -55,6 +57,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 500,
|
dedupWindowMs: 500,
|
||||||
})
|
})
|
||||||
@@ -77,6 +80,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 500,
|
dedupWindowMs: 500,
|
||||||
})
|
})
|
||||||
@@ -102,6 +106,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 500,
|
dedupWindowMs: 500,
|
||||||
})
|
})
|
||||||
@@ -127,6 +132,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 500,
|
dedupWindowMs: 500,
|
||||||
})
|
})
|
||||||
@@ -158,6 +164,7 @@ describe("pruneRecentSyntheticIdles", () => {
|
|||||||
pruneRecentSyntheticIdles({
|
pruneRecentSyntheticIdles({
|
||||||
recentSyntheticIdles,
|
recentSyntheticIdles,
|
||||||
recentRealIdles,
|
recentRealIdles,
|
||||||
|
recentAnyIdles: new Map<string, number>(),
|
||||||
now: 2000,
|
now: 2000,
|
||||||
dedupWindowMs: 500,
|
dedupWindowMs: 500,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export function pruneRecentSyntheticIdles(args: {
|
export function pruneRecentSyntheticIdles(args: {
|
||||||
recentSyntheticIdles: Map<string, number>
|
recentSyntheticIdles: Map<string, number>
|
||||||
recentRealIdles: Map<string, number>
|
recentRealIdles: Map<string, number>
|
||||||
|
recentAnyIdles: Map<string, number>
|
||||||
now: number
|
now: number
|
||||||
dedupWindowMs: number
|
dedupWindowMs: number
|
||||||
}): void {
|
}): void {
|
||||||
const { recentSyntheticIdles, recentRealIdles, now, dedupWindowMs } = args
|
const { recentSyntheticIdles, recentRealIdles, recentAnyIdles, now, dedupWindowMs } = args
|
||||||
|
|
||||||
for (const [sessionID, emittedAt] of recentSyntheticIdles) {
|
for (const [sessionID, emittedAt] of recentSyntheticIdles) {
|
||||||
if (now - emittedAt >= dedupWindowMs) {
|
if (now - emittedAt >= dedupWindowMs) {
|
||||||
@@ -17,4 +18,10 @@ export function pruneRecentSyntheticIdles(args: {
|
|||||||
recentRealIdles.delete(sessionID)
|
recentRealIdles.delete(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [sessionID, emittedAt] of recentAnyIdles) {
|
||||||
|
if (now - emittedAt >= dedupWindowMs) {
|
||||||
|
recentAnyIdles.delete(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user