Fix todo-continuation-enforcer mock isolation

- 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
This commit is contained in:
YeonGyu-Kim
2026-04-04 18:56:36 +09:00
parent 382f9b61fa
commit db7dfa1249
5 changed files with 127 additions and 15 deletions
@@ -10,7 +10,7 @@ import { isLastAssistantMessageAborted } from "./abort-detection"
import { hasUnansweredQuestion } from "./pending-question-detection" import { hasUnansweredQuestion } from "./pending-question-detection"
import { shouldStopForStagnation } from "./stagnation-detection" import { shouldStopForStagnation } from "./stagnation-detection"
import { getIncompleteCount } from "./todo" 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 { resolveLatestMessageInfo } from "./resolve-message-info"
import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compaction-guard" import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compaction-guard"
import type { SessionStateStore } from "./session-state" import type { SessionStateStore } from "./session-state"
@@ -61,17 +61,18 @@ export async function handleSessionIdle(args: {
return return
} }
let prefetchedMessages: MessageWithInfo[] | undefined
try { try {
const messagesResp = await ctx.client.session.messages({ const messagesResp = await ctx.client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) prefetchedMessages = normalizeSDKResponse(messagesResp, [] as MessageWithInfo[])
if (isLastAssistantMessageAborted(messages)) { if (isLastAssistantMessageAborted(prefetchedMessages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return return
} }
if (hasUnansweredQuestion(messages)) { if (hasUnansweredQuestion(prefetchedMessages)) {
log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID }) log(`[${HOOK_NAME}] Skipped: pending question awaiting user response`, { sessionID })
return return
} }
@@ -132,7 +133,7 @@ export async function handleSessionIdle(args: {
let resolvedInfo: ResolvedMessageInfo | undefined let resolvedInfo: ResolvedMessageInfo | undefined
let encounteredCompaction = false let encounteredCompaction = false
try { try {
const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID) const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages)
resolvedInfo = messageInfoResult.resolvedInfo resolvedInfo = messageInfoResult.resolvedInfo
encounteredCompaction = messageInfoResult.encounteredCompaction encounteredCompaction = messageInfoResult.encounteredCompaction
} catch (error) { } catch (error) {
@@ -41,11 +41,24 @@ export function handleNonIdleEvent(args: {
} }
if (eventType === "message.part.updated") { if (eventType === "message.part.updated") {
const info = properties?.info as Record<string, unknown> | undefined const sessionID = typeof properties?.sessionID === "string"
const sessionID = info?.sessionID as string | undefined ? properties.sessionID
const role = info?.role as string | undefined : undefined
const legacyInfo = properties?.info as Record<string, unknown> | 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) const state = sessionStateStore.getExistingState(sessionID)
if (state) state.abortDetectedAt = undefined if (state) state.abortDetectedAt = undefined
sessionStateStore.cancelCountdown(sessionID) sessionStateStore.cancelCountdown(sessionID)
@@ -2,16 +2,19 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared" import { normalizeSDKResponse } from "../../shared"
import type { MessageInfo, ResolveLatestMessageInfoResult } from "./types" import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
export async function resolveLatestMessageInfo( export async function resolveLatestMessageInfo(
ctx: PluginInput, ctx: PluginInput,
sessionID: string sessionID: string,
prefetchedMessages?: MessageWithInfo[]
): Promise<ResolveLatestMessageInfoResult> { ): Promise<ResolveLatestMessageInfoResult> {
const messagesResp = await ctx.client.session.messages({ const messages = prefetchedMessages ?? normalizeSDKResponse(
path: { id: sessionID }, await ctx.client.session.messages({
}) path: { id: sessionID },
const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) }),
[] as MessageWithInfo[],
)
let encounteredCompaction = false let encounteredCompaction = false
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
@@ -463,6 +463,97 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0) 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 () => { test("should cancel countdown on tool execution", async () => {
// given - session starting countdown // given - session starting countdown
const sessionID = "main-tool" const sessionID = "main-tool"
@@ -50,6 +50,10 @@ export interface MessageInfo {
tools?: Record<string, ToolPermission> tools?: Record<string, ToolPermission>
} }
export interface MessageWithInfo {
info?: MessageInfo
}
export interface ResolvedMessageInfo { export interface ResolvedMessageInfo {
agent?: string agent?: string
model?: { providerID: string; modelID: string } model?: { providerID: string; modelID: string }