Merge pull request #4068 from code-yeongyu/feat/pre-publish-fix-v420
v4.2.0: pre-publish review fixes (BLOCKER-1..3, HIGH-5..10, MID-11/12)
This commit is contained in:
@@ -233,11 +233,15 @@ function getPendingNotifications(manager: BackgroundManager): Map<string, string
|
||||
}
|
||||
|
||||
function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return (cast<{ pendingParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).pendingParentWakes
|
||||
return (cast<{
|
||||
parentWakeNotifier: { getPendingParentWakes: () => Map<string, PendingParentWakeForTest> }
|
||||
}>(manager)).parentWakeNotifier.getPendingParentWakes()
|
||||
}
|
||||
|
||||
function getDispatchedParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return (cast<{ dispatchedParentWakes: Map<string, PendingParentWakeForTest> }>(manager)).dispatchedParentWakes
|
||||
return (cast<{
|
||||
parentWakeNotifier: { getDispatchedParentWakes: () => Map<string, PendingParentWakeForTest> }
|
||||
}>(manager)).parentWakeNotifier.getDispatchedParentWakes()
|
||||
}
|
||||
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
type QueueItem,
|
||||
} from "./constants"
|
||||
|
||||
import { resolveRegisteredAgentName, subagentSessions } from "../claude-code-session-state"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { formatDuration } from "./duration-formatter"
|
||||
import {
|
||||
@@ -63,10 +63,7 @@ import {
|
||||
} from "./attempt-lifecycle"
|
||||
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
||||
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
|
||||
import {
|
||||
isSessionActive as isOpenCodeSessionActive,
|
||||
settleAfterSessionIdle,
|
||||
} from "../../hooks/shared/session-idle-settle"
|
||||
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
|
||||
import {
|
||||
findNearestMessageExcludingCompaction,
|
||||
@@ -96,39 +93,9 @@ import {
|
||||
resolveSubagentSpawnContext,
|
||||
type SubagentSpawnContext,
|
||||
} from "./subagent-spawn-limits"
|
||||
import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier"
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
type ParentWakePromptContext = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
type PendingParentWake = {
|
||||
promptContext: ParentWakePromptContext
|
||||
notifications: string[]
|
||||
shouldReply: boolean
|
||||
dispatchedAt?: number
|
||||
toolCallDeferralStartedAt?: number
|
||||
}
|
||||
|
||||
type ParentWakeSessionMessage = {
|
||||
info?: {
|
||||
role?: string
|
||||
finish?: string
|
||||
time?: { created?: unknown }
|
||||
}
|
||||
role?: string
|
||||
finish?: string
|
||||
time?: { created?: unknown }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
type ResumeTaskSnapshot = {
|
||||
status: BackgroundTask["status"]
|
||||
completedAt?: Date
|
||||
@@ -272,10 +239,7 @@ export class BackgroundManager {
|
||||
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
|
||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private readonly parentWakeNotifier: ParentWakeNotifier
|
||||
private observedOutputSessions: Set<string> = new Set()
|
||||
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
|
||||
private rootDescendantCounts: Map<string, number>
|
||||
@@ -306,6 +270,19 @@ export class BackgroundManager {
|
||||
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
|
||||
this.modelFallbackControllerAccessor = options?.modelFallbackControllerAccessor
|
||||
this.logger = options?.log ?? log
|
||||
this.parentWakeNotifier = new ParentWakeNotifier(
|
||||
{
|
||||
client: this.client,
|
||||
directory: this.directory,
|
||||
enqueueNotificationForParent: this.enqueueNotificationForParent.bind(this),
|
||||
},
|
||||
{
|
||||
pendingRetryMs: PENDING_PARENT_WAKE_RETRY_MS,
|
||||
acceptedMessageSkewMs: PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS,
|
||||
toolCallDeferMaxMs: PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS,
|
||||
failureRequeueWindowMs: PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS,
|
||||
},
|
||||
)
|
||||
this.registerProcessCleanup()
|
||||
}
|
||||
|
||||
@@ -1383,222 +1360,12 @@ The fallback retry session is now created and can be inspected directly.
|
||||
this.observedOutputSessions.add(sessionID)
|
||||
}
|
||||
|
||||
private resolveParentWakePromptContext(promptContext: ParentWakePromptContext): ParentWakePromptContext {
|
||||
const resolvedAgent = resolveRegisteredAgentName(promptContext.agent)
|
||||
return {
|
||||
...promptContext,
|
||||
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
||||
...(promptContext.model ? { model: { ...promptContext.model } } : {}),
|
||||
...(promptContext.tools ? { tools: { ...promptContext.tools } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
private cloneParentWake(wake: PendingParentWake): PendingParentWake {
|
||||
const promptContext = this.resolveParentWakePromptContext(wake.promptContext)
|
||||
return {
|
||||
promptContext,
|
||||
notifications: [...wake.notifications],
|
||||
shouldReply: wake.shouldReply,
|
||||
...(wake.dispatchedAt !== undefined ? { dispatchedAt: wake.dispatchedAt } : {}),
|
||||
...(wake.toolCallDeferralStartedAt !== undefined
|
||||
? { toolCallDeferralStartedAt: wake.toolCallDeferralStartedAt }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
private clearDispatchedParentWake(sessionID: string): void {
|
||||
const timer = this.dispatchedParentWakeTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
}
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}
|
||||
|
||||
private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void {
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
const dispatchedWake = this.cloneParentWake(wake)
|
||||
dispatchedWake.dispatchedAt = Date.now()
|
||||
this.dispatchedParentWakes.set(sessionID, dispatchedWake)
|
||||
const timer = setTimeout(() => {
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}, PARENT_WAKE_FAILURE_REQUEUE_WINDOW_MS)
|
||||
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
||||
this.parentWakeNotifier.clearDispatchedParentWake(sessionID)
|
||||
}
|
||||
|
||||
private async requeueDispatchedParentWake(sessionID: string, reason: string): Promise<boolean> {
|
||||
const wake = this.dispatchedParentWakes.get(sessionID)
|
||||
if (!wake) {
|
||||
return false
|
||||
}
|
||||
|
||||
await settleAfterSessionIdle()
|
||||
|
||||
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, wake)) {
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
log("[background-agent] Ignored late parent wake failure after assistant output:", {
|
||||
sessionID,
|
||||
reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...wake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || wake.shouldReply
|
||||
pendingWake.promptContext = wake.promptContext
|
||||
pendingWake.toolCallDeferralStartedAt ??= wake.toolCallDeferralStartedAt
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, this.cloneParentWake(wake))
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Requeued dispatched parent wake after prompt failure:", {
|
||||
sessionID,
|
||||
reason,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
|
||||
try {
|
||||
const messagesResp = await messagesInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
}, this.directory)
|
||||
return normalizeSDKResponse(messagesResp, [] as ParentWakeSessionMessage[])
|
||||
} catch (error) {
|
||||
log("[background-agent] Failed to inspect parent session messages for wake safety:", {
|
||||
sessionID,
|
||||
error,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getParentWakeMessageRole(message: ParentWakeSessionMessage): string | undefined {
|
||||
return message.info?.role ?? message.role
|
||||
}
|
||||
|
||||
private getParentWakeMessageFinish(message: ParentWakeSessionMessage): string | undefined {
|
||||
return message.info?.finish ?? message.finish
|
||||
}
|
||||
|
||||
private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined {
|
||||
const value = message.info?.time?.created ?? message.time?.created
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.getTime()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
const role = this.getParentWakeMessageRole(message)
|
||||
if (role === "assistant") {
|
||||
return this.getParentWakeMessageFinish(message) === "tool-calls"
|
||||
}
|
||||
if (role === "user") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean {
|
||||
const role = this.getParentWakeMessageRole(message)
|
||||
if (role !== "assistant" && role !== "tool") {
|
||||
return false
|
||||
}
|
||||
if (!message.parts || message.parts.length === 0) {
|
||||
return role === "assistant"
|
||||
}
|
||||
return message.parts.some((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
return typeof part.text === "string" && part.text.trim().length > 0
|
||||
}
|
||||
if (part.type === "tool" || part.type === "tool_result") {
|
||||
return true
|
||||
}
|
||||
if (part.content !== undefined) {
|
||||
if (typeof part.content === "string") {
|
||||
return part.content.trim().length > 0
|
||||
}
|
||||
if (Array.isArray(part.content)) {
|
||||
return part.content.length > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
private parentWakeMessageContainsNotification(
|
||||
message: ParentWakeSessionMessage,
|
||||
wake: PendingParentWake,
|
||||
): boolean {
|
||||
if (this.getParentWakeMessageRole(message) !== "user") {
|
||||
return false
|
||||
}
|
||||
return message.parts?.some((part) =>
|
||||
typeof part.text === "string" && wake.notifications.some((notification) => part.text?.includes(notification))
|
||||
) ?? false
|
||||
}
|
||||
|
||||
private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise<boolean> {
|
||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||
if (!this.latestAssistantTurnIsWaitingOnTools(messages)) {
|
||||
delete wake.toolCallDeferralStartedAt
|
||||
return false
|
||||
}
|
||||
const now = Date.now()
|
||||
wake.toolCallDeferralStartedAt ??= now
|
||||
if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS) {
|
||||
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
|
||||
sessionID,
|
||||
})
|
||||
return false
|
||||
}
|
||||
log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", {
|
||||
sessionID,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async hasAcceptedMessageAfterDispatchedParentWake(
|
||||
sessionID: string,
|
||||
wake: PendingParentWake,
|
||||
): Promise<boolean> {
|
||||
if (wake.dispatchedAt === undefined) {
|
||||
return false
|
||||
}
|
||||
const dispatchedAt = wake.dispatchedAt
|
||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||
return messages.some((message) => {
|
||||
const createdAt = this.getParentWakeMessageCreatedAt(message)
|
||||
if (createdAt === undefined) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
createdAt >= dispatchedAt - PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS
|
||||
&& this.parentWakeMessageContainsNotification(message, wake)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message)
|
||||
})
|
||||
return this.parentWakeNotifier.requeueDispatchedParentWake(sessionID, reason)
|
||||
}
|
||||
|
||||
private clearSessionOutputObserved(sessionID: string): void {
|
||||
@@ -2697,132 +2464,11 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
shouldReply: boolean,
|
||||
delayMs?: number,
|
||||
): void {
|
||||
const resolvedPromptContext = this.resolveParentWakePromptContext(promptContext)
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.push(notification)
|
||||
pendingWake.promptContext = resolvedPromptContext
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || shouldReply
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, {
|
||||
promptContext: resolvedPromptContext,
|
||||
notifications: [notification],
|
||||
shouldReply,
|
||||
})
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID, delayMs)
|
||||
this.parentWakeNotifier.queuePendingParentWake(sessionID, notification, promptContext, shouldReply, delayMs)
|
||||
}
|
||||
|
||||
private async flushPendingParentWake(sessionID: string): Promise<void> {
|
||||
if (!this.pendingParentWakes.has(sessionID)) {
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
await settleAfterSessionIdle()
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const latestWake = this.pendingParentWakes.get(sessionID)
|
||||
if (!latestWake) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingParentWakes.delete(sessionID)
|
||||
|
||||
const notificationContent = latestWake.notifications.join("\n\n")
|
||||
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: this.client,
|
||||
sessionID,
|
||||
source: "background-agent-parent-wake",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 250,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: !latestWake.shouldReply,
|
||||
...latestWake.promptContext,
|
||||
parts: [createInternalAgentTextPart(notificationContent)],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...latestWake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
|
||||
pendingWake.promptContext = latestWake.promptContext
|
||||
pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, latestWake)
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||
this.trackDispatchedParentWake(sessionID, latestWake)
|
||||
} catch (error) {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...latestWake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
|
||||
pendingWake.promptContext = latestWake.promptContext
|
||||
pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, latestWake)
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void {
|
||||
if (this.pendingParentWakeTimers.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingParentWakeTimers.delete(sessionID)
|
||||
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
|
||||
})
|
||||
}, delayMs ?? PENDING_PARENT_WAKE_RETRY_MS)
|
||||
|
||||
this.pendingParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
private clearPendingParentWakeTimer(sessionID: string): void {
|
||||
const timer = this.pendingParentWakeTimers.get(sessionID)
|
||||
if (!timer) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
this.pendingParentWakeTimers.delete(sessionID)
|
||||
await this.parentWakeNotifier.flushPendingParentWake(sessionID)
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
@@ -3145,15 +2791,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
this.idleDeferralTimers.clear()
|
||||
|
||||
for (const timer of this.pendingParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.pendingParentWakeTimers.clear()
|
||||
|
||||
for (const timer of this.dispatchedParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.dispatchedParentWakeTimers.clear()
|
||||
this.parentWakeNotifier.shutdown()
|
||||
|
||||
for (const sessionID of trackedSessionIDs) {
|
||||
subagentSessions.delete(sessionID)
|
||||
@@ -3166,8 +2804,6 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
this.notifications.clear()
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.pendingParentWakes.clear()
|
||||
this.dispatchedParentWakes.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.rootDescendantCounts.clear()
|
||||
this.queuesByKey.clear()
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { resolveRegisteredAgentName } from "../claude-code-session-state"
|
||||
import { createInternalAgentTextPart, log, messagesInDirectory, normalizeSDKResponse } from "../../shared"
|
||||
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export type ParentWakePromptContext = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export type PendingParentWake = {
|
||||
promptContext: ParentWakePromptContext
|
||||
notifications: string[]
|
||||
shouldReply: boolean
|
||||
dispatchedAt?: number
|
||||
toolCallDeferralStartedAt?: number
|
||||
}
|
||||
|
||||
type ParentWakeSessionMessage = {
|
||||
info?: {
|
||||
role?: string
|
||||
finish?: string
|
||||
time?: { created?: unknown }
|
||||
}
|
||||
role?: string
|
||||
finish?: string
|
||||
time?: { created?: unknown }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
type ParentWakeNotifierDeps = {
|
||||
client: OpencodeClient
|
||||
directory: string
|
||||
enqueueNotificationForParent: (parentSessionID: string | undefined, operation: () => Promise<void>) => Promise<void>
|
||||
}
|
||||
|
||||
type ParentWakeNotifierOptions = {
|
||||
pendingRetryMs: number
|
||||
acceptedMessageSkewMs: number
|
||||
toolCallDeferMaxMs: number
|
||||
failureRequeueWindowMs: number
|
||||
}
|
||||
|
||||
export class ParentWakeNotifier {
|
||||
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private dispatchedParentWakes: Map<string, PendingParentWake> = new Map()
|
||||
private dispatchedParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
|
||||
constructor(
|
||||
private readonly deps: ParentWakeNotifierDeps,
|
||||
private readonly options: ParentWakeNotifierOptions,
|
||||
) {}
|
||||
|
||||
getPendingParentWakes(): Map<string, PendingParentWake> {
|
||||
return this.pendingParentWakes
|
||||
}
|
||||
|
||||
getPendingParentWakeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return this.pendingParentWakeTimers
|
||||
}
|
||||
|
||||
getDispatchedParentWakes(): Map<string, PendingParentWake> {
|
||||
return this.dispatchedParentWakes
|
||||
}
|
||||
|
||||
getDispatchedParentWakeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return this.dispatchedParentWakeTimers
|
||||
}
|
||||
|
||||
queuePendingParentWake(
|
||||
sessionID: string,
|
||||
notification: string,
|
||||
promptContext: ParentWakePromptContext,
|
||||
shouldReply: boolean,
|
||||
delayMs?: number,
|
||||
): void {
|
||||
const resolvedPromptContext = this.resolveParentWakePromptContext(promptContext)
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.push(notification)
|
||||
pendingWake.promptContext = resolvedPromptContext
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || shouldReply
|
||||
} else {
|
||||
this.pendingParentWakes.set(sessionID, {
|
||||
promptContext: resolvedPromptContext,
|
||||
notifications: [notification],
|
||||
shouldReply,
|
||||
})
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID, delayMs)
|
||||
}
|
||||
|
||||
async flushPendingParentWake(sessionID: string): Promise<void> {
|
||||
if (!this.pendingParentWakes.has(sessionID)) {
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
await settleAfterSessionIdle()
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const latestWake = this.pendingParentWakes.get(sessionID)
|
||||
if (!latestWake) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await this.shouldDeferParentWakeForSessionHistory(sessionID, latestWake)) {
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingParentWakes.delete(sessionID)
|
||||
|
||||
const notificationContent = latestWake.notifications.join("\n\n")
|
||||
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
client: this.deps.client,
|
||||
sessionID,
|
||||
source: "background-agent-parent-wake",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 250,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: !latestWake.shouldReply,
|
||||
...latestWake.promptContext,
|
||||
parts: [createInternalAgentTextPart(notificationContent)],
|
||||
},
|
||||
query: { directory: this.deps.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
throw promptResult.error
|
||||
}
|
||||
if (promptResult.status !== "dispatched") {
|
||||
this.requeueWake(sessionID, latestWake)
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||
this.trackDispatchedParentWake(sessionID, latestWake)
|
||||
} catch (error) {
|
||||
this.requeueWake(sessionID, latestWake)
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||
}
|
||||
}
|
||||
|
||||
clearDispatchedParentWake(sessionID: string): void {
|
||||
const timer = this.dispatchedParentWakeTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
}
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}
|
||||
|
||||
async requeueDispatchedParentWake(sessionID: string, reason: string): Promise<boolean> {
|
||||
const wake = this.dispatchedParentWakes.get(sessionID)
|
||||
if (!wake) {
|
||||
return false
|
||||
}
|
||||
|
||||
await settleAfterSessionIdle()
|
||||
|
||||
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, wake)) {
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
log("[background-agent] Ignored late parent wake failure after assistant output:", {
|
||||
sessionID,
|
||||
reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
this.requeueWake(sessionID, wake)
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
log("[background-agent] Requeued dispatched parent wake after prompt failure:", {
|
||||
sessionID,
|
||||
reason,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void {
|
||||
if (this.pendingParentWakeTimers.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingParentWakeTimers.delete(sessionID)
|
||||
void this.deps.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
|
||||
})
|
||||
}, delayMs ?? this.options.pendingRetryMs)
|
||||
|
||||
this.pendingParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
clearPendingParentWakeTimer(sessionID: string): void {
|
||||
const timer = this.pendingParentWakeTimers.get(sessionID)
|
||||
if (!timer) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timer)
|
||||
this.pendingParentWakeTimers.delete(sessionID)
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
for (const timer of this.pendingParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.pendingParentWakeTimers.clear()
|
||||
|
||||
for (const timer of this.dispatchedParentWakeTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.dispatchedParentWakeTimers.clear()
|
||||
this.pendingParentWakes.clear()
|
||||
this.dispatchedParentWakes.clear()
|
||||
}
|
||||
|
||||
private async isSessionActive(sessionID: string): Promise<boolean> {
|
||||
return isOpenCodeSessionActive(this.deps.client, sessionID)
|
||||
}
|
||||
|
||||
private resolveParentWakePromptContext(promptContext: ParentWakePromptContext): ParentWakePromptContext {
|
||||
const resolvedAgent = resolveRegisteredAgentName(promptContext.agent)
|
||||
return {
|
||||
...promptContext,
|
||||
...(resolvedAgent ? { agent: resolvedAgent } : {}),
|
||||
...(promptContext.model ? { model: { ...promptContext.model } } : {}),
|
||||
...(promptContext.tools ? { tools: { ...promptContext.tools } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
private cloneParentWake(wake: PendingParentWake): PendingParentWake {
|
||||
const promptContext = this.resolveParentWakePromptContext(wake.promptContext)
|
||||
return {
|
||||
promptContext,
|
||||
notifications: [...wake.notifications],
|
||||
shouldReply: wake.shouldReply,
|
||||
...(wake.dispatchedAt !== undefined ? { dispatchedAt: wake.dispatchedAt } : {}),
|
||||
...(wake.toolCallDeferralStartedAt !== undefined
|
||||
? { toolCallDeferralStartedAt: wake.toolCallDeferralStartedAt }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
private trackDispatchedParentWake(sessionID: string, wake: PendingParentWake): void {
|
||||
this.clearDispatchedParentWake(sessionID)
|
||||
const dispatchedWake = this.cloneParentWake(wake)
|
||||
dispatchedWake.dispatchedAt = Date.now()
|
||||
this.dispatchedParentWakes.set(sessionID, dispatchedWake)
|
||||
const timer = setTimeout(() => {
|
||||
this.dispatchedParentWakeTimers.delete(sessionID)
|
||||
this.dispatchedParentWakes.delete(sessionID)
|
||||
}, this.options.failureRequeueWindowMs)
|
||||
this.dispatchedParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
|
||||
try {
|
||||
const messagesResp = await messagesInDirectory(this.deps.client, {
|
||||
path: { id: sessionID },
|
||||
}, this.deps.directory)
|
||||
return normalizeSDKResponse(messagesResp, [] as ParentWakeSessionMessage[])
|
||||
} catch (error) {
|
||||
log("[background-agent] Failed to inspect parent session messages for wake safety:", {
|
||||
sessionID,
|
||||
error,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getParentWakeMessageRole(message: ParentWakeSessionMessage): string | undefined {
|
||||
return message.info?.role ?? message.role
|
||||
}
|
||||
|
||||
private getParentWakeMessageFinish(message: ParentWakeSessionMessage): string | undefined {
|
||||
return message.info?.finish ?? message.finish
|
||||
}
|
||||
|
||||
private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined {
|
||||
const value = message.info?.time?.created ?? message.time?.created
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.getTime()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private latestAssistantTurnIsWaitingOnTools(messages: ParentWakeSessionMessage[]): boolean {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
const role = this.getParentWakeMessageRole(message)
|
||||
if (role === "assistant") {
|
||||
return this.getParentWakeMessageFinish(message) === "tool-calls"
|
||||
}
|
||||
if (role === "user") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private parentWakeMessageHasOutput(message: ParentWakeSessionMessage): boolean {
|
||||
const role = this.getParentWakeMessageRole(message)
|
||||
if (role !== "assistant" && role !== "tool") {
|
||||
return false
|
||||
}
|
||||
if (!message.parts || message.parts.length === 0) {
|
||||
return role === "assistant"
|
||||
}
|
||||
return message.parts.some((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
return typeof part.text === "string" && part.text.trim().length > 0
|
||||
}
|
||||
if (part.type === "tool" || part.type === "tool_result") {
|
||||
return true
|
||||
}
|
||||
if (part.content !== undefined) {
|
||||
if (typeof part.content === "string") {
|
||||
return part.content.trim().length > 0
|
||||
}
|
||||
if (Array.isArray(part.content)) {
|
||||
return part.content.length > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
private parentWakeMessageContainsNotification(message: ParentWakeSessionMessage, wake: PendingParentWake): boolean {
|
||||
if (this.getParentWakeMessageRole(message) !== "user") {
|
||||
return false
|
||||
}
|
||||
return message.parts?.some((part) =>
|
||||
typeof part.text === "string" && wake.notifications.some((notification) => part.text?.includes(notification))
|
||||
) ?? false
|
||||
}
|
||||
|
||||
private async shouldDeferParentWakeForSessionHistory(sessionID: string, wake: PendingParentWake): Promise<boolean> {
|
||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||
if (!this.latestAssistantTurnIsWaitingOnTools(messages)) {
|
||||
delete wake.toolCallDeferralStartedAt
|
||||
return false
|
||||
}
|
||||
const now = Date.now()
|
||||
wake.toolCallDeferralStartedAt ??= now
|
||||
if (wake.shouldReply && now - wake.toolCallDeferralStartedAt >= this.options.toolCallDeferMaxMs) {
|
||||
log("[background-agent] Sending parent wake after stale tool-call deferral window:", {
|
||||
sessionID,
|
||||
})
|
||||
return false
|
||||
}
|
||||
log("[background-agent] Deferred parent wake because latest assistant turn is waiting on tool results:", {
|
||||
sessionID,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async hasAcceptedMessageAfterDispatchedParentWake(sessionID: string, wake: PendingParentWake): Promise<boolean> {
|
||||
if (wake.dispatchedAt === undefined) {
|
||||
return false
|
||||
}
|
||||
const dispatchedAt = wake.dispatchedAt
|
||||
const messages = await this.loadParentWakeSessionMessages(sessionID)
|
||||
return messages.some((message) => {
|
||||
const createdAt = this.getParentWakeMessageCreatedAt(message)
|
||||
if (createdAt === undefined) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
createdAt >= dispatchedAt - this.options.acceptedMessageSkewMs
|
||||
&& this.parentWakeMessageContainsNotification(message, wake)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return createdAt >= dispatchedAt && this.parentWakeMessageHasOutput(message)
|
||||
})
|
||||
}
|
||||
|
||||
private requeueWake(sessionID: string, latestWake: PendingParentWake): void {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
pendingWake.notifications.unshift(...latestWake.notifications)
|
||||
pendingWake.shouldReply = pendingWake.shouldReply || latestWake.shouldReply
|
||||
pendingWake.promptContext = latestWake.promptContext
|
||||
pendingWake.toolCallDeferralStartedAt ??= latestWake.toolCallDeferralStartedAt
|
||||
return
|
||||
}
|
||||
this.pendingParentWakes.set(sessionID, this.cloneParentWake(latestWake))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
|
||||
import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
@@ -182,6 +183,7 @@ export async function startTask(
|
||||
taskId: task.id,
|
||||
})
|
||||
try {
|
||||
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
||||
await promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||
@@ -320,6 +322,7 @@ export async function resumeTask(
|
||||
taskId: task.id,
|
||||
})
|
||||
try {
|
||||
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
||||
await promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
|
||||
|
||||
@@ -98,9 +98,8 @@ function createManager(
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const placeholderClient = {} as PluginInput["client"]
|
||||
const ctx: PluginInput = {
|
||||
client: placeholderClient,
|
||||
client: client as PluginInput["client"],
|
||||
project: {} as PluginInput["project"],
|
||||
directory: tmpdir(),
|
||||
worktree: tmpdir(),
|
||||
@@ -111,7 +110,6 @@ function createManager(
|
||||
const manager = new BackgroundManager(
|
||||
{ pluginContext: ctx, config: undefined, enableParentSessionNotifications }
|
||||
)
|
||||
Reflect.set(manager, "client", client)
|
||||
|
||||
return { manager, promptAsyncCalls }
|
||||
}
|
||||
@@ -174,7 +172,10 @@ function getPendingNotifications(manager: BackgroundManager): Map<string, string
|
||||
}
|
||||
|
||||
function getPendingParentWakes(manager: BackgroundManager): Map<string, PendingParentWakeForTest> {
|
||||
return Reflect.get(manager, "pendingParentWakes") as Map<string, PendingParentWakeForTest>
|
||||
const parentWakeNotifier = Reflect.get(manager, "parentWakeNotifier") as {
|
||||
getPendingParentWakes: () => Map<string, PendingParentWakeForTest>
|
||||
}
|
||||
return parentWakeNotifier.getPendingParentWakes()
|
||||
}
|
||||
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import {
|
||||
promptAfterSessionIdle,
|
||||
promptAsyncAfterSessionIdle,
|
||||
releasePromptAsyncReservation,
|
||||
releaseAllPromptAsyncReservationsForTesting,
|
||||
releasePromptAsyncReservation,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
describe("promptAsyncAfterSessionIdle", () => {
|
||||
@@ -76,7 +76,7 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
source: "test:hold:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const firstResult = await first
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_hold_after_dispatch",
|
||||
@@ -84,7 +84,6 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
source: "test:hold:second",
|
||||
settleMs: 0,
|
||||
})
|
||||
const firstResult = await first
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
@@ -122,6 +121,9 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
test("#given dispatch hold has expired #when the same session prompts again #then the next promptAsync is accepted", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const originalDateNow = Date.now
|
||||
let currentNow = originalDateNow()
|
||||
Date.now = () => currentNow
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
@@ -130,29 +132,33 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
source: "test:expired:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 1,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
source: "test:expired:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
try {
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
source: "test:expired:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 1,
|
||||
})
|
||||
currentNow += 2
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
source: "test:expired:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("dispatched")
|
||||
expect(second.status).toBe("dispatched")
|
||||
expect(promptCalls).toBe(2)
|
||||
// then
|
||||
expect(first.status).toBe("dispatched")
|
||||
expect(second.status).toBe("dispatched")
|
||||
expect(promptCalls).toBe(2)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("#given a peer-message promptAsync hold #when an unrelated route releases the session #then the peer-message hold remains reserved", async () => {
|
||||
@@ -243,6 +249,125 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
expect(promptCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("#given promptAsync dispatch never settles #when dispatch timeout elapses #then reservation is released for the next caller", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const neverSettles = new Promise<void>(() => {})
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
await neverSettles
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_dispatch_timeout",
|
||||
input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } },
|
||||
source: "test:timeout:first",
|
||||
settleMs: 0,
|
||||
dispatchTimeoutMs: 1,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_dispatch_timeout",
|
||||
input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } },
|
||||
source: "test:timeout:second",
|
||||
settleMs: 0,
|
||||
dispatchTimeoutMs: 1,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("failed")
|
||||
expect(second.status).toBe("failed")
|
||||
expect(promptCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("#given promptAsync rejects after dispatch #when a second caller races immediately #then post-dispatch hold still blocks duplicate", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
throw new Error("post-dispatch failure")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_post_dispatch_reject",
|
||||
input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } },
|
||||
source: "test:reject:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_post_dispatch_reject",
|
||||
input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } },
|
||||
source: "test:reject:second",
|
||||
settleMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("failed")
|
||||
expect(second).toEqual({ status: "reserved", reservedBy: "test:reject:first" })
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("#given a similarly named sibling route #when reservedByPrefix uses a strict family prefix #then release does not clear sibling reservation", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
promptCalls += 1
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prefix_sibling",
|
||||
input: {
|
||||
path: { id: "ses_prefix_sibling" },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
},
|
||||
source: "model-fallbackx:message.updated",
|
||||
settleMs: 0,
|
||||
})
|
||||
const released = releasePromptAsyncReservation(
|
||||
"ses_prefix_sibling",
|
||||
"model-fallback-abort:session.error",
|
||||
{ reservedByPrefix: "model-fallback:" },
|
||||
)
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prefix_sibling",
|
||||
input: {
|
||||
path: { id: "ses_prefix_sibling" },
|
||||
body: { parts: [{ type: "text", text: "continue again" }] },
|
||||
},
|
||||
source: "model-fallback:session.error",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("dispatched")
|
||||
expect(released).toBe(false)
|
||||
expect(second).toEqual({ status: "reserved", reservedBy: "model-fallbackx:message.updated" })
|
||||
expect(promptCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("#given two internal prompt calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => {
|
||||
// given
|
||||
let promptCalls = 0
|
||||
@@ -306,7 +431,7 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
source: "test:prompt-hold:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const firstResult = await first
|
||||
const second = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
@@ -314,7 +439,6 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
source: "test:prompt-hold:second",
|
||||
settleMs: 0,
|
||||
})
|
||||
const firstResult = await first
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { createPluginModule } from "./index"
|
||||
import { createPluginModule } from "./testing/create-plugin-module"
|
||||
|
||||
const mockInitConfigContext = mock(() => {})
|
||||
const mockInjectServerAuthIntoClient = mock(() => {})
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { createPluginModule } from "./index"
|
||||
import { createPluginModule } from "./testing/create-plugin-module"
|
||||
|
||||
const mockInitConfigContext = mock(() => {})
|
||||
const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null }))
|
||||
|
||||
+5
-183
@@ -1,196 +1,18 @@
|
||||
import { initConfigContext } from "./cli/config-manager/config-context"
|
||||
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
|
||||
|
||||
import type { HookName } from "./config"
|
||||
|
||||
import { createHooks } from "./create-hooks"
|
||||
import { createManagers } from "./create-managers"
|
||||
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runtime-tmux-config"
|
||||
import { createTools } from "./create-tools"
|
||||
import { initializeOpenClaw } from "./openclaw"
|
||||
import { createPluginInterface } from "./plugin-interface"
|
||||
import {
|
||||
createCompactionAutocontinueHandler,
|
||||
createSessionCompactingHandler,
|
||||
type CompactionAutocontinueHook,
|
||||
} from "./plugin/session-compacting"
|
||||
|
||||
import { loadPluginConfig } from "./plugin-config"
|
||||
import { createModelCacheState } from "./plugin-state"
|
||||
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
||||
import { log } from "./shared/logger"
|
||||
import { logLegacyPluginStartupWarning } from "./shared/log-legacy-plugin-startup-warning"
|
||||
import { injectServerAuthIntoClient } from "./shared/opencode-server-auth"
|
||||
import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shim"
|
||||
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
|
||||
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
|
||||
|
||||
type HooksWithCompactionAutocontinue = Hooks & {
|
||||
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
|
||||
}
|
||||
|
||||
type PluginModuleDeps = {
|
||||
initConfigContext: typeof initConfigContext
|
||||
installAgentSortShim: typeof installAgentSortShim
|
||||
setAgentSortOrder: typeof setAgentSortOrder
|
||||
log: typeof log
|
||||
logLegacyPluginStartupWarning: typeof logLegacyPluginStartupWarning
|
||||
detectExternalSkillPlugin: typeof detectExternalSkillPlugin
|
||||
getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning
|
||||
injectServerAuthIntoClient: typeof injectServerAuthIntoClient
|
||||
loadPluginConfig: typeof loadPluginConfig
|
||||
initializeOpenClaw: typeof initializeOpenClaw
|
||||
isTmuxIntegrationEnabled: typeof isTmuxIntegrationEnabled
|
||||
startTmuxCheck: typeof startTmuxCheck
|
||||
createFirstMessageVariantGate: typeof createFirstMessageVariantGate
|
||||
createRuntimeTmuxConfig: typeof createRuntimeTmuxConfig
|
||||
createModelCacheState: typeof createModelCacheState
|
||||
createManagers: typeof createManagers
|
||||
createTools: typeof createTools
|
||||
createHooks: typeof createHooks
|
||||
createPluginInterface: typeof createPluginInterface
|
||||
}
|
||||
|
||||
const defaultPluginModuleDeps: PluginModuleDeps = {
|
||||
initConfigContext,
|
||||
installAgentSortShim,
|
||||
setAgentSortOrder,
|
||||
log,
|
||||
logLegacyPluginStartupWarning,
|
||||
detectExternalSkillPlugin,
|
||||
getSkillPluginConflictWarning,
|
||||
injectServerAuthIntoClient,
|
||||
loadPluginConfig,
|
||||
initializeOpenClaw,
|
||||
isTmuxIntegrationEnabled,
|
||||
startTmuxCheck,
|
||||
createFirstMessageVariantGate,
|
||||
createRuntimeTmuxConfig,
|
||||
createModelCacheState,
|
||||
createManagers,
|
||||
createTools,
|
||||
createHooks,
|
||||
createPluginInterface,
|
||||
}
|
||||
|
||||
export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): PluginModule {
|
||||
const deps = { ...defaultPluginModuleDeps, ...overrides }
|
||||
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||
deps.installAgentSortShim()
|
||||
deps.initConfigContext("opencode", null)
|
||||
deps.log("[oh-my-openagent] ENTRY - plugin loading", {
|
||||
directory: input.directory,
|
||||
})
|
||||
deps.logLegacyPluginStartupWarning()
|
||||
|
||||
const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory)
|
||||
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
|
||||
console.warn(deps.getSkillPluginConflictWarning(skillPluginCheck.pluginName))
|
||||
}
|
||||
|
||||
deps.injectServerAuthIntoClient(input.client)
|
||||
|
||||
const pluginConfig = deps.loadPluginConfig(input.directory, input)
|
||||
deps.setAgentSortOrder(pluginConfig.agent_order)
|
||||
|
||||
if (pluginConfig.openclaw) {
|
||||
await deps.initializeOpenClaw(pluginConfig.openclaw)
|
||||
}
|
||||
if (pluginConfig.team_mode?.enabled) {
|
||||
const teamModeConfig = pluginConfig.team_mode
|
||||
try {
|
||||
const { ensureBaseDirs, resolveBaseDir } = await import("./features/team-mode/team-registry/paths")
|
||||
const { checkTeamModeDependencies } = await import("./features/team-mode/deps")
|
||||
await checkTeamModeDependencies(teamModeConfig)
|
||||
await ensureBaseDirs(resolveBaseDir(teamModeConfig))
|
||||
if (pluginConfig.disabled_skills?.includes("team-mode")) {
|
||||
console.warn(
|
||||
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[team-mode] init failed:", err)
|
||||
}
|
||||
}
|
||||
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
|
||||
if (tmuxIntegrationEnabled) {
|
||||
deps.startTmuxCheck()
|
||||
}
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
|
||||
|
||||
const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName)
|
||||
const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true
|
||||
|
||||
const firstMessageVariantGate = deps.createFirstMessageVariantGate()
|
||||
|
||||
const tmuxConfig = deps.createRuntimeTmuxConfig(pluginConfig)
|
||||
|
||||
const modelCacheState = deps.createModelCacheState()
|
||||
|
||||
const managers = deps.createManagers({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
tmuxConfig,
|
||||
modelCacheState,
|
||||
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
|
||||
})
|
||||
|
||||
const toolsResult = await deps.createTools({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
managers,
|
||||
})
|
||||
|
||||
const hooks = deps.createHooks({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
backgroundManager: managers.backgroundManager,
|
||||
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
mergedSkills: toolsResult.mergedSkills,
|
||||
availableSkills: toolsResult.availableSkills,
|
||||
})
|
||||
|
||||
const pluginInterface = deps.createPluginInterface({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
firstMessageVariantGate,
|
||||
managers,
|
||||
hooks,
|
||||
tools: toolsResult.filteredTools,
|
||||
})
|
||||
|
||||
const pluginHooks: HooksWithCompactionAutocontinue = {
|
||||
...pluginInterface,
|
||||
|
||||
"experimental.session.compacting": createSessionCompactingHandler(hooks),
|
||||
|
||||
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
|
||||
}
|
||||
|
||||
return pluginHooks
|
||||
}
|
||||
|
||||
return {
|
||||
id: "oh-my-openagent",
|
||||
server: serverPlugin,
|
||||
}
|
||||
}
|
||||
import type { PluginModule } from "@opencode-ai/plugin"
|
||||
import { createPluginModule } from "./testing/create-plugin-module"
|
||||
|
||||
const pluginModule: PluginModule = createPluginModule()
|
||||
|
||||
export default pluginModule
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
AgentName,
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
HookName,
|
||||
BuiltinCommandName,
|
||||
HookName,
|
||||
McpName,
|
||||
OhMyOpenCodeConfig,
|
||||
} from "./config"
|
||||
|
||||
export type { ConfigLoadError } from "./shared/config-errors"
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import ts from "typescript"
|
||||
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map<string, string>([
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for ast-grep tool module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "tools", "ast-grep", "tools.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for doctor dependency module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "cli", "doctor", "checks", "dependencies.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for session recovery module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "index.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for auto-update checker hook module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "auto-update-checker", "hook.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux layout-runner module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "layout-runner.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close-runner module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close-runner.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-dimensions module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-dimensions.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill-runner module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill-runner.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
// TODO(MOCK-MODULE-AUDIT): add cleanup for tmux stale-session sweep module mocks.
|
||||
[
|
||||
path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "stale-session-sweep-runtime.test.ts"),
|
||||
"justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup",
|
||||
],
|
||||
])
|
||||
|
||||
async function listTestFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const nestedFiles = await Promise.all(entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
return listTestFiles(entryPath)
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".test.ts") && !entry.name.endsWith(".d.ts")) {
|
||||
return [entryPath]
|
||||
}
|
||||
return []
|
||||
}))
|
||||
|
||||
return nestedFiles.flat()
|
||||
}
|
||||
|
||||
function relativeSourcePath(filePath: string): string {
|
||||
return path.relative(SOURCE_ROOT, filePath)
|
||||
}
|
||||
|
||||
function isMockModuleCall(node: ts.CallExpression): boolean {
|
||||
const expression = node.expression
|
||||
return ts.isPropertyAccessExpression(expression)
|
||||
&& ts.isIdentifier(expression.expression)
|
||||
&& expression.expression.text === "mock"
|
||||
&& expression.name.text === "module"
|
||||
}
|
||||
|
||||
function getMockModulePath(node: ts.CallExpression): string | null {
|
||||
if (!isMockModuleCall(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modulePath = node.arguments[0]
|
||||
if (!modulePath || !ts.isStringLiteralLike(modulePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return modulePath.text
|
||||
}
|
||||
|
||||
function collectMockModulePaths(sourceFile: ts.SourceFile): string[] {
|
||||
const modulePaths: string[] = []
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const modulePath = getMockModulePath(node)
|
||||
if (modulePath) {
|
||||
modulePaths.push(modulePath)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return modulePaths
|
||||
}
|
||||
|
||||
function hasMockModuleCall(sourceFile: ts.SourceFile): boolean {
|
||||
return collectMockModulePaths(sourceFile).length > 0
|
||||
}
|
||||
|
||||
function hasDuplicateModuleReset(sourceFile: ts.SourceFile): boolean {
|
||||
const seenModulePaths = new Set<string>()
|
||||
for (const modulePath of collectMockModulePaths(sourceFile)) {
|
||||
if (seenModulePaths.has(modulePath)) {
|
||||
return true
|
||||
}
|
||||
seenModulePaths.add(modulePath)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isCleanupCall(node: ts.CallExpression): boolean {
|
||||
if (ts.isIdentifier(node.expression)) {
|
||||
return node.expression.text === "afterEach" || node.expression.text === "afterAll"
|
||||
}
|
||||
|
||||
const expression = node.expression
|
||||
return ts.isPropertyAccessExpression(expression)
|
||||
&& ts.isIdentifier(expression.expression)
|
||||
&& expression.expression.text === "mock"
|
||||
&& expression.name.text === "restore"
|
||||
}
|
||||
|
||||
function hasCleanupPattern(sourceFile: ts.SourceFile): boolean {
|
||||
if (hasDuplicateModuleReset(sourceFile)) {
|
||||
return true
|
||||
}
|
||||
|
||||
let foundCleanup = false
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (foundCleanup) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node) && isCleanupCall(node)) {
|
||||
foundCleanup = true
|
||||
return
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return foundCleanup
|
||||
}
|
||||
|
||||
describe("mock.module lifecycle hygiene", () => {
|
||||
test("#given test files using mock.module #when audited #then each must pair with cleanup", async () => {
|
||||
// given
|
||||
const files = await listTestFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
if (MOCK_MODULE_LIFECYCLE_ALLOWLIST.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true)
|
||||
if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
expect(offenders.sort()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
PROMPT_TIMEOUT_MS,
|
||||
type PromptRetryOptions,
|
||||
} from "./prompt-timeout-context"
|
||||
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle } from "./prompt-async-gate"
|
||||
import {
|
||||
promptAfterSessionIdle,
|
||||
promptAsyncAfterSessionIdle,
|
||||
releasePromptAsyncReservation,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -119,6 +123,7 @@ export async function promptWithModelSuggestionRetry(
|
||||
if (timeoutContext.wasTimedOut()) {
|
||||
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
|
||||
}
|
||||
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry")
|
||||
throw error
|
||||
} finally {
|
||||
timeoutContext.cleanup()
|
||||
@@ -169,6 +174,11 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
throw error
|
||||
}
|
||||
|
||||
// The first attempt failed synchronously with ProviderModelNotFoundError, which means the
|
||||
// prompt did not reach the server. Release the post-dispatch reservation hold so the
|
||||
// immediate retry can dispatch without waiting for the hold window to expire.
|
||||
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry:sync")
|
||||
|
||||
log("[model-suggestion-retry] Model not found, retrying with suggestion", {
|
||||
original: `${suggestion.providerID}/${suggestion.modelID}`,
|
||||
suggested: suggestion.suggestion,
|
||||
|
||||
+140
-103
@@ -6,6 +6,7 @@ import {
|
||||
} from "./session-idle-settle"
|
||||
|
||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path?: { id?: string }
|
||||
@@ -36,6 +37,9 @@ type PromptAsyncReservation = {
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||
|
||||
export type PromptAsyncGateResult =
|
||||
| { status: "dispatched"; response: unknown }
|
||||
| { status: "active" }
|
||||
@@ -84,11 +88,114 @@ function reservationSourceMatches(
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof expectedPrefix === "string") {
|
||||
return reservationSource.startsWith(expectedPrefix)
|
||||
const prefixes = typeof expectedPrefix === "string" ? [expectedPrefix] : expectedPrefix
|
||||
return prefixes
|
||||
.filter((prefix) => prefix.length > 0 && prefix.endsWith(":"))
|
||||
.some((prefix) => reservationSource.startsWith(prefix))
|
||||
}
|
||||
|
||||
async function withDispatchTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
dispatchTimeoutMs: number,
|
||||
operationName: string,
|
||||
): Promise<T> {
|
||||
if (dispatchTimeoutMs <= 0) {
|
||||
return operation
|
||||
}
|
||||
|
||||
return expectedPrefix.some((prefix) => reservationSource.startsWith(prefix))
|
||||
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`))
|
||||
}, dispatchTimeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([operation, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutID !== undefined) {
|
||||
clearTimeout(timeoutID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
sessionName: "promptAsync" | "prompt"
|
||||
client: { session?: { status?: () => Promise<unknown> } }
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs: number
|
||||
postDispatchHoldMs: number
|
||||
dispatchTimeoutMs: number
|
||||
checkStatus: boolean
|
||||
dispatch: (input: TInput) => Promise<unknown>
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
sessionName,
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus,
|
||||
dispatch,
|
||||
} = args
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log(`[prompt-async-gate] ${sessionName} skipped because session is reserved`, {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let dispatchAttempted = false
|
||||
|
||||
try {
|
||||
const canReadStatus = checkStatus && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log(`[prompt-async-gate] ${sessionName} skipped because session is active`, { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log(`[prompt-async-gate] ${sessionName} dispatching`, { sessionID, source })
|
||||
dispatchAttempted = true
|
||||
const response = await withDispatchTimeout(
|
||||
dispatch(input),
|
||||
dispatchTimeoutMs,
|
||||
`[prompt-async-gate] ${sessionName} dispatch`,
|
||||
)
|
||||
log(`[prompt-async-gate] ${sessionName} dispatched`, { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log(`[prompt-async-gate] ${sessionName} failed`, { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (dispatchAttempted && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
@@ -98,6 +205,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
@@ -108,62 +216,26 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const promptAsync = client.session?.promptAsync
|
||||
|
||||
if (typeof client.session?.promptAsync !== "function") {
|
||||
if (typeof promptAsync !== "function") {
|
||||
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] promptAsync skipped because session is reserved", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName: "promptAsync",
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let holdReservationAfterDispatch = false
|
||||
|
||||
try {
|
||||
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log("[prompt-async-gate] promptAsync skipped because session is active", { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log("[prompt-async-gate] promptAsync dispatching", { sessionID, source })
|
||||
const response = await client.session.promptAsync(input)
|
||||
if (postDispatchHoldMs > 0) {
|
||||
holdReservationAfterDispatch = true
|
||||
}
|
||||
log("[prompt-async-gate] promptAsync dispatched", { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] promptAsync failed", { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
dispatch: (dispatchInput) => promptAsync(dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
@@ -173,6 +245,7 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
@@ -183,62 +256,26 @@ export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const prompt = client.session?.prompt
|
||||
|
||||
if (typeof client.session?.prompt !== "function") {
|
||||
if (typeof prompt !== "function") {
|
||||
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
|
||||
const existing = getActiveReservation(sessionID)
|
||||
if (existing) {
|
||||
log("[prompt-async-gate] prompt skipped because session is reserved", {
|
||||
sessionID,
|
||||
source,
|
||||
reservedBy: existing.source,
|
||||
reservedAgeMs: Date.now() - existing.reservedAt,
|
||||
})
|
||||
return { status: "reserved", reservedBy: existing.source }
|
||||
}
|
||||
|
||||
const reservation: PromptAsyncReservation = {
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName: "prompt",
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
reservedAt: Date.now(),
|
||||
token: Symbol(source),
|
||||
}
|
||||
promptAsyncReservations.set(sessionID, reservation)
|
||||
let holdReservationAfterDispatch = false
|
||||
|
||||
try {
|
||||
const canReadStatus = args.checkStatus !== false && typeof client.session?.status === "function"
|
||||
if (settleMs > 0) {
|
||||
await settleAfterSessionIdle(settleMs)
|
||||
}
|
||||
|
||||
if (canReadStatus && await isSessionActive(client, sessionID)) {
|
||||
log("[prompt-async-gate] prompt skipped because session is active", { sessionID, source })
|
||||
return { status: "active" }
|
||||
}
|
||||
|
||||
log("[prompt-async-gate] prompt dispatching", { sessionID, source })
|
||||
const response = await client.session.prompt(input)
|
||||
if (postDispatchHoldMs > 0) {
|
||||
holdReservationAfterDispatch = true
|
||||
}
|
||||
log("[prompt-async-gate] prompt dispatched", { sessionID, source })
|
||||
return { status: "dispatched", response }
|
||||
} catch (error) {
|
||||
log("[prompt-async-gate] prompt failed", { sessionID, source, error: String(error) })
|
||||
return { status: "failed", error }
|
||||
} finally {
|
||||
const current = promptAsyncReservations.get(sessionID)
|
||||
if (current?.token === reservation.token) {
|
||||
if (holdReservationAfterDispatch && postDispatchHoldMs > 0) {
|
||||
reservation.expiresAt = Date.now() + postDispatchHoldMs
|
||||
} else {
|
||||
promptAsyncReservations.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
dispatch: (dispatchInput) => prompt(dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import ts from "typescript"
|
||||
|
||||
const SOURCE_ROOT = path.resolve(import.meta.dir, "..")
|
||||
const PROMPT_GATE_FILE = path.join(SOURCE_ROOT, "shared", "prompt-async-gate.ts")
|
||||
const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
|
||||
[
|
||||
path.join(SOURCE_ROOT, "plugin", "event.ts"),
|
||||
"team idle wake hint wires a client facade for downstream gate-routed dispatch",
|
||||
],
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
|
||||
"runtime type guard checks promptAsync presence before gate-routed promptAsyncAfterSessionIdle",
|
||||
],
|
||||
])
|
||||
|
||||
async function listSourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
@@ -30,35 +41,220 @@ function relativeSourcePath(filePath: string): string {
|
||||
return path.relative(SOURCE_ROOT, filePath)
|
||||
}
|
||||
|
||||
function uncommentedLines(contents: string): string[] {
|
||||
return contents
|
||||
.split("\n")
|
||||
.map((line) => line.trimStart())
|
||||
.filter((line) => !line.startsWith("//") && !line.startsWith("*"))
|
||||
function getPropertyName(node: ts.PropertyName | ts.MemberName | ts.Expression): string | null {
|
||||
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
|
||||
return node.text
|
||||
}
|
||||
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.text
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
||||
if (ts.isParenthesizedExpression(expression)) {
|
||||
return unwrapExpression(expression.expression)
|
||||
}
|
||||
|
||||
if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) {
|
||||
return unwrapExpression(expression.expression)
|
||||
}
|
||||
|
||||
if (ts.isNonNullExpression(expression)) {
|
||||
return unwrapExpression(expression.expression)
|
||||
}
|
||||
|
||||
return expression
|
||||
}
|
||||
|
||||
function isSessionAccessExpression(expression: ts.Expression): boolean {
|
||||
const unwrapped = unwrapExpression(expression)
|
||||
|
||||
if (ts.isIdentifier(unwrapped)) {
|
||||
return unwrapped.text === "session"
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isPropertyAccessExpression(unwrapped)
|
||||
|| ts.isPropertyAccessChain(unwrapped)
|
||||
) {
|
||||
const propertyName = getPropertyName(unwrapped.name)
|
||||
return propertyName === "session"
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isElementAccessExpression(unwrapped)
|
||||
|| ts.isElementAccessChain(unwrapped)
|
||||
) {
|
||||
const argument = unwrapped.argumentExpression
|
||||
if (!argument) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getPropertyName(argument) === "session"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isRawPromptPropertyAccess(node: ts.Node): boolean {
|
||||
if (
|
||||
ts.isPropertyAccessExpression(node)
|
||||
|| ts.isPropertyAccessChain(node)
|
||||
) {
|
||||
const propertyName = getPropertyName(node.name)
|
||||
if (propertyName !== "prompt" && propertyName !== "promptAsync") {
|
||||
return false
|
||||
}
|
||||
|
||||
return isSessionAccessExpression(node.expression)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isElementAccessExpression(node)
|
||||
|| ts.isElementAccessChain(node)
|
||||
) {
|
||||
const argument = node.argumentExpression
|
||||
if (!argument) {
|
||||
return false
|
||||
}
|
||||
|
||||
const propertyName = getPropertyName(argument)
|
||||
if (propertyName !== "prompt" && propertyName !== "promptAsync") {
|
||||
return false
|
||||
}
|
||||
|
||||
return isSessionAccessExpression(node.expression)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isPromptBindingPattern(node: ts.Node): boolean {
|
||||
if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isObjectBindingPattern(node.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isSessionAccessExpression(node.initializer)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return node.name.elements.some((element) => {
|
||||
const keyName = element.propertyName
|
||||
? getPropertyName(element.propertyName)
|
||||
: getPropertyName(element.name)
|
||||
return keyName === "prompt" || keyName === "promptAsync"
|
||||
})
|
||||
}
|
||||
|
||||
function isReflectApplyPromptCall(node: ts.Node): boolean {
|
||||
if (!ts.isCallExpression(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const callee = unwrapExpression(node.expression)
|
||||
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "apply") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!ts.isIdentifier(callee.expression) || callee.expression.text !== "Reflect") {
|
||||
return false
|
||||
}
|
||||
|
||||
const firstArgument = node.arguments[0]
|
||||
if (!firstArgument) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isRawPromptPropertyAccess(firstArgument)
|
||||
}
|
||||
|
||||
function isTypeofPromptCheck(node: ts.Node): boolean {
|
||||
return ts.isTypeOfExpression(node.parent)
|
||||
}
|
||||
|
||||
function detectRawPromptInSnippet(contents: string): boolean {
|
||||
const sourceFile = ts.createSourceFile("audit-snippet.ts", contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
let detected = false
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (detected) {
|
||||
return
|
||||
}
|
||||
|
||||
const isRawPromptAccess = isRawPromptPropertyAccess(node) && !isTypeofPromptCheck(node)
|
||||
if (isRawPromptAccess || isPromptBindingPattern(node) || isReflectApplyPromptCall(node)) {
|
||||
detected = true
|
||||
return
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return detected
|
||||
}
|
||||
|
||||
describe("production prompt injection routes", () => {
|
||||
test("#given a destructuring promptAsync reference #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "const { promptAsync } = client.session"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given bracket promptAsync reference #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "const value = client['session']['promptAsync']"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given type-cast promptAsync reference #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "const promptAsync = (client.session as { promptAsync?: unknown }).promptAsync"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given optional-chain promptAsync call #when audit scans snippet #then it is flagged", () => {
|
||||
// given
|
||||
const snippet = "await client.session?.promptAsync({ body: { text: 'hi' } })"
|
||||
|
||||
// when
|
||||
const detected = detectRawPromptInSnippet(snippet)
|
||||
|
||||
// then
|
||||
expect(detected).toBe(true)
|
||||
})
|
||||
|
||||
test("#given production TypeScript sources #when prompt routes are audited #then only the shared gate may call raw OpenCode prompt APIs", async () => {
|
||||
// given
|
||||
const files = await listSourceFiles(SOURCE_ROOT)
|
||||
const offenders: string[] = []
|
||||
const rawPromptPatterns = [
|
||||
/\bsession\.promptAsync\s*\(/,
|
||||
/\bsession\.prompt\s*\(/,
|
||||
/\bReflect\.apply\s*\(\s*\w*promptAsync\b/,
|
||||
/\bReflect\.apply\s*\(\s*\w*prompt\b/,
|
||||
/\b(?:const|let|var)\s+\w*promptAsync\w*\s*=\s*[\w.]+\.session\.promptAsync\b/,
|
||||
/\b(?:const|let|var)\s+\w*prompt\w*\s*=\s*[\w.]+\.session\.prompt\b/,
|
||||
]
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
if (filePath === PROMPT_GATE_FILE) {
|
||||
if (filePath === PROMPT_GATE_FILE || RAW_PROMPT_ALLOWLIST.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
if (rawPromptPatterns.some((pattern) => pattern.test(contents))) {
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
if (detectRawPromptInSnippet(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
}
|
||||
@@ -74,7 +270,7 @@ describe("production prompt injection routes", () => {
|
||||
|
||||
// when
|
||||
for (const filePath of files) {
|
||||
const contents = uncommentedLines(await readFile(filePath, "utf8")).join("\n")
|
||||
const contents = await readFile(filePath, "utf8")
|
||||
if (/postDispatchHoldMs\s*:\s*0\b/.test(contents)) {
|
||||
offenders.push(relativeSourcePath(filePath))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
|
||||
import type { HookName } from "../config"
|
||||
import { initConfigContext } from "../cli/config-manager/config-context"
|
||||
|
||||
import { createHooks } from "../create-hooks"
|
||||
import { createManagers } from "../create-managers"
|
||||
import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "../create-runtime-tmux-config"
|
||||
import { createTools } from "../create-tools"
|
||||
import { initializeOpenClaw } from "../openclaw"
|
||||
import { createPluginInterface } from "../plugin-interface"
|
||||
import { loadPluginConfig } from "../plugin-config"
|
||||
import { createModelCacheState } from "../plugin-state"
|
||||
import {
|
||||
createCompactionAutocontinueHandler,
|
||||
createSessionCompactingHandler,
|
||||
type CompactionAutocontinueHook,
|
||||
} from "../plugin/session-compacting"
|
||||
import { installAgentSortShim, setAgentSortOrder } from "../shared/agent-sort-shim"
|
||||
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "../shared/external-plugin-detector"
|
||||
import { createFirstMessageVariantGate } from "../shared/first-message-variant"
|
||||
import { log } from "../shared/logger"
|
||||
import { logLegacyPluginStartupWarning } from "../shared/log-legacy-plugin-startup-warning"
|
||||
import { injectServerAuthIntoClient } from "../shared/opencode-server-auth"
|
||||
import { startBackgroundCheck as startTmuxCheck } from "../tools/interactive-bash"
|
||||
|
||||
type HooksWithCompactionAutocontinue = Hooks & {
|
||||
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
|
||||
}
|
||||
|
||||
export type PluginModuleDeps = {
|
||||
initConfigContext: typeof initConfigContext
|
||||
installAgentSortShim: typeof installAgentSortShim
|
||||
setAgentSortOrder: typeof setAgentSortOrder
|
||||
log: typeof log
|
||||
logLegacyPluginStartupWarning: typeof logLegacyPluginStartupWarning
|
||||
detectExternalSkillPlugin: typeof detectExternalSkillPlugin
|
||||
getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning
|
||||
injectServerAuthIntoClient: typeof injectServerAuthIntoClient
|
||||
loadPluginConfig: typeof loadPluginConfig
|
||||
initializeOpenClaw: typeof initializeOpenClaw
|
||||
isTmuxIntegrationEnabled: typeof isTmuxIntegrationEnabled
|
||||
startTmuxCheck: typeof startTmuxCheck
|
||||
createFirstMessageVariantGate: typeof createFirstMessageVariantGate
|
||||
createRuntimeTmuxConfig: typeof createRuntimeTmuxConfig
|
||||
createModelCacheState: typeof createModelCacheState
|
||||
createManagers: typeof createManagers
|
||||
createTools: typeof createTools
|
||||
createHooks: typeof createHooks
|
||||
createPluginInterface: typeof createPluginInterface
|
||||
}
|
||||
|
||||
const defaultPluginModuleDeps: PluginModuleDeps = {
|
||||
initConfigContext,
|
||||
installAgentSortShim,
|
||||
setAgentSortOrder,
|
||||
log,
|
||||
logLegacyPluginStartupWarning,
|
||||
detectExternalSkillPlugin,
|
||||
getSkillPluginConflictWarning,
|
||||
injectServerAuthIntoClient,
|
||||
loadPluginConfig,
|
||||
initializeOpenClaw,
|
||||
isTmuxIntegrationEnabled,
|
||||
startTmuxCheck,
|
||||
createFirstMessageVariantGate,
|
||||
createRuntimeTmuxConfig,
|
||||
createModelCacheState,
|
||||
createManagers,
|
||||
createTools,
|
||||
createHooks,
|
||||
createPluginInterface,
|
||||
}
|
||||
|
||||
export function createPluginModule(overrides: Partial<PluginModuleDeps> = {}): PluginModule {
|
||||
const deps = { ...defaultPluginModuleDeps, ...overrides }
|
||||
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||
deps.installAgentSortShim()
|
||||
deps.initConfigContext("opencode", null)
|
||||
deps.log("[oh-my-openagent] ENTRY - plugin loading", {
|
||||
directory: input.directory,
|
||||
})
|
||||
deps.logLegacyPluginStartupWarning()
|
||||
|
||||
const skillPluginCheck = deps.detectExternalSkillPlugin(input.directory)
|
||||
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
|
||||
console.warn(deps.getSkillPluginConflictWarning(skillPluginCheck.pluginName))
|
||||
}
|
||||
|
||||
deps.injectServerAuthIntoClient(input.client)
|
||||
|
||||
const pluginConfig = deps.loadPluginConfig(input.directory, input)
|
||||
deps.setAgentSortOrder(pluginConfig.agent_order)
|
||||
|
||||
if (pluginConfig.openclaw) {
|
||||
await deps.initializeOpenClaw(pluginConfig.openclaw)
|
||||
}
|
||||
if (pluginConfig.team_mode?.enabled) {
|
||||
const teamModeConfig = pluginConfig.team_mode
|
||||
try {
|
||||
const { ensureBaseDirs, resolveBaseDir } = await import("../features/team-mode/team-registry/paths")
|
||||
const { checkTeamModeDependencies } = await import("../features/team-mode/deps")
|
||||
await checkTeamModeDependencies(teamModeConfig)
|
||||
await ensureBaseDirs(resolveBaseDir(teamModeConfig))
|
||||
if (pluginConfig.disabled_skills?.includes("team-mode")) {
|
||||
console.warn(
|
||||
"[team-mode] enabled=true but team-mode skill is disabled; skill docs hidden but tools still registered (D-29)",
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[team-mode] init failed:", err)
|
||||
}
|
||||
}
|
||||
const tmuxIntegrationEnabled = deps.isTmuxIntegrationEnabled(pluginConfig)
|
||||
if (tmuxIntegrationEnabled) {
|
||||
deps.startTmuxCheck()
|
||||
}
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
|
||||
|
||||
const isHookEnabled = (hookName: HookName): boolean => !disabledHooks.has(hookName)
|
||||
const safeHookEnabled = pluginConfig.experimental?.safe_hook_creation ?? true
|
||||
|
||||
const firstMessageVariantGate = deps.createFirstMessageVariantGate()
|
||||
|
||||
const tmuxConfig = deps.createRuntimeTmuxConfig(pluginConfig)
|
||||
|
||||
const modelCacheState = deps.createModelCacheState()
|
||||
|
||||
const managers = deps.createManagers({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
tmuxConfig,
|
||||
modelCacheState,
|
||||
backgroundNotificationHookEnabled: isHookEnabled("background-notification"),
|
||||
})
|
||||
|
||||
const toolsResult = await deps.createTools({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
managers,
|
||||
})
|
||||
|
||||
const hooks = deps.createHooks({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
backgroundManager: managers.backgroundManager,
|
||||
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
mergedSkills: toolsResult.mergedSkills,
|
||||
availableSkills: toolsResult.availableSkills,
|
||||
})
|
||||
|
||||
const pluginInterface = deps.createPluginInterface({
|
||||
ctx: input,
|
||||
pluginConfig,
|
||||
firstMessageVariantGate,
|
||||
managers,
|
||||
hooks,
|
||||
tools: toolsResult.filteredTools,
|
||||
})
|
||||
|
||||
const pluginHooks: HooksWithCompactionAutocontinue = {
|
||||
...pluginInterface,
|
||||
|
||||
"experimental.session.compacting": createSessionCompactingHandler(hooks),
|
||||
|
||||
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
|
||||
}
|
||||
|
||||
return pluginHooks
|
||||
}
|
||||
|
||||
return {
|
||||
id: "oh-my-openagent",
|
||||
server: serverPlugin,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user