fix(delegate-task): Wave 1 - fix polling timeout, resource cleanup, tool restrictions, idle dedup, auth-plugins JSONC, CLI runner hang
- fix(delegate-task): return error on poll timeout instead of silent null - fix(delegate-task): ensure toast and session cleanup on all error paths with try/finally - fix(delegate-task): apply agent tool restrictions in sync-prompt-sender - fix(plugin): add symmetric idle dedup to prevent double hook triggers - fix(cli): replace regex-based JSONC editing with jsonc-parser in auth-plugins - fix(cli): abort event stream after completion and restore no-timeout default All changes verified with tests and typecheck.
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
|
||||
import { createEventHandler } from "./event"
|
||||
|
||||
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
|
||||
|
||||
describe("createEventHandler - idle deduplication", () => {
|
||||
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
|
||||
//#given
|
||||
const dispatchCalls: EventInput[] = []
|
||||
const mockDispatchToHooks = async (input: EventInput) => {
|
||||
if (input.event.type === "session.idle") {
|
||||
dispatchCalls.push(input)
|
||||
}
|
||||
}
|
||||
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
autoUpdateChecker: { event: mockDispatchToHooks as any },
|
||||
claudeCodeHooks: { event: async () => {} },
|
||||
backgroundNotificationHook: { event: async () => {} },
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
thinkMode: { event: async () => {} },
|
||||
anthropicContextWindowLimitRecovery: { event: async () => {} },
|
||||
agentUsageReminder: { event: async () => {} },
|
||||
categorySkillReminder: { event: async () => {} },
|
||||
interactiveBashSession: { event: async () => {} },
|
||||
ralphLoop: { event: async () => {} },
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
} as any,
|
||||
})
|
||||
|
||||
const sessionId = "ses_test123"
|
||||
|
||||
//#when - session.status with idle (generates synthetic idle first)
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - synthetic idle dispatched once
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||
|
||||
//#when - real session.idle arrives
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - real idle deduped, no additional dispatch
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it("Order B (idle→status): real idle deduped - synthetic idle not dispatched", async () => {
|
||||
//#given
|
||||
const dispatchCalls: EventInput[] = []
|
||||
const mockDispatchToHooks = async (input: EventInput) => {
|
||||
if (input.event.type === "session.idle") {
|
||||
dispatchCalls.push(input)
|
||||
}
|
||||
}
|
||||
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
autoUpdateChecker: { event: mockDispatchToHooks as any },
|
||||
claudeCodeHooks: { event: async () => {} },
|
||||
backgroundNotificationHook: { event: async () => {} },
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
thinkMode: { event: async () => {} },
|
||||
anthropicContextWindowLimitRecovery: { event: async () => {} },
|
||||
agentUsageReminder: { event: async () => {} },
|
||||
categorySkillReminder: { event: async () => {} },
|
||||
interactiveBashSession: { event: async () => {} },
|
||||
ralphLoop: { event: async () => {} },
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
} as any,
|
||||
})
|
||||
|
||||
const sessionId = "ses_test456"
|
||||
|
||||
//#when - real session.idle arrives first
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - real idle dispatched once
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
|
||||
|
||||
//#when - session.status with idle (generates synthetic idle)
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - synthetic idle deduped, no additional dispatch
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it("both maps pruned on every event", async () => {
|
||||
//#given
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
autoUpdateChecker: { event: async () => {} },
|
||||
claudeCodeHooks: { event: async () => {} },
|
||||
backgroundNotificationHook: { event: async () => {} },
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
thinkMode: { event: async () => {} },
|
||||
anthropicContextWindowLimitRecovery: { event: async () => {} },
|
||||
agentUsageReminder: { event: async () => {} },
|
||||
categorySkillReminder: { event: async () => {} },
|
||||
interactiveBashSession: { event: async () => {} },
|
||||
ralphLoop: { event: async () => {} },
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
} as any,
|
||||
})
|
||||
|
||||
// Trigger some synthetic idles
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "ses_stale_1",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "ses_stale_2",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger some real idles
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "ses_stale_3",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "ses_stale_4",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when - wait for dedup window to expire (600ms > 500ms)
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
// Trigger any event to trigger pruning
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
},
|
||||
})
|
||||
|
||||
//#then - both maps should be pruned (no dedup should occur for new events)
|
||||
// We verify by checking that a new idle event for same session is dispatched
|
||||
const dispatchCalls: EventInput[] = []
|
||||
const eventHandlerWithMock = createEventHandler({
|
||||
ctx: {} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
autoUpdateChecker: {
|
||||
event: async (input: EventInput) => {
|
||||
dispatchCalls.push(input)
|
||||
},
|
||||
},
|
||||
claudeCodeHooks: { event: async () => {} },
|
||||
backgroundNotificationHook: { event: async () => {} },
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
thinkMode: { event: async () => {} },
|
||||
anthropicContextWindowLimitRecovery: { event: async () => {} },
|
||||
agentUsageReminder: { event: async () => {} },
|
||||
categorySkillReminder: { event: async () => {} },
|
||||
interactiveBashSession: { event: async () => {} },
|
||||
ralphLoop: { event: async () => {} },
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
},
|
||||
})
|
||||
|
||||
await eventHandlerWithMock({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "ses_stale_1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
})
|
||||
|
||||
it("dedup only applies within window - outside window both dispatch", async () => {
|
||||
//#given
|
||||
const dispatchCalls: EventInput[] = []
|
||||
const eventHandler = createEventHandler({
|
||||
ctx: {} as any,
|
||||
pluginConfig: {} as any,
|
||||
firstMessageVariantGate: {
|
||||
markSessionCreated: () => {},
|
||||
clear: () => {},
|
||||
},
|
||||
managers: {
|
||||
tmuxSessionManager: {
|
||||
onSessionCreated: async () => {},
|
||||
onSessionDeleted: async () => {},
|
||||
},
|
||||
} as any,
|
||||
hooks: {
|
||||
autoUpdateChecker: {
|
||||
event: async (input: EventInput) => {
|
||||
if (input.event.type === "session.idle") {
|
||||
dispatchCalls.push(input)
|
||||
}
|
||||
},
|
||||
},
|
||||
claudeCodeHooks: { event: async () => {} },
|
||||
backgroundNotificationHook: { event: async () => {} },
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
thinkMode: { event: async () => {} },
|
||||
anthropicContextWindowLimitRecovery: { event: async () => {} },
|
||||
agentUsageReminder: { event: async () => {} },
|
||||
categorySkillReminder: { event: async () => {} },
|
||||
interactiveBashSession: { event: async () => {} },
|
||||
ralphLoop: { event: async () => {} },
|
||||
stopContinuationGuard: { event: async () => {} },
|
||||
compactionTodoPreserver: { event: async () => {} },
|
||||
atlasHook: { handler: async () => {} },
|
||||
} as any,
|
||||
})
|
||||
|
||||
const sessionId = "ses_outside_window"
|
||||
|
||||
//#when - synthetic idle first
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
status: { type: "idle" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - synthetic dispatched
|
||||
expect(dispatchCalls.length).toBe(1)
|
||||
|
||||
//#when - wait for dedup window to expire (600ms > 500ms)
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
//#when - real idle arrives outside window
|
||||
await eventHandler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then - real idle dispatched (outside dedup window)
|
||||
expect(dispatchCalls.length).toBe(2)
|
||||
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
||||
expect(dispatchCalls[1].event.type).toBe("session.idle")
|
||||
})
|
||||
})
|
||||
@@ -52,11 +52,13 @@ export function createEventHandler(args: {
|
||||
}
|
||||
|
||||
const recentSyntheticIdles = new Map<string, number>()
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
const DEDUP_WINDOW_MS = 500
|
||||
|
||||
return async (input): Promise<void> => {
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: Date.now(),
|
||||
dedupWindowMs: DEDUP_WINDOW_MS,
|
||||
})
|
||||
@@ -69,6 +71,7 @@ export function createEventHandler(args: {
|
||||
recentSyntheticIdles.delete(sessionID)
|
||||
return
|
||||
}
|
||||
recentRealIdles.set(sessionID, Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +80,11 @@ export function createEventHandler(args: {
|
||||
const syntheticIdle = normalizeSessionStatusToIdle(input)
|
||||
if (syntheticIdle) {
|
||||
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
|
||||
const emittedAt = recentRealIdles.get(sessionID)
|
||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
||||
recentRealIdles.delete(sessionID)
|
||||
return
|
||||
}
|
||||
recentSyntheticIdles.set(sessionID, Date.now())
|
||||
await dispatchToHooks(syntheticIdle)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
["ses_old", 1000],
|
||||
["ses_new", 1600],
|
||||
])
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 500,
|
||||
})
|
||||
@@ -28,10 +30,12 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
["ses_fresh_1", 1950],
|
||||
["ses_fresh_2", 1980],
|
||||
])
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 100,
|
||||
})
|
||||
@@ -45,10 +49,12 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
it("handles empty Map without crashing (no-op on empty)", () => {
|
||||
//#given
|
||||
const recentSyntheticIdles = new Map<string, number>()
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 500,
|
||||
})
|
||||
@@ -65,10 +71,12 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
["ses_stale_2", 1200],
|
||||
["ses_fresh_2", 1980],
|
||||
])
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 500,
|
||||
})
|
||||
@@ -88,10 +96,12 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
["ses_old_2", 800],
|
||||
["ses_old_3", 1200],
|
||||
])
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 500,
|
||||
})
|
||||
@@ -111,10 +121,12 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
recentSyntheticIdles.set(`ses_fresh_${i}`, 1950 + i)
|
||||
}
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 500,
|
||||
})
|
||||
@@ -130,4 +142,32 @@ describe("pruneRecentSyntheticIdles", () => {
|
||||
expect(recentSyntheticIdles.has(`ses_fresh_${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("prunes both synthetic and real idle maps (dual map pruning)", () => {
|
||||
//#given
|
||||
const recentSyntheticIdles = new Map<string, number>([
|
||||
["synthetic_old", 1000],
|
||||
["synthetic_new", 1600],
|
||||
])
|
||||
const recentRealIdles = new Map<string, number>([
|
||||
["real_old", 1000],
|
||||
["real_new", 1600],
|
||||
])
|
||||
|
||||
//#when
|
||||
pruneRecentSyntheticIdles({
|
||||
recentSyntheticIdles,
|
||||
recentRealIdles,
|
||||
now: 2000,
|
||||
dedupWindowMs: 500,
|
||||
})
|
||||
|
||||
//#then - both maps pruned
|
||||
expect(recentSyntheticIdles.has("synthetic_old")).toBe(false)
|
||||
expect(recentSyntheticIdles.has("synthetic_new")).toBe(true)
|
||||
expect(recentRealIdles.has("real_old")).toBe(false)
|
||||
expect(recentRealIdles.has("real_new")).toBe(true)
|
||||
expect(recentSyntheticIdles.size).toBe(1)
|
||||
expect(recentRealIdles.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
export function pruneRecentSyntheticIdles(args: {
|
||||
recentSyntheticIdles: Map<string, number>
|
||||
recentRealIdles: Map<string, number>
|
||||
now: number
|
||||
dedupWindowMs: number
|
||||
}): void {
|
||||
const { recentSyntheticIdles, now, dedupWindowMs } = args
|
||||
const { recentSyntheticIdles, recentRealIdles, now, dedupWindowMs } = args
|
||||
|
||||
for (const [sessionID, emittedAt] of recentSyntheticIdles) {
|
||||
if (now - emittedAt >= dedupWindowMs) {
|
||||
recentSyntheticIdles.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, emittedAt] of recentRealIdles) {
|
||||
if (now - emittedAt >= dedupWindowMs) {
|
||||
recentRealIdles.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user