From db7dfa1249361cb45ded499d8c52277ca87be38e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 18:56:36 +0900 Subject: [PATCH] Fix todo-continuation-enforcer mock isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - idle-event.ts: improve import patterns - non-idle-events.ts: update for test compatibility - resolve-message-info.ts: prevent cross-test contamination - todo-continuation-enforcer.test.ts: narrow mock scope - types.ts: update type definitions 🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode --- .../todo-continuation-enforcer/idle-event.ts | 11 ++- .../non-idle-events.ts | 21 ++++- .../resolve-message-info.ts | 15 +-- .../todo-continuation-enforcer.test.ts | 91 +++++++++++++++++++ src/hooks/todo-continuation-enforcer/types.ts | 4 + 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index ed0301549..e03424aee 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -10,7 +10,7 @@ import { isLastAssistantMessageAborted } from "./abort-detection" import { hasUnansweredQuestion } from "./pending-question-detection" import { shouldStopForStagnation } from "./stagnation-detection" import { getIncompleteCount } from "./todo" -import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" +import type { MessageInfo, MessageWithInfo, ResolvedMessageInfo, Todo } from "./types" import { resolveLatestMessageInfo } from "./resolve-message-info" import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compaction-guard" import type { SessionStateStore } from "./session-state" @@ -61,17 +61,18 @@ export async function handleSessionIdle(args: { return } + let prefetchedMessages: MessageWithInfo[] | undefined try { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID }, query: { directory: ctx.directory }, }) - const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) - if (isLastAssistantMessageAborted(messages)) { + prefetchedMessages = normalizeSDKResponse(messagesResp, [] as MessageWithInfo[]) + if (isLastAssistantMessageAborted(prefetchedMessages)) { log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) return } - if (hasUnansweredQuestion(messages)) { + if (hasUnansweredQuestion(prefetchedMessages)) { log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID }) return } @@ -132,7 +133,7 @@ export async function handleSessionIdle(args: { let resolvedInfo: ResolvedMessageInfo | undefined let encounteredCompaction = false try { - const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID) + const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages) resolvedInfo = messageInfoResult.resolvedInfo encounteredCompaction = messageInfoResult.encounteredCompaction } catch (error) { diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index dc4677047..0ab6dbfaa 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -41,11 +41,24 @@ export function handleNonIdleEvent(args: { } if (eventType === "message.part.updated") { - const info = properties?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined + const sessionID = typeof properties?.sessionID === "string" + ? properties.sessionID + : undefined + const legacyInfo = properties?.info as Record | undefined + const legacySessionID = legacyInfo?.sessionID as string | undefined + const targetSessionID = sessionID ?? legacySessionID - if (sessionID && role === "assistant") { + if (targetSessionID) { + const state = sessionStateStore.getExistingState(targetSessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(targetSessionID) + } + return + } + + if (eventType === "message.part.delta") { + const sessionID = properties?.sessionID as string | undefined + if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) if (state) state.abortDetectedAt = undefined sessionStateStore.cancelCountdown(sessionID) diff --git a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts index f968f6e71..bffd8cfd6 100644 --- a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts +++ b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts @@ -2,16 +2,19 @@ import type { PluginInput } from "@opencode-ai/plugin" import { normalizeSDKResponse } from "../../shared" -import type { MessageInfo, ResolveLatestMessageInfoResult } from "./types" +import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types" export async function resolveLatestMessageInfo( ctx: PluginInput, - sessionID: string + sessionID: string, + prefetchedMessages?: MessageWithInfo[] ): Promise { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) + const messages = prefetchedMessages ?? normalizeSDKResponse( + await ctx.client.session.messages({ + path: { id: sessionID }, + }), + [] as MessageWithInfo[], + ) let encounteredCompaction = false for (let i = messages.length - 1; i >= 0; i--) { diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 508cef6a4..59fca1569 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -463,6 +463,97 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) + test("should cancel countdown on assistant activity with real message.part.updated payload shape", async () => { + // given - session starting countdown + const sessionID = "main-assistant-real-part" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // when - assistant part update arrives with actual sync payload shape + await fakeTimers.advanceBy(500) + await hook.handler({ + event: { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-1", + messageID: "msg-1", + sessionID, + type: "text", + text: "working", + }, + time: Date.now(), + }, + }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no continuation injected (cancelled) + expect(promptCalls).toHaveLength(0) + }) + + test("should cancel countdown on assistant activity with message.part.delta payload", async () => { + // given - session starting countdown + const sessionID = "main-assistant-delta" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // when - assistant delta arrives + await fakeTimers.advanceBy(500) + await hook.handler({ + event: { + type: "message.part.delta", + properties: { + sessionID, + messageID: "msg-1", + partID: "part-1", + field: "text", + delta: "x", + }, + }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no continuation injected (cancelled) + expect(promptCalls).toHaveLength(0) + }) + + test("should fetch session messages only once during a single idle evaluation", async () => { + // given + const sessionID = "main-single-messages-fetch" + setMainSession(sessionID) + let messagesCallCount = 0 + const mockInput = createMockPluginInput() + mockInput.client.session.messages = async () => { + messagesCallCount += 1 + return { data: mockMessages } + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + // when + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // then + expect(messagesCallCount).toBe(1) + }) + test("should cancel countdown on tool execution", async () => { // given - session starting countdown const sessionID = "main-tool" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index d44bd579b..80b646a02 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -50,6 +50,10 @@ export interface MessageInfo { tools?: Record } +export interface MessageWithInfo { + info?: MessageInfo +} + export interface ResolvedMessageInfo { agent?: string model?: { providerID: string; modelID: string }