fix(todo-continuation-enforcer): preserve model/provider from nearest message
When injecting continuation prompts, extract and pass the model field (providerID + modelID) from the nearest stored message, matching the pattern used in background-agent/manager.ts and session-recovery. Also updated tests to capture the model field for verification.
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
|
||||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
|
||||||
import type { BackgroundManager } from "../features/background-agent"
|
import type { BackgroundManager } from "../features/background-agent"
|
||||||
|
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||||
|
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||||
|
|
||||||
describe("todo-continuation-enforcer", () => {
|
describe("todo-continuation-enforcer", () => {
|
||||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||||
let toastCalls: Array<{ title: string; message: string }>
|
let toastCalls: Array<{ title: string; message: string }>
|
||||||
|
|
||||||
function createMockPluginInput() {
|
function createMockPluginInput() {
|
||||||
@@ -20,6 +20,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
promptCalls.push({
|
promptCalls.push({
|
||||||
sessionID: opts.path.id,
|
sessionID: opts.path.id,
|
||||||
agent: opts.body.agent,
|
agent: opts.body.agent,
|
||||||
|
model: opts.body.model,
|
||||||
text: opts.body.parts[0].text,
|
text: opts.body.parts[0].text,
|
||||||
})
|
})
|
||||||
return {}
|
return {}
|
||||||
@@ -41,8 +42,8 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||||
return {
|
return {
|
||||||
getTasksByParentSession: () => runningTasks
|
getTasksByParentSession: () => runningTasks
|
||||||
? [{ status: "running" }]
|
? [{ status: "running" }]
|
||||||
: [],
|
: [],
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
@@ -229,9 +230,9 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - user sends message immediately (before 2s countdown)
|
// #when - user sends message immediately (before 2s countdown)
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "message.updated",
|
type: "message.updated",
|
||||||
properties: { info: { sessionID, role: "user" } }
|
properties: { info: { sessionID, role: "user" } }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,9 +256,9 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
// #when - assistant starts responding
|
// #when - assistant starts responding
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "message.part.updated",
|
type: "message.part.updated",
|
||||||
properties: { info: { sessionID, role: "assistant" } }
|
properties: { info: { sessionID, role: "assistant" } }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,12 +419,12 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - abort error occurs (with abort-specific error)
|
// #when - abort error occurs (with abort-specific error)
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -447,20 +448,20 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - abort error occurs
|
// #when - abort error occurs
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// #when - assistant sends a message (intervening event clears abort state)
|
// #when - assistant sends a message (intervening event clears abort state)
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "message.updated",
|
type: "message.updated",
|
||||||
properties: { info: { sessionID, role: "assistant" } }
|
properties: { info: { sessionID, role: "assistant" } }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -484,12 +485,12 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - abort error occurs
|
// #when - abort error occurs
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: { message: "aborted" }
|
error: { message: "aborted" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -518,12 +519,12 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - non-abort error occurs (e.g., network error, API error)
|
// #when - non-abort error occurs (e.g., network error, API error)
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: { name: "NetworkError", message: "Connection failed" }
|
error: { name: "NetworkError", message: "Connection failed" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -547,12 +548,12 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - abort error occurs
|
// #when - abort error occurs
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
sessionID,
|
||||||
error: { name: "AbortError", message: "cancelled" }
|
error: { name: "AbortError", message: "cancelled" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -584,17 +585,17 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
|
|
||||||
// #when - first abort error
|
// #when - first abort error
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: { sessionID, error: { message: "aborted" } }
|
properties: { sessionID, error: { message: "aborted" } }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// #when - second abort error (immediately before idle)
|
// #when - second abort error (immediately before idle)
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: { sessionID, error: { message: "interrupted" } }
|
properties: { sessionID, error: { message: "interrupted" } }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { existsSync, readdirSync } from "node:fs"
|
import { existsSync, readdirSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { BackgroundManager } from "../features/background-agent"
|
||||||
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
|
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
findNearestMessageWithFields,
|
findNearestMessageWithFields,
|
||||||
MESSAGE_STORAGE,
|
MESSAGE_STORAGE,
|
||||||
} from "../features/hook-message-injector"
|
} from "../features/hook-message-injector"
|
||||||
import type { BackgroundManager } from "../features/background-agent"
|
|
||||||
import { log } from "../shared/logger"
|
import { log } from "../shared/logger"
|
||||||
|
|
||||||
const HOOK_NAME = "todo-continuation-enforcer"
|
const HOOK_NAME = "todo-continuation-enforcer"
|
||||||
@@ -62,22 +62,22 @@ function getMessageDir(sessionID: string): string | null {
|
|||||||
|
|
||||||
function isAbortError(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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
function cancelCountdown(sessionID: string): void {
|
function cancelCountdown(sessionID: string): void {
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (!state) return
|
if (!state) return
|
||||||
|
|
||||||
if (state.countdownTimer) {
|
if (state.countdownTimer) {
|
||||||
clearTimeout(state.countdownTimer)
|
clearTimeout(state.countdownTimer)
|
||||||
state.countdownTimer = undefined
|
state.countdownTimer = undefined
|
||||||
@@ -148,7 +148,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
|
|
||||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
|
|
||||||
if (state?.isRecovering) {
|
if (state?.isRecovering) {
|
||||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||||
return
|
return
|
||||||
@@ -183,9 +183,9 @@ export function createTodoContinuationEnforcer(
|
|||||||
const messageDir = getMessageDir(sessionID)
|
const messageDir = getMessageDir(sessionID)
|
||||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||||
|
|
||||||
const hasWritePermission = !prevMessage?.tools ||
|
const hasWritePermission = !prevMessage?.tools ||
|
||||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||||
|
|
||||||
if (!hasWritePermission) {
|
if (!hasWritePermission) {
|
||||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||||
return
|
return
|
||||||
@@ -199,18 +199,23 @@ export function createTodoContinuationEnforcer(
|
|||||||
|
|
||||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||||
|
|
||||||
|
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||||
|
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||||
|
: undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
|
||||||
|
|
||||||
await ctx.client.session.prompt({
|
await ctx.client.session.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
agent: prevMessage?.agent,
|
agent: prevMessage?.agent,
|
||||||
|
model: modelField,
|
||||||
parts: [{ type: "text", text: prompt }],
|
parts: [{ type: "text", text: prompt }],
|
||||||
},
|
},
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
||||||
@@ -250,7 +255,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
const isAbort = isAbortError(props?.error)
|
const isAbort = isAbortError(props?.error)
|
||||||
state.lastEventWasAbortError = isAbort
|
state.lastEventWasAbortError = isAbort
|
||||||
cancelCountdown(sessionID)
|
cancelCountdown(sessionID)
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -264,7 +269,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
const mainSessionID = getMainSessionID()
|
const mainSessionID = getMainSessionID()
|
||||||
const isMainSession = sessionID === mainSessionID
|
const isMainSession = sessionID === mainSessionID
|
||||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||||
|
|
||||||
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
|
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
|
||||||
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user