fix(todo-continuation-enforcer): simplify implementation and remove 10s throttle blocking background task completion
Removes the complex state machine and 10-second throttle (MIN_INJECTION_INTERVAL_MS) that was causing background task completion to hang. The hook now: - Uses straightforward error cooldown logic instead of complex injection throttling - Removes unnecessary state tracking that was delaying continuation injection - Maintains all safety checks (recovery mode, running tasks, error state) - Keeps countdown behavior with toast notifications Fixes #312 - Resolves v2.7.0 issue where background task completion would freeze the agent due to injection delays. 🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
383
src/hooks/todo-continuation-enforcer.test.ts
Normal file
383
src/hooks/todo-continuation-enforcer.test.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||||
|
|
||||||
|
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||||
|
import { setMainSession } from "../features/claude-code-session-state"
|
||||||
|
import type { BackgroundManager } from "../features/background-agent"
|
||||||
|
|
||||||
|
describe("todo-continuation-enforcer", () => {
|
||||||
|
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||||
|
let toastCalls: Array<{ title: string; message: string }>
|
||||||
|
|
||||||
|
function createMockPluginInput() {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
todo: async () => ({ data: [
|
||||||
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
|
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||||
|
]}),
|
||||||
|
prompt: async (opts: any) => {
|
||||||
|
promptCalls.push({
|
||||||
|
sessionID: opts.path.id,
|
||||||
|
agent: opts.body.agent,
|
||||||
|
text: opts.body.parts[0].text,
|
||||||
|
})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async (opts: any) => {
|
||||||
|
toastCalls.push({
|
||||||
|
title: opts.body.title,
|
||||||
|
message: opts.body.message,
|
||||||
|
})
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/tmp/test",
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||||
|
return {
|
||||||
|
getTasksByParentSession: () => runningTasks
|
||||||
|
? [{ status: "running" }]
|
||||||
|
: [],
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
promptCalls = []
|
||||||
|
toastCalls = []
|
||||||
|
setMainSession(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setMainSession(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should inject continuation when idle with incomplete todos", async () => {
|
||||||
|
// #given - main session with incomplete todos
|
||||||
|
const sessionID = "main-123"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
|
backgroundManager: createMockBackgroundManager(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then - countdown toast shown
|
||||||
|
await new Promise(r => setTimeout(r, 100))
|
||||||
|
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(toastCalls[0].title).toBe("Todo Continuation")
|
||||||
|
|
||||||
|
// #then - after countdown, continuation injected
|
||||||
|
await new Promise(r => setTimeout(r, 2500))
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not inject when all todos are complete", async () => {
|
||||||
|
// #given - session with all todos complete
|
||||||
|
const sessionID = "main-456"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const mockInput = createMockPluginInput()
|
||||||
|
mockInput.client.session.todo = async () => ({ data: [
|
||||||
|
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
|
||||||
|
]})
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not inject when background tasks are running", async () => {
|
||||||
|
// #given - session with running background tasks
|
||||||
|
const sessionID = "main-789"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||||
|
backgroundManager: createMockBackgroundManager(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not inject for non-main session", async () => {
|
||||||
|
// #given - main session set, different session goes idle
|
||||||
|
setMainSession("main-session")
|
||||||
|
const otherSession = "other-session"
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - non-main session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should skip injection after recent error", async () => {
|
||||||
|
// #given - session that just had an error
|
||||||
|
const sessionID = "main-error"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - session error occurs
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - session goes idle immediately after
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected (error cooldown)
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should clear error state on user message and allow injection", async () => {
|
||||||
|
// #given - session with error, then user clears it
|
||||||
|
const sessionID = "main-error-clear"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - error occurs
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.error", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - user sends message (clears error immediately)
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 2500))
|
||||||
|
|
||||||
|
// #then - continuation injected (error was cleared by user message)
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should cancel countdown on user message", async () => {
|
||||||
|
// #given - session starting countdown
|
||||||
|
const sessionID = "main-cancel"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - user sends message immediately (before 2s countdown)
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: { info: { sessionID, role: "user" } }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then - wait past countdown time and verify no injection
|
||||||
|
await new Promise(r => setTimeout(r, 2500))
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should cancel countdown on assistant activity", async () => {
|
||||||
|
// #given - session starting countdown
|
||||||
|
const sessionID = "main-assistant"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - assistant starts responding
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
await hook.handler({
|
||||||
|
event: {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: { info: { sessionID, role: "assistant" } }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected (cancelled)
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should cancel countdown on tool execution", async () => {
|
||||||
|
// #given - session starting countdown
|
||||||
|
const sessionID = "main-tool"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - tool starts executing
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "tool.execute.before", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected (cancelled)
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should skip injection during recovery mode", async () => {
|
||||||
|
// #given - session in recovery mode
|
||||||
|
const sessionID = "main-recovery"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - mark as recovering
|
||||||
|
hook.markRecovering(sessionID)
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should inject after recovery complete", async () => {
|
||||||
|
// #given - session was in recovery, now complete
|
||||||
|
const sessionID = "main-recovery-done"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - mark as recovering then complete
|
||||||
|
hook.markRecovering(sessionID)
|
||||||
|
hook.markRecoveryComplete(sessionID)
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - continuation injected
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should cleanup on session deleted", async () => {
|
||||||
|
// #given - session starting countdown
|
||||||
|
const sessionID = "main-delete"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #when - session is deleted during countdown
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
// #then - no continuation injected (cleaned up)
|
||||||
|
expect(promptCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should show countdown toast updates", async () => {
|
||||||
|
// #given - session with incomplete todos
|
||||||
|
const sessionID = "main-toast"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - session goes idle
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
|
||||||
|
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
|
||||||
|
await new Promise(r => setTimeout(r, 2500))
|
||||||
|
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(toastCalls[0].message).toContain("2s")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not have 10s throttle between injections", async () => {
|
||||||
|
// #given - new hook instance (no prior state)
|
||||||
|
const sessionID = "main-no-throttle"
|
||||||
|
setMainSession(sessionID)
|
||||||
|
|
||||||
|
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||||
|
|
||||||
|
// #when - first idle cycle completes
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
await new Promise(r => setTimeout(r, 2500))
|
||||||
|
|
||||||
|
// #then - first injection happened
|
||||||
|
expect(promptCalls.length).toBe(1)
|
||||||
|
|
||||||
|
// #when - immediately trigger second idle (no 10s wait needed)
|
||||||
|
await hook.handler({
|
||||||
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
|
})
|
||||||
|
await new Promise(r => setTimeout(r, 2500))
|
||||||
|
|
||||||
|
// #then - second injection also happened (no throttle blocking)
|
||||||
|
expect(promptCalls.length).toBe(2)
|
||||||
|
}, { timeout: 10000 })
|
||||||
|
})
|
||||||
@@ -28,6 +28,13 @@ interface Todo {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
lastErrorAt?: number
|
||||||
|
countdownTimer?: ReturnType<typeof setTimeout>
|
||||||
|
countdownInterval?: ReturnType<typeof setInterval>
|
||||||
|
isRecovering?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||||
|
|
||||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||||
@@ -38,29 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
|||||||
|
|
||||||
const COUNTDOWN_SECONDS = 2
|
const COUNTDOWN_SECONDS = 2
|
||||||
const TOAST_DURATION_MS = 900
|
const TOAST_DURATION_MS = 900
|
||||||
const MIN_INJECTION_INTERVAL_MS = 10_000
|
const ERROR_COOLDOWN_MS = 3_000
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// STATE MACHINE TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type SessionMode =
|
|
||||||
| "idle" // Observed idle, no countdown started yet
|
|
||||||
| "countingDown" // Waiting N seconds before injecting
|
|
||||||
| "injecting" // Currently calling session.prompt
|
|
||||||
| "recovering" // Session recovery in progress (external control)
|
|
||||||
| "errorBypass" // Bypass mode after session.error/interrupt
|
|
||||||
|
|
||||||
interface SessionState {
|
|
||||||
version: number // Monotonic generation token - increment to invalidate pending callbacks
|
|
||||||
mode: SessionMode
|
|
||||||
timer?: ReturnType<typeof setTimeout> // Pending countdown timer
|
|
||||||
lastAttemptedAt?: number // Timestamp of last injection attempt (throttle all attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function getMessageDir(sessionID: string): string | null {
|
function getMessageDir(sessionID: string): string | null {
|
||||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||||
@@ -76,20 +61,24 @@ function getMessageDir(sessionID: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectInterrupt(error: unknown): boolean {
|
function isAbortError(error: unknown): boolean {
|
||||||
if (!error) return false
|
if (!error) return false
|
||||||
|
|
||||||
if (typeof error === "object") {
|
if (typeof error === "object") {
|
||||||
const errObj = error as Record<string, unknown>
|
const errObj = error as Record<string, unknown>
|
||||||
const name = errObj.name as string | undefined
|
const name = errObj.name as string | undefined
|
||||||
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||||
|
|
||||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||||
if (name === "DOMException" && message.includes("abort")) return true
|
if (name === "DOMException" && message.includes("abort")) return true
|
||||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
const lower = error.toLowerCase()
|
const lower = error.toLowerCase()
|
||||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,91 +86,56 @@ function getIncompleteCount(todos: Todo[]): number {
|
|||||||
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
|
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MAIN IMPLEMENTATION
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export function createTodoContinuationEnforcer(
|
export function createTodoContinuationEnforcer(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options: TodoContinuationEnforcerOptions = {}
|
options: TodoContinuationEnforcerOptions = {}
|
||||||
): TodoContinuationEnforcer {
|
): TodoContinuationEnforcer {
|
||||||
const { backgroundManager } = options
|
const { backgroundManager } = options
|
||||||
|
|
||||||
// Single source of truth: per-session state machine
|
|
||||||
const sessions = new Map<string, SessionState>()
|
const sessions = new Map<string, SessionState>()
|
||||||
|
|
||||||
// ============================================================================
|
function getState(sessionID: string): SessionState {
|
||||||
// STATE HELPERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function getOrCreateState(sessionID: string): SessionState {
|
|
||||||
let state = sessions.get(sessionID)
|
let state = sessions.get(sessionID)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = { version: 0, mode: "idle" }
|
state = {}
|
||||||
sessions.set(sessionID, state)
|
sessions.set(sessionID, state)
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTimer(state: SessionState): void {
|
function cancelCountdown(sessionID: string): void {
|
||||||
if (state.timer) {
|
|
||||||
clearTimeout(state.timer)
|
|
||||||
state.timer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate any pending or in-flight operation by incrementing version.
|
|
||||||
* ALWAYS bumps version regardless of current mode to prevent last-mile races.
|
|
||||||
*/
|
|
||||||
function invalidate(sessionID: string, reason: string): void {
|
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (!state) return
|
if (!state) return
|
||||||
|
|
||||||
// Skip if in recovery mode (external control)
|
if (state.countdownTimer) {
|
||||||
if (state.mode === "recovering") return
|
clearTimeout(state.countdownTimer)
|
||||||
|
state.countdownTimer = undefined
|
||||||
state.version++
|
}
|
||||||
clearTimer(state)
|
if (state.countdownInterval) {
|
||||||
|
clearInterval(state.countdownInterval)
|
||||||
if (state.mode !== "idle" && state.mode !== "errorBypass") {
|
state.countdownInterval = undefined
|
||||||
log(`[${HOOK_NAME}] Invalidated`, { sessionID, reason, prevMode: state.mode, newVersion: state.version })
|
|
||||||
state.mode = "idle"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function cleanup(sessionID: string): void {
|
||||||
* Check if this is the main session (not a subagent session).
|
cancelCountdown(sessionID)
|
||||||
*/
|
sessions.delete(sessionID)
|
||||||
function isMainSession(sessionID: string): boolean {
|
|
||||||
const mainSessionID = getMainSessionID()
|
|
||||||
// If no main session is set, allow all. If set, only allow main.
|
|
||||||
return !mainSessionID || sessionID === mainSessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EXTERNAL API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const markRecovering = (sessionID: string): void => {
|
const markRecovering = (sessionID: string): void => {
|
||||||
const state = getOrCreateState(sessionID)
|
const state = getState(sessionID)
|
||||||
invalidate(sessionID, "entering recovery mode")
|
state.isRecovering = true
|
||||||
state.mode = "recovering"
|
cancelCountdown(sessionID)
|
||||||
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
|
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
const markRecoveryComplete = (sessionID: string): void => {
|
const markRecoveryComplete = (sessionID: string): void => {
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (state && state.mode === "recovering") {
|
if (state) {
|
||||||
state.mode = "idle"
|
state.isRecovering = false
|
||||||
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
|
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TOAST HELPER
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
|
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
|
||||||
await ctx.client.tui.showToast({
|
await ctx.client.tui.showToast({
|
||||||
body: {
|
body: {
|
||||||
@@ -193,125 +147,64 @@ export function createTodoContinuationEnforcer(
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||||
// CORE INJECTION LOGIC
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function executeInjection(sessionID: string, capturedVersion: number): Promise<void> {
|
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (!state) return
|
|
||||||
|
|
||||||
// Version check: if version changed since we started, abort
|
if (state?.isRecovering) {
|
||||||
if (state.version !== capturedVersion) {
|
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||||
log(`[${HOOK_NAME}] Injection aborted: version mismatch`, {
|
|
||||||
sessionID, capturedVersion, currentVersion: state.version
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode check: must still be in countingDown mode
|
if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||||
if (state.mode !== "countingDown") {
|
log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
|
||||||
log(`[${HOOK_NAME}] Injection aborted: mode changed`, {
|
|
||||||
sessionID, mode: state.mode
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle check: minimum interval between injection attempts
|
const hasRunningBgTasks = backgroundManager
|
||||||
if (state.lastAttemptedAt) {
|
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||||
const elapsed = Date.now() - state.lastAttemptedAt
|
: false
|
||||||
if (elapsed < MIN_INJECTION_INTERVAL_MS) {
|
|
||||||
log(`[${HOOK_NAME}] Injection throttled: too soon since last injection`, {
|
if (hasRunningBgTasks) {
|
||||||
sessionID, elapsedMs: elapsed, minIntervalMs: MIN_INJECTION_INTERVAL_MS
|
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
|
||||||
})
|
return
|
||||||
state.mode = "idle"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.mode = "injecting"
|
|
||||||
|
|
||||||
// Re-verify todos (CRITICAL: always re-check before injecting)
|
|
||||||
let todos: Todo[] = []
|
let todos: Todo[] = []
|
||||||
try {
|
try {
|
||||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||||
todos = (response.data ?? response) as Todo[]
|
todos = (response.data ?? response) as Todo[]
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`[${HOOK_NAME}] Failed to fetch todos for injection`, { sessionID, error: String(err) })
|
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) })
|
||||||
state.mode = "idle"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version check again after async operation
|
const freshIncompleteCount = getIncompleteCount(todos)
|
||||||
if (state.version !== capturedVersion) {
|
if (freshIncompleteCount === 0) {
|
||||||
log(`[${HOOK_NAME}] Injection aborted after todo fetch: version mismatch`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
|
||||||
state.mode = "idle"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const incompleteCount = getIncompleteCount(todos)
|
|
||||||
if (incompleteCount === 0) {
|
|
||||||
log(`[${HOOK_NAME}] No incomplete todos at injection time`, { sessionID, total: todos.length })
|
|
||||||
state.mode = "idle"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip entirely if background tasks are running (no false positives)
|
|
||||||
const hasRunningBgTasks = backgroundManager
|
|
||||||
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
|
|
||||||
: false
|
|
||||||
|
|
||||||
if (hasRunningBgTasks) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
|
|
||||||
state.mode = "idle"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get previous message agent info
|
|
||||||
const messageDir = getMessageDir(sessionID)
|
const messageDir = getMessageDir(sessionID)
|
||||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||||
|
|
||||||
// Check write permission
|
const hasWritePermission = !prevMessage?.tools ||
|
||||||
const agentHasWritePermission = !prevMessage?.tools ||
|
|
||||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||||
|
|
||||||
if (!agentHasWritePermission) {
|
if (!hasWritePermission) {
|
||||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, {
|
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||||
sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools
|
|
||||||
})
|
|
||||||
state.mode = "idle"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan mode agents only analyze and plan, not implement - skip todo continuation
|
|
||||||
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
|
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
|
||||||
const isPlanModeAgent = agentName === "plan" || agentName === "planner-sisyphus"
|
if (agentName === "plan" || agentName === "planner-sisyphus") {
|
||||||
if (isPlanModeAgent) {
|
log(`[${HOOK_NAME}] Skipped: plan mode agent`, { sessionID, agent: prevMessage?.agent })
|
||||||
log(`[${HOOK_NAME}] Skipped: plan mode agent detected`, {
|
|
||||||
sessionID, agent: prevMessage?.agent
|
|
||||||
})
|
|
||||||
state.mode = "idle"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incompleteCount}/${todos.length} completed, ${incompleteCount} remaining]`
|
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||||
|
|
||||||
// Final version check right before API call (last-mile race mitigation)
|
|
||||||
if (state.version !== capturedVersion) {
|
|
||||||
log(`[${HOOK_NAME}] Injection aborted: version changed before API call`, { sessionID })
|
|
||||||
state.mode = "idle"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set lastAttemptedAt BEFORE calling API (throttle attempts, not just successes)
|
|
||||||
state.lastAttemptedAt = Date.now()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log(`[${HOOK_NAME}] Injecting continuation prompt`, {
|
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||||
sessionID,
|
|
||||||
agent: prevMessage?.agent,
|
|
||||||
incompleteCount
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.client.session.prompt({
|
await ctx.client.session.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@@ -322,234 +215,152 @@ export function createTodoContinuationEnforcer(
|
|||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID })
|
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
|
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
||||||
}
|
}
|
||||||
|
|
||||||
state.mode = "idle"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
|
||||||
// COUNTDOWN STARTER
|
const state = getState(sessionID)
|
||||||
// ============================================================================
|
cancelCountdown(sessionID)
|
||||||
|
|
||||||
function startCountdown(sessionID: string, incompleteCount: number): void {
|
|
||||||
const state = getOrCreateState(sessionID)
|
|
||||||
|
|
||||||
// Cancel any existing countdown
|
|
||||||
invalidate(sessionID, "starting new countdown")
|
|
||||||
|
|
||||||
// Increment version for this new countdown
|
|
||||||
state.version++
|
|
||||||
state.mode = "countingDown"
|
|
||||||
const capturedVersion = state.version
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Starting countdown`, {
|
|
||||||
sessionID,
|
|
||||||
seconds: COUNTDOWN_SECONDS,
|
|
||||||
version: capturedVersion,
|
|
||||||
incompleteCount
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show initial toast
|
|
||||||
showCountdownToast(COUNTDOWN_SECONDS, incompleteCount)
|
|
||||||
|
|
||||||
// Show countdown toasts
|
|
||||||
let secondsRemaining = COUNTDOWN_SECONDS
|
let secondsRemaining = COUNTDOWN_SECONDS
|
||||||
const toastInterval = setInterval(() => {
|
showCountdownToast(secondsRemaining, incompleteCount)
|
||||||
// Check if countdown was cancelled
|
|
||||||
if (state.version !== capturedVersion) {
|
state.countdownInterval = setInterval(() => {
|
||||||
clearInterval(toastInterval)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secondsRemaining--
|
secondsRemaining--
|
||||||
if (secondsRemaining > 0) {
|
if (secondsRemaining > 0) {
|
||||||
showCountdownToast(secondsRemaining, incompleteCount)
|
showCountdownToast(secondsRemaining, incompleteCount)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
// Schedule the injection
|
state.countdownTimer = setTimeout(() => {
|
||||||
state.timer = setTimeout(() => {
|
cancelCountdown(sessionID)
|
||||||
clearInterval(toastInterval)
|
injectContinuation(sessionID, incompleteCount, total)
|
||||||
clearTimer(state)
|
|
||||||
executeInjection(sessionID, capturedVersion)
|
|
||||||
}, COUNTDOWN_SECONDS * 1000)
|
}, COUNTDOWN_SECONDS * 1000)
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
|
||||||
// EVENT HANDLER
|
}
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// SESSION.ERROR - Enter error bypass mode
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const isInterrupt = detectInterrupt(props?.error)
|
const state = getState(sessionID)
|
||||||
const state = getOrCreateState(sessionID)
|
state.lastErrorAt = Date.now()
|
||||||
|
cancelCountdown(sessionID)
|
||||||
|
|
||||||
invalidate(sessionID, isInterrupt ? "user interrupt" : "session error")
|
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
|
||||||
state.mode = "errorBypass"
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// SESSION.IDLE - Main trigger for todo continuation
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.idle") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] session.idle received`, { sessionID })
|
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||||
|
|
||||||
// Skip if not main session
|
const mainSessionID = getMainSessionID()
|
||||||
if (!isMainSession(sessionID)) {
|
if (mainSessionID && sessionID !== mainSessionID) {
|
||||||
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = getOrCreateState(sessionID)
|
const state = getState(sessionID)
|
||||||
|
|
||||||
// Skip if in recovery mode
|
if (state.isRecovering) {
|
||||||
if (state.mode === "recovering") {
|
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||||
log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if in error bypass mode (DO NOT clear - wait for user message)
|
if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||||
if (state.mode === "errorBypass") {
|
log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
|
||||||
log(`[${HOOK_NAME}] Skipped: error bypass (awaiting user message to resume)`, { sessionID })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already counting down or injecting
|
const hasRunningBgTasks = backgroundManager
|
||||||
if (state.mode === "countingDown" || state.mode === "injecting") {
|
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||||
log(`[${HOOK_NAME}] Skipped: already ${state.mode}`, { sessionID })
|
: false
|
||||||
|
|
||||||
|
if (hasRunningBgTasks) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch todos
|
|
||||||
let todos: Todo[] = []
|
let todos: Todo[] = []
|
||||||
try {
|
try {
|
||||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||||
todos = (response.data ?? response) as Todo[]
|
todos = (response.data ?? response) as Todo[]
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`[${HOOK_NAME}] Todo API error`, { sessionID, error: String(err) })
|
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!todos || todos.length === 0) {
|
if (!todos || todos.length === 0) {
|
||||||
log(`[${HOOK_NAME}] No todos found`, { sessionID })
|
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const incompleteCount = getIncompleteCount(todos)
|
const incompleteCount = getIncompleteCount(todos)
|
||||||
if (incompleteCount === 0) {
|
if (incompleteCount === 0) {
|
||||||
log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length })
|
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if background tasks are running (avoid toast spam with no injection)
|
startCountdown(sessionID, incompleteCount, todos.length)
|
||||||
const hasRunningBgTasks = backgroundManager
|
|
||||||
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
|
|
||||||
: false
|
|
||||||
|
|
||||||
if (hasRunningBgTasks) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Found incomplete todos`, {
|
|
||||||
sessionID,
|
|
||||||
incomplete: incompleteCount,
|
|
||||||
total: todos.length
|
|
||||||
})
|
|
||||||
|
|
||||||
startCountdown(sessionID, incompleteCount)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// MESSAGE.UPDATED - Cancel countdown on activity
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
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
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
const finish = info?.finish as string | undefined
|
|
||||||
|
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
// User message: Always cancel countdown and clear errorBypass
|
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (state?.mode === "errorBypass") {
|
if (state) {
|
||||||
state.mode = "idle"
|
state.lastErrorAt = undefined
|
||||||
log(`[${HOOK_NAME}] User message cleared errorBypass mode`, { sessionID })
|
|
||||||
}
|
}
|
||||||
invalidate(sessionID, "user message received")
|
cancelCountdown(sessionID)
|
||||||
return
|
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assistant message WITHOUT finish: Agent is working, cancel countdown
|
if (role === "assistant") {
|
||||||
if (role === "assistant" && !finish) {
|
cancelCountdown(sessionID)
|
||||||
invalidate(sessionID, "assistant is working (streaming)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assistant message WITH finish: Agent finished a turn (let session.idle handle it)
|
|
||||||
if (role === "assistant" && finish) {
|
|
||||||
log(`[${HOOK_NAME}] Assistant turn finished`, { sessionID, finish })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// MESSAGE.PART.UPDATED - Cancel countdown on streaming activity
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
if (event.type === "message.part.updated") {
|
if (event.type === "message.part.updated") {
|
||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = info?.sessionID as string | undefined
|
||||||
const role = info?.role as string | undefined
|
const role = info?.role as string | undefined
|
||||||
|
|
||||||
if (sessionID && role === "assistant") {
|
if (sessionID && role === "assistant") {
|
||||||
invalidate(sessionID, "assistant streaming")
|
cancelCountdown(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// TOOL EVENTS - Cancel countdown when tools are executing
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
invalidate(sessionID, `tool execution (${event.type})`)
|
cancelCountdown(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// SESSION.DELETED - Cleanup
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
const state = sessions.get(sessionInfo.id)
|
cleanup(sessionInfo.id)
|
||||||
if (state) {
|
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||||
clearTimer(state)
|
|
||||||
}
|
|
||||||
sessions.delete(sessionInfo.id)
|
|
||||||
log(`[${HOOK_NAME}] Session deleted, state cleaned up`, { sessionID: sessionInfo.id })
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user