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 { 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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user