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:
@@ -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) {
|
||||
|
||||
@@ -41,11 +41,24 @@ export function handleNonIdleEvent(args: {
|
||||
}
|
||||
|
||||
if (eventType === "message.part.updated") {
|
||||
const info = properties?.info as Record<string, unknown> | 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<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)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
|
||||
@@ -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<ResolveLatestMessageInfoResult> {
|
||||
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--) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface MessageInfo {
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
export interface MessageWithInfo {
|
||||
info?: MessageInfo
|
||||
}
|
||||
|
||||
export interface ResolvedMessageInfo {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
|
||||
Reference in New Issue
Block a user