Merge pull request #4197 from code-yeongyu/fix/prompt-gate-event-shapes-and-finish-marker
fix(runtime-fallback,prompt-gate): recognize all OpenCode progress event shapes and boolean/completed finish markers
This commit is contained in:
@@ -149,6 +149,70 @@ describe("first-prompt-watchdog", () => {
|
|||||||
watchdog.dispose()
|
watchdog.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given session emits message.part.updated with sessionID under properties.part #when watchdog tracks #then the watchdog recognizes progress and resets the silence timer", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "session-nested-part-progress"
|
||||||
|
subagentSessions.add(sessionID)
|
||||||
|
const deps = createDeps(PLUGIN_CONFIG_WITH_FALLBACK)
|
||||||
|
const calls: RecordedCalls = { abort: [], autoRetry: [] }
|
||||||
|
const helpers = createHelpers(calls, AGENT)
|
||||||
|
const watchdog = createFirstPromptWatchdog(deps, helpers, WATCHDOG_MS)
|
||||||
|
|
||||||
|
// when
|
||||||
|
watchdog.onUserMessage(sessionID, PRIMARY_MODEL, AGENT)
|
||||||
|
await wait(SAFE_WAIT_BEFORE_FIRE_MS)
|
||||||
|
observeEventForWatchdog(
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "part-1",
|
||||||
|
messageID: "msg-1",
|
||||||
|
sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: "still working",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watchdog,
|
||||||
|
)
|
||||||
|
await wait(SAFE_WAIT_AFTER_FIRE_MS)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(calls.abort).toEqual([])
|
||||||
|
expect(calls.autoRetry).toEqual([])
|
||||||
|
|
||||||
|
watchdog.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given session emits message.part.delta with field/delta but no part.type #when watchdog tracks #then the watchdog recognizes progress", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "session-delta-progress"
|
||||||
|
subagentSessions.add(sessionID)
|
||||||
|
const deps = createDeps(PLUGIN_CONFIG_WITH_FALLBACK)
|
||||||
|
const calls: RecordedCalls = { abort: [], autoRetry: [] }
|
||||||
|
const helpers = createHelpers(calls, AGENT)
|
||||||
|
const watchdog = createFirstPromptWatchdog(deps, helpers, WATCHDOG_MS)
|
||||||
|
|
||||||
|
// when
|
||||||
|
watchdog.onUserMessage(sessionID, PRIMARY_MODEL, AGENT)
|
||||||
|
await wait(SAFE_WAIT_BEFORE_FIRE_MS)
|
||||||
|
observeEventForWatchdog(
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: { sessionID, field: "text", delta: "x" },
|
||||||
|
},
|
||||||
|
watchdog,
|
||||||
|
)
|
||||||
|
await wait(SAFE_WAIT_AFTER_FIRE_MS)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(calls.abort).toEqual([])
|
||||||
|
expect(calls.autoRetry).toEqual([])
|
||||||
|
|
||||||
|
watchdog.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it("#given the session is not a subagent #when a user message is observed #then the watchdog never arms and nothing fires", async () => {
|
it("#given the session is not a subagent #when a user message is observed #then the watchdog never arms and nothing fires", async () => {
|
||||||
// given
|
// given
|
||||||
const sessionID = "session-not-a-subagent"
|
const sessionID = "session-not-a-subagent"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { AutoRetryHelpers } from "./auto-retry"
|
|||||||
import { HOOK_NAME, DEFAULT_FIRST_PROMPT_WATCHDOG_MS } from "./constants"
|
import { HOOK_NAME, DEFAULT_FIRST_PROMPT_WATCHDOG_MS } from "./constants"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||||
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
import { isRecord } from "../../shared/record-type-guard"
|
||||||
import { createFallbackState } from "./fallback-state"
|
import { createFallbackState } from "./fallback-state"
|
||||||
import { getFallbackModelsForSession } from "./fallback-models"
|
import { getFallbackModelsForSession } from "./fallback-models"
|
||||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||||
@@ -43,25 +45,26 @@ export function observeEventForWatchdog(
|
|||||||
event: { type: string; properties?: unknown },
|
event: { type: string; properties?: unknown },
|
||||||
watchdog: FirstPromptWatchdog,
|
watchdog: FirstPromptWatchdog,
|
||||||
): void {
|
): void {
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
const props = isRecord(event.properties) ? event.properties : undefined
|
||||||
if (!props) return
|
if (!props) return
|
||||||
|
|
||||||
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
|
||||||
const sessionID = props.sessionID as string | undefined
|
const sessionID = resolveMessageEventSessionID(props)
|
||||||
const part = props.part as Record<string, unknown> | undefined
|
const part = isRecord(props.part) ? props.part : undefined
|
||||||
const partType = typeof part?.type === "string"
|
const hasPartType = typeof part?.type === "string"
|
||||||
? part.type
|
const hasTopLevelType = typeof props.type === "string"
|
||||||
: typeof props.type === "string" ? props.type : undefined
|
const hasTextDelta = props.field === "text" && typeof props.delta === "string"
|
||||||
if (sessionID && partType) {
|
const hasNonEmptySessionPart = typeof part?.sessionID === "string" && Object.keys(part).length > 0
|
||||||
|
if (sessionID && (hasPartType || hasTopLevelType || hasTextDelta || hasNonEmptySessionPart)) {
|
||||||
watchdog.onAssistantProgress(sessionID)
|
watchdog.onAssistantProgress(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
const info = props.info as Record<string, unknown> | undefined
|
const info = isRecord(props.info) ? props.info : undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
|
||||||
const role = info?.role as string | undefined
|
const role = typeof info?.role === "string" ? info.role : undefined
|
||||||
if (!sessionID || !role) return
|
if (!sessionID || !role) return
|
||||||
|
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
@@ -74,10 +77,10 @@ export function observeEventForWatchdog(
|
|||||||
if (role === "assistant") {
|
if (role === "assistant") {
|
||||||
const hasError = info?.error !== undefined
|
const hasError = info?.error !== undefined
|
||||||
const hasFinish = info?.finish !== undefined
|
const hasFinish = info?.finish !== undefined
|
||||||
const eventParts = props.parts as Array<{ type?: string }> | undefined
|
const eventParts = Array.isArray(props.parts) ? props.parts : undefined
|
||||||
const infoParts = info?.parts as Array<{ type?: string }> | undefined
|
const infoParts = Array.isArray(info?.parts) ? info.parts : undefined
|
||||||
const parts = eventParts ?? infoParts ?? []
|
const parts = eventParts ?? infoParts ?? []
|
||||||
const hasAnyPart = parts.some((part) => typeof part?.type === "string")
|
const hasAnyPart = parts.some((part) => isRecord(part) && typeof part.type === "string")
|
||||||
if (hasError || hasFinish || hasAnyPart) {
|
if (hasError || hasFinish || hasAnyPart) {
|
||||||
watchdog.onAssistantProgress(sessionID)
|
watchdog.onAssistantProgress(sessionID)
|
||||||
}
|
}
|
||||||
@@ -86,9 +89,7 @@ export function observeEventForWatchdog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (TERMINAL_EVENT_TYPES.has(event.type)) {
|
if (TERMINAL_EVENT_TYPES.has(event.type)) {
|
||||||
const sessionID =
|
const sessionID = resolveSessionEventID(props)
|
||||||
(props.sessionID as string | undefined) ??
|
|
||||||
((props.info as Record<string, unknown> | undefined)?.id as string | undefined)
|
|
||||||
if (sessionID) watchdog.onSessionTerminal(sessionID)
|
if (sessionID) watchdog.onSessionTerminal(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -751,6 +751,86 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given latest assistant turn has boolean finish === true #when checking blocks #then it is treated as terminal (NOT blocking)", async () => {
|
||||||
|
// given
|
||||||
|
let promptCalls = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { ses_boolean_finish: { type: "idle" } } }),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: { id: "msg_user", role: "user" },
|
||||||
|
parts: [{ type: "text", text: "run work" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { id: "msg_assistant", role: "assistant", finish: true },
|
||||||
|
parts: [{ type: "text", text: "done" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
promptAsync: async () => {
|
||||||
|
promptCalls += 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await dispatchInternalPrompt({
|
||||||
|
mode: "async",
|
||||||
|
client,
|
||||||
|
sessionID: "ses_boolean_finish",
|
||||||
|
input: { path: { id: "ses_boolean_finish" }, body: { parts: [] } },
|
||||||
|
source: "test:boolean-finish",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(promptCalls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given latest assistant turn has info.time.completed but no finish field #when checking blocks #then it is treated as terminal", async () => {
|
||||||
|
// given
|
||||||
|
let promptCalls = 0
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { ses_completed_time: { type: "idle" } } }),
|
||||||
|
messages: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: { id: "msg_user", role: "user" },
|
||||||
|
parts: [{ type: "text", text: "run work" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { id: "msg_assistant", role: "assistant", time: { completed: 1_762_000_000_000 } },
|
||||||
|
parts: [{ type: "text", text: "done" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
promptAsync: async () => {
|
||||||
|
promptCalls += 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await dispatchInternalPrompt({
|
||||||
|
mode: "async",
|
||||||
|
client,
|
||||||
|
sessionID: "ses_completed_time",
|
||||||
|
input: { path: { id: "ses_completed_time" }, body: { parts: [] } },
|
||||||
|
source: "test:completed-time",
|
||||||
|
settleMs: 0,
|
||||||
|
postDispatchHoldMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.status).toBe("dispatched")
|
||||||
|
expect(promptCalls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("#given internal user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
test("#given internal user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => {
|
||||||
// given
|
// given
|
||||||
let promptCalls = 0
|
let promptCalls = 0
|
||||||
|
|||||||
@@ -341,15 +341,36 @@ function messageRole(message: unknown): string | undefined {
|
|||||||
return typeof message.role === "string" ? message.role : undefined
|
return typeof message.role === "string" ? message.role : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageFinish(message: unknown): string | undefined {
|
function messageFinish(message: unknown): string | true | undefined {
|
||||||
if (!isRecord(message)) {
|
if (!isRecord(message)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const info = message.info
|
const info = message.info
|
||||||
if (isRecord(info) && typeof info.finish === "string") {
|
if (isRecord(info)) {
|
||||||
return info.finish
|
if (info.finish === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof info.finish === "string" && info.finish.length > 0) {
|
||||||
|
return info.finish
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return typeof message.finish === "string" ? message.finish : undefined
|
if (message.finish === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return typeof message.finish === "string" && message.finish.length > 0 ? message.finish : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageCompleted(message: unknown): boolean {
|
||||||
|
if (!isRecord(message)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const info = message.info
|
||||||
|
const time = isRecord(info) && isRecord(info.time) ? info.time : undefined
|
||||||
|
const completed = time?.completed
|
||||||
|
if (typeof completed === "number" && Number.isFinite(completed)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return typeof completed === "string" && completed.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function toInternalInitiatorTextPartLike(part: unknown): InternalInitiatorTextPartLike {
|
function toInternalInitiatorTextPartLike(part: unknown): InternalInitiatorTextPartLike {
|
||||||
@@ -419,7 +440,13 @@ function latestAssistantTurnBlocksInternalPrompt(messages: unknown[]): boolean {
|
|||||||
const message = messages[index]
|
const message = messages[index]
|
||||||
const role = messageRole(message)
|
const role = messageRole(message)
|
||||||
if (role === "assistant") {
|
if (role === "assistant") {
|
||||||
|
if (messageCompleted(message)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const finish = messageFinish(message)
|
const finish = messageFinish(message)
|
||||||
|
if (finish === true) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (finish === undefined || finish === "unknown") {
|
if (finish === undefined || finish === "unknown") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user